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/renderer.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tau.tui.ansi import (
|
|
4
|
+
CURSOR_MARKER, RESET, visible_width, truncate,
|
|
5
|
+
_ANSI_RE, _char_width, # type: ignore[attr-defined]
|
|
6
|
+
)
|
|
7
|
+
from tau.tui.terminal import Terminal
|
|
8
|
+
from tau.tui.component import Component
|
|
9
|
+
|
|
10
|
+
_CURSOR_MARKER_LEN = len(CURSOR_MARKER)
|
|
11
|
+
|
|
12
|
+
# Blank columns reserved on the left/right edges of the terminal so content
|
|
13
|
+
# never touches the window border.
|
|
14
|
+
_LEFT_PAD = 1
|
|
15
|
+
_RIGHT_PAD = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Renderer:
|
|
19
|
+
"""
|
|
20
|
+
Scrollback-mode differential renderer.
|
|
21
|
+
|
|
22
|
+
Renders into the main terminal buffer — no alternate screen. Content
|
|
23
|
+
grows downward; old lines scroll into the terminal's native scrollback
|
|
24
|
+
buffer so the user can scroll back with the terminal's own scrollbar.
|
|
25
|
+
|
|
26
|
+
Positioning uses only relative cursor moves (ESC[NA / ESC[NB) so the
|
|
27
|
+
terminal's own scroll state is never disrupted. Overlays are composited
|
|
28
|
+
directly into the content lines before the diff, keeping a single
|
|
29
|
+
rendering pass.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, terminal: Terminal, show_hardware_cursor: bool = False) -> None:
|
|
33
|
+
self._terminal = terminal
|
|
34
|
+
self._show_hardware_cursor = show_hardware_cursor
|
|
35
|
+
self._prev_lines: list[str] = []
|
|
36
|
+
self._cursor_row: int = 0 # logical index of last rendered line
|
|
37
|
+
self._hw_cursor_row: int = 0 # where the terminal cursor actually is
|
|
38
|
+
self._viewport_top: int = 0 # first logical line visible on screen
|
|
39
|
+
self._max_lines: int = 0
|
|
40
|
+
self._prev_width: int = 0
|
|
41
|
+
self._prev_height: int = 0
|
|
42
|
+
self._resized: bool = False
|
|
43
|
+
# Memoizes the width-clamp of each line (line -> clamped line). Unchanged
|
|
44
|
+
# blocks emit stable string objects, so this skips the costly visible_width
|
|
45
|
+
# ANSI scan for every line that didn't change since the last frame.
|
|
46
|
+
self._clamp_cache: dict[str, str] = {}
|
|
47
|
+
self._clamp_cache_width: int = 0
|
|
48
|
+
terminal.on_resize(self._on_resize)
|
|
49
|
+
|
|
50
|
+
# -------------------------------------------------------------------------
|
|
51
|
+
# Public API
|
|
52
|
+
# -------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def render(self, component: Component, overlays: list | None = None) -> None:
|
|
55
|
+
"""Render component differentially into the terminal scrollback buffer."""
|
|
56
|
+
width = self._terminal.width - _LEFT_PAD - _RIGHT_PAD
|
|
57
|
+
height = self._terminal.height
|
|
58
|
+
width_changed = self._resized or (self._prev_width != 0 and self._prev_width != width)
|
|
59
|
+
self._resized = False
|
|
60
|
+
|
|
61
|
+
new_lines: list[str] = component.render(width)
|
|
62
|
+
|
|
63
|
+
# Clamp every line to the terminal width. A logical line wider than
|
|
64
|
+
# `width` causes the terminal to auto-wrap onto extra physical rows,
|
|
65
|
+
# which desynchronises _hw_cursor_row (a logical index) from the real
|
|
66
|
+
# hardware cursor row (counted in physical rows). That divergence makes
|
|
67
|
+
# the differential render write to the wrong rows, producing the
|
|
68
|
+
# duplicated-lines artifact that disappears on resize (full-clear redraw).
|
|
69
|
+
#
|
|
70
|
+
# `visible_width` runs an ANSI regex per line, which dominates frame cost
|
|
71
|
+
# on long transcripts (every line, every frame). Memoize the clamp by line
|
|
72
|
+
# value, rebuilt each frame so it stays bounded to the visible content —
|
|
73
|
+
# unchanged lines (stable cached-block strings) hit the cache and skip the
|
|
74
|
+
# scan entirely.
|
|
75
|
+
if width != self._clamp_cache_width:
|
|
76
|
+
self._clamp_cache = {}
|
|
77
|
+
self._clamp_cache_width = width
|
|
78
|
+
prev_clamp = self._clamp_cache
|
|
79
|
+
clamp: dict[str, str] = {}
|
|
80
|
+
clamped: list[str] = []
|
|
81
|
+
for line in new_lines:
|
|
82
|
+
out = prev_clamp.get(line)
|
|
83
|
+
if out is None:
|
|
84
|
+
out = truncate(line, width) if visible_width(line) > width else line
|
|
85
|
+
clamp[line] = out
|
|
86
|
+
clamped.append(out)
|
|
87
|
+
self._clamp_cache = clamp
|
|
88
|
+
new_lines = clamped
|
|
89
|
+
|
|
90
|
+
# Always have at least one line so index arithmetic stays valid.
|
|
91
|
+
if not new_lines:
|
|
92
|
+
new_lines = [""]
|
|
93
|
+
|
|
94
|
+
# Composite overlays into the visible viewport portion of new_lines.
|
|
95
|
+
if overlays:
|
|
96
|
+
new_lines = self._composite_overlays(new_lines, overlays, width, height)
|
|
97
|
+
|
|
98
|
+
# Locate and strip CURSOR_MARKER (TextInput IME cursor position).
|
|
99
|
+
cursor_pos: tuple[int, int] | None = None
|
|
100
|
+
for _r, _line in enumerate(new_lines):
|
|
101
|
+
if CURSOR_MARKER in _line:
|
|
102
|
+
_mi = _line.index(CURSOR_MARKER)
|
|
103
|
+
cursor_pos = (_r, visible_width(_line[:_mi]) + _LEFT_PAD)
|
|
104
|
+
new_lines[_r] = _line[:_mi] + _line[_mi + _CURSOR_MARKER_LEN:]
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
# Reserve the left/right margins on every line.
|
|
108
|
+
left_pad = " " * _LEFT_PAD
|
|
109
|
+
right_pad = " " * _RIGHT_PAD
|
|
110
|
+
new_lines = [left_pad + line + right_pad for line in new_lines]
|
|
111
|
+
|
|
112
|
+
# First render (or after reset()).
|
|
113
|
+
if not self._prev_lines and not width_changed:
|
|
114
|
+
self._full_render(new_lines, cursor_pos, width, height, clear=False)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Width changed — wrapping changes, must fully redraw.
|
|
118
|
+
if width_changed:
|
|
119
|
+
self._full_render(new_lines, cursor_pos, width, height, clear=True)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
prev = self._prev_lines
|
|
123
|
+
|
|
124
|
+
# Find first and last changed line.
|
|
125
|
+
max_len = max(len(new_lines), len(prev))
|
|
126
|
+
first_changed = -1
|
|
127
|
+
last_changed = -1
|
|
128
|
+
for i in range(max_len):
|
|
129
|
+
old_line = prev[i] if i < len(prev) else ""
|
|
130
|
+
new_line = new_lines[i] if i < len(new_lines) else ""
|
|
131
|
+
if old_line != new_line:
|
|
132
|
+
if first_changed == -1:
|
|
133
|
+
first_changed = i
|
|
134
|
+
last_changed = i
|
|
135
|
+
|
|
136
|
+
# No content changes — only reposition IME cursor if needed.
|
|
137
|
+
if first_changed == -1:
|
|
138
|
+
self._position_hw_cursor(cursor_pos, new_lines)
|
|
139
|
+
self._prev_height = height
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Changed line is above the visible viewport — full redraw.
|
|
143
|
+
if first_changed < self._viewport_top:
|
|
144
|
+
self._full_render(new_lines, cursor_pos, width, height, clear=True)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# === Differential render ===
|
|
148
|
+
buf = self._terminal.begin_sync()
|
|
149
|
+
|
|
150
|
+
viewport_top = self._viewport_top
|
|
151
|
+
hw_cursor = self._hw_cursor_row
|
|
152
|
+
viewport_bottom = viewport_top + height - 1
|
|
153
|
+
|
|
154
|
+
# If first changed row is beyond the viewport bottom, scroll down to it.
|
|
155
|
+
if first_changed > viewport_bottom:
|
|
156
|
+
current_screen_row = hw_cursor - viewport_top
|
|
157
|
+
move_to_bottom = max(0, (height - 1) - current_screen_row)
|
|
158
|
+
if move_to_bottom > 0:
|
|
159
|
+
buf += f"\x1b[{move_to_bottom}B"
|
|
160
|
+
scroll = first_changed - viewport_bottom
|
|
161
|
+
buf += "\r\n" * scroll
|
|
162
|
+
viewport_top += scroll
|
|
163
|
+
hw_cursor = first_changed
|
|
164
|
+
viewport_bottom = viewport_top + height - 1
|
|
165
|
+
|
|
166
|
+
# Move cursor up/down to the first changed line.
|
|
167
|
+
line_diff = first_changed - hw_cursor
|
|
168
|
+
if line_diff > 0:
|
|
169
|
+
buf += f"\x1b[{line_diff}B"
|
|
170
|
+
elif line_diff < 0:
|
|
171
|
+
buf += f"\x1b[{-line_diff}A"
|
|
172
|
+
buf += "\r"
|
|
173
|
+
hw_cursor = first_changed
|
|
174
|
+
|
|
175
|
+
render_end = min(last_changed, len(new_lines) - 1)
|
|
176
|
+
for i in range(first_changed, render_end + 1):
|
|
177
|
+
if i > first_changed:
|
|
178
|
+
buf += "\r\n"
|
|
179
|
+
hw_cursor += 1
|
|
180
|
+
buf += "\x1b[2K"
|
|
181
|
+
if i < len(new_lines) and new_lines[i]:
|
|
182
|
+
buf += new_lines[i]
|
|
183
|
+
|
|
184
|
+
final_cursor_row = render_end
|
|
185
|
+
|
|
186
|
+
# Clear extra lines if content shrank.
|
|
187
|
+
if len(prev) > len(new_lines):
|
|
188
|
+
if render_end < len(new_lines) - 1:
|
|
189
|
+
move_down = len(new_lines) - 1 - render_end
|
|
190
|
+
buf += f"\x1b[{move_down}B"
|
|
191
|
+
final_cursor_row = len(new_lines) - 1
|
|
192
|
+
extra = len(prev) - len(new_lines)
|
|
193
|
+
for _ in range(extra):
|
|
194
|
+
buf += "\r\n\x1b[2K"
|
|
195
|
+
buf += f"\x1b[{extra}A"
|
|
196
|
+
|
|
197
|
+
buf += self._terminal.end_sync()
|
|
198
|
+
self._terminal.write(buf)
|
|
199
|
+
|
|
200
|
+
self._cursor_row = max(0, len(new_lines) - 1)
|
|
201
|
+
self._hw_cursor_row = final_cursor_row
|
|
202
|
+
self._max_lines = max(self._max_lines, len(new_lines))
|
|
203
|
+
self._viewport_top = max(viewport_top, final_cursor_row - height + 1)
|
|
204
|
+
self._prev_lines = new_lines
|
|
205
|
+
self._prev_width = width
|
|
206
|
+
self._prev_height = height
|
|
207
|
+
|
|
208
|
+
self._position_hw_cursor(cursor_pos, new_lines)
|
|
209
|
+
|
|
210
|
+
def clear(self) -> None:
|
|
211
|
+
"""Erase the entire screen and scrollback buffer."""
|
|
212
|
+
self._terminal.write_flush(
|
|
213
|
+
self._terminal.begin_sync()
|
|
214
|
+
+ "\x1b[2J\x1b[H\x1b[3J"
|
|
215
|
+
+ self._terminal.end_sync()
|
|
216
|
+
)
|
|
217
|
+
self._prev_lines = []
|
|
218
|
+
self._cursor_row = 0
|
|
219
|
+
self._hw_cursor_row = 0
|
|
220
|
+
self._viewport_top = 0
|
|
221
|
+
self._max_lines = 0
|
|
222
|
+
|
|
223
|
+
def reset(self) -> None:
|
|
224
|
+
"""Force a full re-render on the next frame without clearing the screen."""
|
|
225
|
+
self._prev_lines = []
|
|
226
|
+
self._cursor_row = 0
|
|
227
|
+
self._hw_cursor_row = 0
|
|
228
|
+
self._viewport_top = 0
|
|
229
|
+
|
|
230
|
+
# -------------------------------------------------------------------------
|
|
231
|
+
# Internal helpers
|
|
232
|
+
# -------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _full_render(
|
|
235
|
+
self,
|
|
236
|
+
new_lines: list[str],
|
|
237
|
+
cursor_pos: tuple[int, int] | None,
|
|
238
|
+
width: int,
|
|
239
|
+
height: int,
|
|
240
|
+
*,
|
|
241
|
+
clear: bool,
|
|
242
|
+
) -> None:
|
|
243
|
+
buf = self._terminal.begin_sync()
|
|
244
|
+
if clear:
|
|
245
|
+
buf += "\x1b[2J\x1b[H\x1b[3J" # clear screen + scrollback
|
|
246
|
+
else:
|
|
247
|
+
buf += "\r" # start from column 0 for first render
|
|
248
|
+
for i, line in enumerate(new_lines):
|
|
249
|
+
if i > 0:
|
|
250
|
+
buf += "\r\n"
|
|
251
|
+
buf += "\x1b[2K"
|
|
252
|
+
buf += line
|
|
253
|
+
buf += self._terminal.end_sync()
|
|
254
|
+
self._terminal.write(buf)
|
|
255
|
+
|
|
256
|
+
self._cursor_row = max(0, len(new_lines) - 1)
|
|
257
|
+
self._hw_cursor_row = self._cursor_row
|
|
258
|
+
self._max_lines = len(new_lines) if clear else max(self._max_lines, len(new_lines))
|
|
259
|
+
buf_len = max(height, len(new_lines))
|
|
260
|
+
self._viewport_top = max(0, buf_len - height)
|
|
261
|
+
self._prev_lines = new_lines
|
|
262
|
+
self._prev_width = width
|
|
263
|
+
self._prev_height = height
|
|
264
|
+
|
|
265
|
+
self._position_hw_cursor(cursor_pos, new_lines)
|
|
266
|
+
|
|
267
|
+
def _position_hw_cursor(
|
|
268
|
+
self, cursor_pos: tuple[int, int] | None, new_lines: list[str]
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Move the hardware terminal cursor to the IME position and show/hide it."""
|
|
271
|
+
if cursor_pos is None or not new_lines:
|
|
272
|
+
self._terminal.write_flush("\x1b[?25l")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
target_row, target_col = cursor_pos
|
|
276
|
+
target_row = max(0, min(target_row, len(new_lines) - 1))
|
|
277
|
+
|
|
278
|
+
row_delta = target_row - self._hw_cursor_row
|
|
279
|
+
buf = ""
|
|
280
|
+
if row_delta > 0:
|
|
281
|
+
buf += f"\x1b[{row_delta}B"
|
|
282
|
+
elif row_delta < 0:
|
|
283
|
+
buf += f"\x1b[{-row_delta}A"
|
|
284
|
+
buf += f"\x1b[{target_col + 1}G" # absolute column (1-indexed)
|
|
285
|
+
if self._show_hardware_cursor:
|
|
286
|
+
buf += "\x1b[?25h" # show cursor (if enabled)
|
|
287
|
+
self._terminal.write_flush(buf)
|
|
288
|
+
self._hw_cursor_row = target_row
|
|
289
|
+
|
|
290
|
+
def _composite_overlays(
|
|
291
|
+
self, lines: list[str], overlays: list, width: int, height: int
|
|
292
|
+
) -> list[str]:
|
|
293
|
+
"""Composite all visible overlays into the visible portion of lines."""
|
|
294
|
+
viewport_start = max(0, len(lines) - height)
|
|
295
|
+
result = list(lines)
|
|
296
|
+
|
|
297
|
+
for entry in overlays:
|
|
298
|
+
if not entry.is_visible(width, height):
|
|
299
|
+
continue
|
|
300
|
+
ov_w = entry.resolve_width(width)
|
|
301
|
+
all_ov_lines = entry.component.render(ov_w)
|
|
302
|
+
ov_w2, ov_h, ov_row, ov_col = entry.resolve(width, height, len(all_ov_lines))
|
|
303
|
+
ov_lines = all_ov_lines[:ov_h]
|
|
304
|
+
|
|
305
|
+
for i, ov_line in enumerate(ov_lines):
|
|
306
|
+
logical = viewport_start + ov_row + i
|
|
307
|
+
if logical < 0:
|
|
308
|
+
continue
|
|
309
|
+
while logical >= len(result):
|
|
310
|
+
result.append("")
|
|
311
|
+
result[logical] = _composite_line(
|
|
312
|
+
result[logical], _fit_line(ov_line, ov_w2), ov_col, ov_w2, width
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
def _on_resize(self) -> None:
|
|
318
|
+
# Clear state; next render() call forces a full clear+redraw, even if
|
|
319
|
+
# the reported width didn't change (e.g. a height-only resize), so a
|
|
320
|
+
# stale frame is never left on screen for the new render to stack atop.
|
|
321
|
+
self._prev_lines = []
|
|
322
|
+
self._cursor_row = 0
|
|
323
|
+
self._hw_cursor_row = 0
|
|
324
|
+
self._viewport_top = 0
|
|
325
|
+
self._resized = True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ── Line helpers ──────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
def _fit_line(line: str, width: int) -> str:
|
|
331
|
+
"""Pad or truncate a line to exactly width visible columns."""
|
|
332
|
+
vw = visible_width(line)
|
|
333
|
+
if vw > width:
|
|
334
|
+
return truncate(line, width, ellipsis="")
|
|
335
|
+
if vw < width:
|
|
336
|
+
return line + RESET + " " * (width - vw)
|
|
337
|
+
return line
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _split_at_column(text: str, col: int) -> tuple[str, str]:
|
|
341
|
+
"""ANSI-safe split: returns (text_before_col, text_from_col_onwards)."""
|
|
342
|
+
vis = 0
|
|
343
|
+
i = 0
|
|
344
|
+
while i < len(text):
|
|
345
|
+
m = _ANSI_RE.match(text, i)
|
|
346
|
+
if m:
|
|
347
|
+
i += len(m.group(0))
|
|
348
|
+
continue
|
|
349
|
+
ch = text[i]
|
|
350
|
+
w = _char_width(ch)
|
|
351
|
+
if vis + w > col:
|
|
352
|
+
break
|
|
353
|
+
vis += w
|
|
354
|
+
i += 1
|
|
355
|
+
return text[:i], text[i:]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _composite_line(base: str, overlay: str, col: int, ov_width: int, total_width: int) -> str:
|
|
359
|
+
"""Splice overlay into base starting at visual column col."""
|
|
360
|
+
before, rest = _split_at_column(base, col)
|
|
361
|
+
|
|
362
|
+
# Pad before to exactly col visual columns if base is shorter.
|
|
363
|
+
before_vw = visible_width(before)
|
|
364
|
+
if before_vw < col:
|
|
365
|
+
before += " " * (col - before_vw)
|
|
366
|
+
|
|
367
|
+
# Skip the overlay zone in the base to get the "after" segment.
|
|
368
|
+
_, after = _split_at_column(rest, ov_width)
|
|
369
|
+
|
|
370
|
+
# Overlay is already padded to ov_width by _fit_line; just truncate if needed.
|
|
371
|
+
ov_vw = visible_width(overlay)
|
|
372
|
+
if ov_vw > ov_width:
|
|
373
|
+
overlay = truncate(overlay, ov_width, ellipsis="")
|
|
374
|
+
|
|
375
|
+
result = before + RESET + overlay + RESET + after
|
|
376
|
+
if visible_width(result) > total_width:
|
|
377
|
+
result = truncate(result, total_width, ellipsis="")
|
|
378
|
+
return result
|