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/agent_hooks.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from tau.runtime.service import Runtime
|
|
9
|
+
from tau.tui.components.layout import Layout
|
|
10
|
+
from tau.tui.components.message_list import MessageBlock
|
|
11
|
+
from tau.tui.tui import TUI
|
|
12
|
+
|
|
13
|
+
# Flush streamed tokens to the block at most once per frame (~60fps).
|
|
14
|
+
# Markdown is re-parsed only on each flush, not on every token.
|
|
15
|
+
_STREAM_FLUSH_INTERVAL = 1 / 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_component(root: object, attr: str) -> object | None:
|
|
19
|
+
"""Depth-first search the component tree for one exposing ``attr``."""
|
|
20
|
+
if root is None:
|
|
21
|
+
return None
|
|
22
|
+
if hasattr(root, attr):
|
|
23
|
+
return root
|
|
24
|
+
for child in (getattr(root, "children", None) or []):
|
|
25
|
+
found = _find_component(child, attr)
|
|
26
|
+
if found is not None:
|
|
27
|
+
return found
|
|
28
|
+
for slot in (getattr(root, "_slots", None) or []):
|
|
29
|
+
comp = slot[0] if isinstance(slot, tuple) else slot
|
|
30
|
+
found = _find_component(comp, attr)
|
|
31
|
+
if found is not None:
|
|
32
|
+
return found
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentHookHandler:
|
|
37
|
+
"""Subscribes to agent lifecycle hooks and drives the TUI in response.
|
|
38
|
+
|
|
39
|
+
Owns all streaming state (current block, terminal block) so App does not
|
|
40
|
+
need to track it. Call ``subscribe()`` after the agent is ready, then
|
|
41
|
+
``unsubscribe()`` (or use the returned unsub callables) on teardown.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, runtime: Runtime, layout: Layout, tui: TUI,
|
|
45
|
+
on_palette_refresh: Callable[[], None] | None = None,
|
|
46
|
+
on_turn_content: Callable[[], None] | None = None) -> None:
|
|
47
|
+
self._runtime = runtime
|
|
48
|
+
self._layout = layout
|
|
49
|
+
self._tui = tui
|
|
50
|
+
self._on_palette_refresh = on_palette_refresh
|
|
51
|
+
self._on_turn_content = on_turn_content
|
|
52
|
+
|
|
53
|
+
self._current_block: MessageBlock | None = None
|
|
54
|
+
self._current_text_length: int = 0
|
|
55
|
+
self._current_terminal_block: MessageBlock | None = None
|
|
56
|
+
self._unsubs: list[Callable[[], None]] = []
|
|
57
|
+
|
|
58
|
+
# Streaming batch state — pending token flush
|
|
59
|
+
self._pending_msg: object = None
|
|
60
|
+
self._pending_flush_handle: asyncio.TimerHandle | None = None
|
|
61
|
+
self._last_flush_at: float = 0.0
|
|
62
|
+
|
|
63
|
+
def subscribe(self) -> None:
|
|
64
|
+
"""Register all hook handlers on the current agent."""
|
|
65
|
+
agent = self._runtime.agent
|
|
66
|
+
if agent is None:
|
|
67
|
+
return
|
|
68
|
+
hooks = agent.hooks
|
|
69
|
+
self._unsubs = [
|
|
70
|
+
hooks.register("agent_start", self._on_agent_start),
|
|
71
|
+
hooks.register("agent_end", self._on_agent_end),
|
|
72
|
+
hooks.register("settled", self._on_settled),
|
|
73
|
+
hooks.register("message_start", self._on_message_start),
|
|
74
|
+
hooks.register("message_update", self._on_message_update),
|
|
75
|
+
hooks.register("message_end", self._on_message_end),
|
|
76
|
+
hooks.register("message_rollback", self._on_message_rollback),
|
|
77
|
+
hooks.register("tool_execution_start", self._on_tool_start),
|
|
78
|
+
hooks.register("tool_execution_end", self._on_tool_end),
|
|
79
|
+
hooks.register("model_select", self._on_model_select),
|
|
80
|
+
hooks.register("terminal_execution", self._on_terminal_execution),
|
|
81
|
+
hooks.register("terminal_output", self._on_terminal_output),
|
|
82
|
+
hooks.register("session_start", self._on_session_start),
|
|
83
|
+
hooks.register("queue_update", self._on_queue_update),
|
|
84
|
+
hooks.register("compaction_start", self._on_compaction_start),
|
|
85
|
+
hooks.register("compaction_end", self._on_compaction_end),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def unsubscribe(self) -> None:
|
|
89
|
+
for unsub in self._unsubs:
|
|
90
|
+
unsub()
|
|
91
|
+
self._unsubs.clear()
|
|
92
|
+
|
|
93
|
+
# ── Session ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async def _on_session_start(self, event: object) -> None:
|
|
96
|
+
from tau.hooks.session import SessionStartReason
|
|
97
|
+
reason = getattr(event, "reason", None)
|
|
98
|
+
# Reasons that swap in a different message history: Fork/Resume/Clone
|
|
99
|
+
# replay the new branch into the transcript; New just clears it.
|
|
100
|
+
replay = reason in (
|
|
101
|
+
SessionStartReason.Fork,
|
|
102
|
+
SessionStartReason.Resume,
|
|
103
|
+
SessionStartReason.Clone,
|
|
104
|
+
)
|
|
105
|
+
if reason == SessionStartReason.New or replay:
|
|
106
|
+
self._layout.clear_messages()
|
|
107
|
+
self._layout.spinner.stop()
|
|
108
|
+
self._current_block = None
|
|
109
|
+
self._current_text_length = 0
|
|
110
|
+
self._current_terminal_block = None
|
|
111
|
+
if replay:
|
|
112
|
+
sm = self._runtime.session_manager
|
|
113
|
+
if sm is not None:
|
|
114
|
+
ctx = sm.build_session_context()
|
|
115
|
+
for msg in ctx.messages:
|
|
116
|
+
self._layout.add_message(msg)
|
|
117
|
+
sm = self._runtime.session_manager
|
|
118
|
+
if sm is not None:
|
|
119
|
+
self._layout.set_cwd(sm.cwd)
|
|
120
|
+
# Resume/branch swap in a different message history but run no agent turn,
|
|
121
|
+
# so the footer's context-usage badge would otherwise keep the previous
|
|
122
|
+
# session's value. Push fresh usage to it from this guaranteed refresh point.
|
|
123
|
+
self._refresh_footer_context()
|
|
124
|
+
if self._on_palette_refresh is not None:
|
|
125
|
+
self._on_palette_refresh()
|
|
126
|
+
self._tui.request_render()
|
|
127
|
+
|
|
128
|
+
def _refresh_footer_context(self) -> None:
|
|
129
|
+
"""Re-push current context usage to the footer model badge, if present."""
|
|
130
|
+
footer = getattr(self._layout, "footer", None)
|
|
131
|
+
badge = _find_component(footer, "update_context_from_ctx")
|
|
132
|
+
update = getattr(badge, "update_context_from_ctx", None)
|
|
133
|
+
if not callable(update):
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
from tau.extensions.context import ExtensionContext
|
|
137
|
+
update(ExtensionContext.from_runtime(self._runtime))
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# ── Agent lifecycle ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async def _on_agent_start(self, _event: object) -> None:
|
|
144
|
+
self._spinner(self._layout.spinner._theme.label_thinking, running=True)
|
|
145
|
+
|
|
146
|
+
async def _on_agent_end(self, _event: object) -> None:
|
|
147
|
+
self._spinner(running=False)
|
|
148
|
+
|
|
149
|
+
async def _on_settled(self, _event: object) -> None:
|
|
150
|
+
self._layout.set_pending_queue([], [])
|
|
151
|
+
self._spinner(running=False)
|
|
152
|
+
|
|
153
|
+
async def _on_compaction_start(self, _event: object) -> None:
|
|
154
|
+
self._spinner(self._layout.spinner._theme.label_compacting, running=True)
|
|
155
|
+
|
|
156
|
+
async def _on_compaction_end(self, _event: object) -> None:
|
|
157
|
+
self._spinner(running=False)
|
|
158
|
+
|
|
159
|
+
# ── Messages ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
async def _on_message_start(self, event: object) -> None:
|
|
162
|
+
msg = getattr(event, "message", None)
|
|
163
|
+
if msg is None:
|
|
164
|
+
return
|
|
165
|
+
self._layout.spinner.set_label(self._layout.spinner._theme.label_thinking)
|
|
166
|
+
block = self._layout.add_message(msg, streaming=False)
|
|
167
|
+
self._current_block = block
|
|
168
|
+
self._current_text_length = _text_length(msg)
|
|
169
|
+
self._tui.request_render()
|
|
170
|
+
|
|
171
|
+
async def _on_message_update(self, event: object) -> None:
|
|
172
|
+
from tau.message.types import ThinkingContent, TextContent
|
|
173
|
+
msg = getattr(event, "message", None)
|
|
174
|
+
if msg is None or self._current_block is None:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# Update spinner label immediately — zero cost.
|
|
178
|
+
contents = getattr(msg, "contents", [])
|
|
179
|
+
if contents:
|
|
180
|
+
self._mark_turn_content()
|
|
181
|
+
last = contents[-1]
|
|
182
|
+
if isinstance(last, ThinkingContent):
|
|
183
|
+
self._layout.spinner.set_label(self._layout.spinner._theme.label_thinking)
|
|
184
|
+
elif isinstance(last, TextContent) and last.content:
|
|
185
|
+
self._layout.spinner.set_label(self._layout.spinner._theme.label_streaming)
|
|
186
|
+
|
|
187
|
+
# Buffer the latest message; schedule a flush if none pending.
|
|
188
|
+
self._pending_msg = msg
|
|
189
|
+
if self._pending_flush_handle is None:
|
|
190
|
+
elapsed = time.monotonic() - self._last_flush_at
|
|
191
|
+
delay = max(0.0, _STREAM_FLUSH_INTERVAL - elapsed)
|
|
192
|
+
loop = asyncio.get_event_loop()
|
|
193
|
+
self._pending_flush_handle = loop.call_later(delay, self._flush_pending)
|
|
194
|
+
|
|
195
|
+
def _flush_pending(self) -> None:
|
|
196
|
+
"""Flush the buffered token batch: re-parse markdown once, then render."""
|
|
197
|
+
self._pending_flush_handle = None
|
|
198
|
+
msg = self._pending_msg
|
|
199
|
+
self._pending_msg = None
|
|
200
|
+
if msg is None or self._current_block is None:
|
|
201
|
+
return
|
|
202
|
+
tl = _text_length(msg)
|
|
203
|
+
self._update_block(msg, streaming=tl > self._current_text_length)
|
|
204
|
+
self._current_text_length = tl
|
|
205
|
+
self._last_flush_at = time.monotonic()
|
|
206
|
+
self._tui.request_render()
|
|
207
|
+
|
|
208
|
+
async def _on_message_end(self, event: object) -> None:
|
|
209
|
+
from tau.message.types import AssistantMessage, ToolMessage
|
|
210
|
+
# Cancel any pending batch flush — message_end supersedes it.
|
|
211
|
+
if self._pending_flush_handle is not None:
|
|
212
|
+
self._pending_flush_handle.cancel()
|
|
213
|
+
self._pending_flush_handle = None
|
|
214
|
+
self._pending_msg = None
|
|
215
|
+
self._last_flush_at = 0.0
|
|
216
|
+
|
|
217
|
+
msg = getattr(event, "message", None)
|
|
218
|
+
if msg is None:
|
|
219
|
+
return
|
|
220
|
+
if isinstance(msg, (AssistantMessage, ToolMessage)):
|
|
221
|
+
self._mark_turn_content()
|
|
222
|
+
if self._current_block is not None:
|
|
223
|
+
self._update_block(msg, streaming=False, clear=True)
|
|
224
|
+
else:
|
|
225
|
+
self._layout.add_message(msg)
|
|
226
|
+
self._tui.request_render()
|
|
227
|
+
|
|
228
|
+
async def _on_message_rollback(self, event: object) -> None:
|
|
229
|
+
"""Remove the last ``count`` message blocks from the transcript.
|
|
230
|
+
|
|
231
|
+
Mirrors the engine dropping an interrupted tool turn: cancel any pending
|
|
232
|
+
flush, drop the assistant tool-call block and its tool-result block, and
|
|
233
|
+
reset the streaming cursor so the following interrupt marker renders fresh.
|
|
234
|
+
"""
|
|
235
|
+
count = getattr(event, "count", 0)
|
|
236
|
+
if self._pending_flush_handle is not None:
|
|
237
|
+
self._pending_flush_handle.cancel()
|
|
238
|
+
self._pending_flush_handle = None
|
|
239
|
+
self._pending_msg = None
|
|
240
|
+
for _ in range(count):
|
|
241
|
+
if not self._layout.messages.remove_last():
|
|
242
|
+
break
|
|
243
|
+
self._current_block = None
|
|
244
|
+
self._current_text_length = 0
|
|
245
|
+
self._tui.request_render()
|
|
246
|
+
|
|
247
|
+
# ── Tools ─────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
async def _on_tool_start(self, _event: object) -> None:
|
|
250
|
+
self._mark_turn_content()
|
|
251
|
+
self._spinner(self._layout.spinner._theme.label_tool_calling)
|
|
252
|
+
|
|
253
|
+
async def _on_tool_end(self, _event: object) -> None:
|
|
254
|
+
self._spinner(self._layout.spinner._theme.label_thinking)
|
|
255
|
+
|
|
256
|
+
# ── Terminal ──────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
async def _on_terminal_execution(self, event: object) -> None:
|
|
259
|
+
msg = getattr(event, "message", None)
|
|
260
|
+
streaming = getattr(event, "streaming", False)
|
|
261
|
+
if msg is None:
|
|
262
|
+
return
|
|
263
|
+
if streaming:
|
|
264
|
+
block = self._layout.add_message(msg, streaming=True)
|
|
265
|
+
self._current_terminal_block = block
|
|
266
|
+
else:
|
|
267
|
+
if self._current_terminal_block is not None:
|
|
268
|
+
self._current_terminal_block.set_streaming(False)
|
|
269
|
+
self._current_terminal_block = None
|
|
270
|
+
self._tui.request_render()
|
|
271
|
+
|
|
272
|
+
async def _on_terminal_output(self, _event: object) -> None:
|
|
273
|
+
if self._current_terminal_block is not None:
|
|
274
|
+
self._current_terminal_block.invalidate()
|
|
275
|
+
self._tui.request_render()
|
|
276
|
+
|
|
277
|
+
# ── Model / queue ─────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
async def _on_model_select(self, _event: object) -> None:
|
|
280
|
+
if self._on_palette_refresh is not None:
|
|
281
|
+
self._on_palette_refresh()
|
|
282
|
+
self._tui.request_render()
|
|
283
|
+
|
|
284
|
+
async def _on_queue_update(self, event: object) -> None:
|
|
285
|
+
from tau.message.types import TextContent
|
|
286
|
+
|
|
287
|
+
engine_state = None
|
|
288
|
+
agent = self._runtime.agent
|
|
289
|
+
if agent is not None:
|
|
290
|
+
engine_state = agent._engine.state
|
|
291
|
+
|
|
292
|
+
steering: list[str] = []
|
|
293
|
+
followup: list[str] = []
|
|
294
|
+
|
|
295
|
+
if engine_state is not None:
|
|
296
|
+
for queue, out in [
|
|
297
|
+
(engine_state.steering_queue, steering),
|
|
298
|
+
(engine_state.follow_up_queue, followup),
|
|
299
|
+
]:
|
|
300
|
+
if queue:
|
|
301
|
+
for msg in queue.snapshot():
|
|
302
|
+
text = "".join(
|
|
303
|
+
c.content for c in getattr(msg, "contents", [])
|
|
304
|
+
if isinstance(c, TextContent)
|
|
305
|
+
)
|
|
306
|
+
if text:
|
|
307
|
+
out.append(text)
|
|
308
|
+
|
|
309
|
+
self._layout.set_pending_queue(steering, followup)
|
|
310
|
+
self._tui.request_render()
|
|
311
|
+
|
|
312
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
def _mark_turn_content(self) -> None:
|
|
315
|
+
"""Tell the input handler the assistant has produced output this turn."""
|
|
316
|
+
if self._on_turn_content is not None:
|
|
317
|
+
self._on_turn_content()
|
|
318
|
+
|
|
319
|
+
def _spinner(self, label: str | None = None, *, running: bool | None = None) -> None:
|
|
320
|
+
if label is not None:
|
|
321
|
+
self._layout.spinner.set_label(label)
|
|
322
|
+
if running is True:
|
|
323
|
+
self._layout.spinner.start()
|
|
324
|
+
elif running is False:
|
|
325
|
+
self._layout.spinner.stop()
|
|
326
|
+
self._tui.request_render()
|
|
327
|
+
|
|
328
|
+
def _update_block(self, msg: object, *, streaming: bool, clear: bool = False) -> None:
|
|
329
|
+
if self._current_block is None:
|
|
330
|
+
return
|
|
331
|
+
self._current_block._message = msg
|
|
332
|
+
self._current_block.set_streaming(streaming)
|
|
333
|
+
self._current_block.invalidate()
|
|
334
|
+
if clear:
|
|
335
|
+
self._current_block = None
|
|
336
|
+
self._current_text_length = 0
|
|
337
|
+
|
|
338
|
+
def _refresh_model_badge(self) -> None:
|
|
339
|
+
if self._on_palette_refresh is not None:
|
|
340
|
+
self._on_palette_refresh()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _text_length(message: object) -> int:
|
|
344
|
+
from tau.message.types import TextContent
|
|
345
|
+
contents = getattr(message, "contents", [])
|
|
346
|
+
return sum(len(item.content) for item in contents if isinstance(item, TextContent))
|
tau/tui/ansi.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
|
|
6
|
+
# ── Regex to strip all ANSI escape sequences ─────────────────────────────────
|
|
7
|
+
_ANSI_RE = re.compile(
|
|
8
|
+
r"\x1b(?:"
|
|
9
|
+
r"\[[0-9;:<=>?]*[ -/]*[@-~]" # CSI sequences
|
|
10
|
+
r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences
|
|
11
|
+
r"|_[^\x1b]*(?:\x1b\\|\x07)" # APC sequences
|
|
12
|
+
r"|[PX^][^\x1b]*\x1b\\" # DCS / PM / SOS
|
|
13
|
+
r"|[@-_]" # 2-char Fe sequences (ESC @-_)
|
|
14
|
+
r")"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# ── Cursor marker ────────────────────────────────────────────────────────────
|
|
18
|
+
# An APC sequence (zero visible width, stripped by _ANSI_RE) injected into a
|
|
19
|
+
# rendered line to tell the Renderer where to position the hardware cursor for
|
|
20
|
+
# IME candidate windows. TextInput inserts it; Renderer removes it and moves
|
|
21
|
+
# the real terminal cursor to the marked column.
|
|
22
|
+
CURSOR_MARKER = "\x1b_C\x1b\\"
|
|
23
|
+
|
|
24
|
+
# ── SGR (colour / style) ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
RESET = "\x1b[0m"
|
|
27
|
+
BOLD = "\x1b[1m"
|
|
28
|
+
DIM = "\x1b[2m"
|
|
29
|
+
ITALIC = "\x1b[3m"
|
|
30
|
+
UNDERLINE = "\x1b[4m"
|
|
31
|
+
BLINK = "\x1b[5m"
|
|
32
|
+
REVERSE = "\x1b[7m"
|
|
33
|
+
STRIKE = "\x1b[9m"
|
|
34
|
+
|
|
35
|
+
# Standard foreground colours
|
|
36
|
+
BLACK = "\x1b[30m"
|
|
37
|
+
RED = "\x1b[31m"
|
|
38
|
+
GREEN = "\x1b[32m"
|
|
39
|
+
YELLOW = "\x1b[33m"
|
|
40
|
+
BLUE = "\x1b[34m"
|
|
41
|
+
MAGENTA = "\x1b[35m"
|
|
42
|
+
CYAN = "\x1b[36m"
|
|
43
|
+
WHITE = "\x1b[37m"
|
|
44
|
+
DEFAULT = "\x1b[39m"
|
|
45
|
+
|
|
46
|
+
# Bright foreground colours
|
|
47
|
+
BRIGHT_BLACK = "\x1b[90m"
|
|
48
|
+
BRIGHT_RED = "\x1b[91m"
|
|
49
|
+
BRIGHT_GREEN = "\x1b[92m"
|
|
50
|
+
BRIGHT_YELLOW = "\x1b[93m"
|
|
51
|
+
BRIGHT_BLUE = "\x1b[94m"
|
|
52
|
+
BRIGHT_MAGENTA = "\x1b[95m"
|
|
53
|
+
BRIGHT_CYAN = "\x1b[96m"
|
|
54
|
+
BRIGHT_WHITE = "\x1b[97m"
|
|
55
|
+
|
|
56
|
+
# Standard background colours
|
|
57
|
+
BG_BLACK = "\x1b[40m"
|
|
58
|
+
BG_RED = "\x1b[41m"
|
|
59
|
+
BG_GREEN = "\x1b[42m"
|
|
60
|
+
BG_YELLOW = "\x1b[43m"
|
|
61
|
+
BG_BLUE = "\x1b[44m"
|
|
62
|
+
BG_MAGENTA = "\x1b[45m"
|
|
63
|
+
BG_CYAN = "\x1b[46m"
|
|
64
|
+
BG_WHITE = "\x1b[47m"
|
|
65
|
+
BG_DEFAULT = "\x1b[49m"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fg(r: int, g: int, b: int) -> str:
|
|
69
|
+
"""Truecolor foreground."""
|
|
70
|
+
return f"\x1b[38;2;{r};{g};{b}m"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bg(r: int, g: int, b: int) -> str:
|
|
74
|
+
"""Truecolor background."""
|
|
75
|
+
return f"\x1b[48;2;{r};{g};{b}m"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fg256(code: int) -> str:
|
|
79
|
+
"""256-colour foreground (0–255)."""
|
|
80
|
+
return f"\x1b[38;5;{code}m"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bg256(code: int) -> str:
|
|
84
|
+
"""256-colour background (0–255)."""
|
|
85
|
+
return f"\x1b[48;5;{code}m"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def style(text: str, *codes: str) -> str:
|
|
89
|
+
"""Wrap text with one or more SGR codes, reset at end."""
|
|
90
|
+
return "".join(codes) + text + RESET
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Width calculation ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _char_width(ch: str) -> int:
|
|
96
|
+
"""Return the terminal column width of a single character."""
|
|
97
|
+
cp = ord(ch)
|
|
98
|
+
# Null byte / C0 / C1 control chars
|
|
99
|
+
if cp == 0 or (0x007F <= cp <= 0x009F):
|
|
100
|
+
return 0
|
|
101
|
+
eaw = unicodedata.east_asian_width(ch)
|
|
102
|
+
if eaw in ("W", "F"):
|
|
103
|
+
return 2
|
|
104
|
+
if eaw == "Na" or unicodedata.category(ch) in ("Mn", "Me", "Cf"):
|
|
105
|
+
# Combining marks and format chars have zero width
|
|
106
|
+
if unicodedata.category(ch) in ("Mn", "Me", "Cf"):
|
|
107
|
+
return 0
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def visible_width(text: str) -> int:
|
|
112
|
+
"""Return the number of terminal columns the string will occupy."""
|
|
113
|
+
# Fast path: pure ASCII printable
|
|
114
|
+
if text.isascii() and text.isprintable():
|
|
115
|
+
return len(text)
|
|
116
|
+
stripped = strip_ansi(text)
|
|
117
|
+
return sum(_char_width(ch) for ch in stripped)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Strip ─────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def strip_ansi(text: str) -> str:
|
|
123
|
+
"""Remove all ANSI escape sequences from text."""
|
|
124
|
+
return _ANSI_RE.sub("", text)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Truncation ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def truncate(text: str, max_width: int, ellipsis: str = "…") -> str:
|
|
130
|
+
"""
|
|
131
|
+
Truncate text to max_width columns, preserving ANSI codes.
|
|
132
|
+
Appends ellipsis if truncation occurred.
|
|
133
|
+
"""
|
|
134
|
+
if visible_width(text) <= max_width:
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
ellipsis_w = visible_width(ellipsis)
|
|
138
|
+
target = max_width - ellipsis_w
|
|
139
|
+
result, _ = _take_columns(text, target)
|
|
140
|
+
return result + RESET + ellipsis + RESET
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pad(text: str, width: int, char: str = " ", align: str = "left") -> str:
|
|
144
|
+
"""
|
|
145
|
+
Pad text to exactly width columns with char.
|
|
146
|
+
align: 'left' | 'right' | 'center'
|
|
147
|
+
"""
|
|
148
|
+
current = visible_width(text)
|
|
149
|
+
deficit = max(0, width - current)
|
|
150
|
+
if align == "right":
|
|
151
|
+
return char * deficit + text
|
|
152
|
+
if align == "center":
|
|
153
|
+
left = deficit // 2
|
|
154
|
+
right = deficit - left
|
|
155
|
+
return char * left + text + char * right
|
|
156
|
+
return text + char * deficit
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── Wrapping ──────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def wrap(text: str, width: int) -> list[str]:
|
|
162
|
+
"""
|
|
163
|
+
Wrap text to width columns, preserving ANSI escape codes across line breaks.
|
|
164
|
+
Hard newlines in the source are respected.
|
|
165
|
+
"""
|
|
166
|
+
if width <= 0:
|
|
167
|
+
return [text]
|
|
168
|
+
|
|
169
|
+
result: list[str] = []
|
|
170
|
+
for source_line in text.split("\n"):
|
|
171
|
+
result.extend(_wrap_single_line(source_line, width))
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _wrap_single_line(line: str, width: int) -> list[str]:
|
|
176
|
+
"""Wrap a single line (no embedded newlines) to width columns."""
|
|
177
|
+
if visible_width(line) <= width:
|
|
178
|
+
return [line]
|
|
179
|
+
|
|
180
|
+
lines: list[str] = []
|
|
181
|
+
tracker = _AnsiStateTracker()
|
|
182
|
+
remaining = line
|
|
183
|
+
|
|
184
|
+
while remaining:
|
|
185
|
+
chunk, remaining = _split_at_columns(remaining, width, tracker)
|
|
186
|
+
lines.append(chunk)
|
|
187
|
+
|
|
188
|
+
return lines
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _split_at_columns(text: str, width: int, tracker: _AnsiStateTracker) -> tuple[str, str]:
|
|
192
|
+
"""
|
|
193
|
+
Split text into (head, tail) where head fits within width columns.
|
|
194
|
+
Tracker maintains active SGR state so head starts with the right codes
|
|
195
|
+
and tail can be resumed correctly.
|
|
196
|
+
"""
|
|
197
|
+
prefix = tracker.active_codes()
|
|
198
|
+
taken, col = "", 0
|
|
199
|
+
i = 0
|
|
200
|
+
|
|
201
|
+
while i < len(text):
|
|
202
|
+
# Check for ANSI escape at current position
|
|
203
|
+
m = _ANSI_RE.match(text, i)
|
|
204
|
+
if m:
|
|
205
|
+
code = m.group(0)
|
|
206
|
+
tracker.process(code)
|
|
207
|
+
taken += code
|
|
208
|
+
i += len(code)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
ch = text[i]
|
|
212
|
+
w = _char_width(ch)
|
|
213
|
+
if col + w > width:
|
|
214
|
+
break
|
|
215
|
+
taken += ch
|
|
216
|
+
col += w
|
|
217
|
+
i += 1
|
|
218
|
+
|
|
219
|
+
head = prefix + taken + (RESET if tracker.has_state() else "")
|
|
220
|
+
tail = text[i:]
|
|
221
|
+
return head, tail
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _take_columns(text: str, max_cols: int) -> tuple[str, int]:
|
|
225
|
+
"""Return (text_up_to_max_cols, actual_cols_taken), preserving ANSI codes."""
|
|
226
|
+
result, col = "", 0
|
|
227
|
+
i = 0
|
|
228
|
+
while i < len(text):
|
|
229
|
+
m = _ANSI_RE.match(text, i)
|
|
230
|
+
if m:
|
|
231
|
+
result += m.group(0)
|
|
232
|
+
i += len(m.group(0))
|
|
233
|
+
continue
|
|
234
|
+
ch = text[i]
|
|
235
|
+
w = _char_width(ch)
|
|
236
|
+
if col + w > max_cols:
|
|
237
|
+
break
|
|
238
|
+
result += ch
|
|
239
|
+
col += w
|
|
240
|
+
i += 1
|
|
241
|
+
return result, col
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── ANSI state tracker ────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
class _AnsiStateTracker:
|
|
247
|
+
"""
|
|
248
|
+
Tracks active SGR (colour/style) state so it can be re-applied after
|
|
249
|
+
a line break during wrapping.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
self._bold = False
|
|
254
|
+
self._dim = False
|
|
255
|
+
self._italic = False
|
|
256
|
+
self._underline = False
|
|
257
|
+
self._fg: str | None = None
|
|
258
|
+
self._bg: str | None = None
|
|
259
|
+
|
|
260
|
+
def process(self, code: str) -> None:
|
|
261
|
+
m = re.match(r"\x1b\[([\d;]*)m$", code)
|
|
262
|
+
if not m:
|
|
263
|
+
return
|
|
264
|
+
params = m.group(1)
|
|
265
|
+
if not params or params == "0":
|
|
266
|
+
self._reset()
|
|
267
|
+
return
|
|
268
|
+
nums = [int(x) for x in params.split(";") if x]
|
|
269
|
+
i = 0
|
|
270
|
+
while i < len(nums):
|
|
271
|
+
n = nums[i]
|
|
272
|
+
if n == 0:
|
|
273
|
+
self._reset()
|
|
274
|
+
elif n == 1:
|
|
275
|
+
self._bold = True
|
|
276
|
+
elif n == 2:
|
|
277
|
+
self._dim = True
|
|
278
|
+
elif n == 3:
|
|
279
|
+
self._italic = True
|
|
280
|
+
elif n == 4:
|
|
281
|
+
self._underline = True
|
|
282
|
+
elif n == 22:
|
|
283
|
+
self._bold = self._dim = False
|
|
284
|
+
elif n == 23:
|
|
285
|
+
self._italic = False
|
|
286
|
+
elif n == 24:
|
|
287
|
+
self._underline = False
|
|
288
|
+
elif 30 <= n <= 37 or n == 39 or 90 <= n <= 97:
|
|
289
|
+
self._fg = f"\x1b[{n}m"
|
|
290
|
+
elif n == 38 and i + 2 < len(nums) and nums[i + 1] == 5:
|
|
291
|
+
self._fg = f"\x1b[38;5;{nums[i + 2]}m"
|
|
292
|
+
i += 2
|
|
293
|
+
elif n == 38 and i + 4 < len(nums) and nums[i + 1] == 2:
|
|
294
|
+
self._fg = f"\x1b[38;2;{nums[i+2]};{nums[i+3]};{nums[i+4]}m"
|
|
295
|
+
i += 4
|
|
296
|
+
elif 40 <= n <= 47 or n == 49 or 100 <= n <= 107:
|
|
297
|
+
self._bg = f"\x1b[{n}m"
|
|
298
|
+
elif n == 48 and i + 2 < len(nums) and nums[i + 1] == 5:
|
|
299
|
+
self._bg = f"\x1b[48;5;{nums[i + 2]}m"
|
|
300
|
+
i += 2
|
|
301
|
+
elif n == 48 and i + 4 < len(nums) and nums[i + 1] == 2:
|
|
302
|
+
self._bg = f"\x1b[48;2;{nums[i+2]};{nums[i+3]};{nums[i+4]}m"
|
|
303
|
+
i += 4
|
|
304
|
+
i += 1
|
|
305
|
+
|
|
306
|
+
def _reset(self) -> None:
|
|
307
|
+
self._bold = self._dim = self._italic = self._underline = False
|
|
308
|
+
self._fg = self._bg = None
|
|
309
|
+
|
|
310
|
+
def has_state(self) -> bool:
|
|
311
|
+
return bool(self._bold or self._dim or self._italic or
|
|
312
|
+
self._underline or self._fg or self._bg)
|
|
313
|
+
|
|
314
|
+
def active_codes(self) -> str:
|
|
315
|
+
if not self.has_state():
|
|
316
|
+
return ""
|
|
317
|
+
parts = []
|
|
318
|
+
if self._bold:
|
|
319
|
+
parts.append(BOLD)
|
|
320
|
+
if self._dim:
|
|
321
|
+
parts.append(DIM)
|
|
322
|
+
if self._italic:
|
|
323
|
+
parts.append(ITALIC)
|
|
324
|
+
if self._underline:
|
|
325
|
+
parts.append(UNDERLINE)
|
|
326
|
+
if self._fg:
|
|
327
|
+
parts.append(self._fg)
|
|
328
|
+
if self._bg:
|
|
329
|
+
parts.append(self._bg)
|
|
330
|
+
return "".join(parts)
|