tau-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from tau.tui.ansi import BOLD, DIM, RESET, pad, visible_width
|
|
6
|
+
from tau.tui.component import Component
|
|
7
|
+
from tau.tui.input import InputEvent, KeyEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _box(inner_lines: list[str], title: str, width: int) -> list[str]:
|
|
11
|
+
inner_w = max(1, width - 4)
|
|
12
|
+
if title:
|
|
13
|
+
t = f" {title} "
|
|
14
|
+
tv = visible_width(t)
|
|
15
|
+
dashes = max(0, width - 2 - tv)
|
|
16
|
+
left_d = dashes // 2
|
|
17
|
+
right_d = dashes - left_d
|
|
18
|
+
top = "╭" + "─" * left_d + BOLD + t + RESET + "─" * right_d + "╮"
|
|
19
|
+
else:
|
|
20
|
+
top = "╭" + "─" * (width - 2) + "╮"
|
|
21
|
+
lines = [top]
|
|
22
|
+
for line in inner_lines:
|
|
23
|
+
lines.append("│ " + pad(line, inner_w) + " │")
|
|
24
|
+
lines.append("╰" + "─" * (width - 2) + "╯")
|
|
25
|
+
return lines
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PromptOverlay(Component):
|
|
29
|
+
"""A floating single-line text input overlay.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
handle_ref = []
|
|
34
|
+
|
|
35
|
+
def on_commit(value):
|
|
36
|
+
handle_ref[0].close()
|
|
37
|
+
save_key(value)
|
|
38
|
+
|
|
39
|
+
def on_cancel():
|
|
40
|
+
handle_ref[0].close()
|
|
41
|
+
|
|
42
|
+
prompt = PromptOverlay("Enter API key", on_commit=on_commit,
|
|
43
|
+
on_cancel=on_cancel, secret=True)
|
|
44
|
+
handle = tui.show_overlay(prompt, OverlayOptions(width="50%"))
|
|
45
|
+
handle_ref.append(handle)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
label: str,
|
|
51
|
+
on_commit: Callable[[str], None] | None = None,
|
|
52
|
+
on_cancel: Callable[[], None] | None = None,
|
|
53
|
+
secret: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._label = label
|
|
56
|
+
self._on_commit = on_commit
|
|
57
|
+
self._on_cancel = on_cancel
|
|
58
|
+
self._secret = secret
|
|
59
|
+
self._value = ""
|
|
60
|
+
|
|
61
|
+
# ── Component ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def render(self, width: int) -> list[str]:
|
|
64
|
+
display = "*" * len(self._value) if self._secret else self._value
|
|
65
|
+
inner = [
|
|
66
|
+
f" {BOLD}{self._label}{RESET}",
|
|
67
|
+
f" {DIM}Enter to confirm · Esc to cancel{RESET}",
|
|
68
|
+
f" {display}█",
|
|
69
|
+
]
|
|
70
|
+
return _box(inner, "", width)
|
|
71
|
+
|
|
72
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
73
|
+
if not isinstance(event, KeyEvent):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
match event.key:
|
|
77
|
+
case "enter":
|
|
78
|
+
val = self._value
|
|
79
|
+
if self._on_commit is not None:
|
|
80
|
+
self._on_commit(val)
|
|
81
|
+
case "escape":
|
|
82
|
+
if self._on_cancel is not None:
|
|
83
|
+
self._on_cancel()
|
|
84
|
+
case "backspace":
|
|
85
|
+
self._value = self._value[:-1]
|
|
86
|
+
case ch if len(ch) == 1 and ch.isprintable():
|
|
87
|
+
self._value += ch
|
|
88
|
+
case _:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def invalidate(self) -> None:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EditorOverlay(Component):
|
|
98
|
+
"""A floating multi-line text editor overlay.
|
|
99
|
+
|
|
100
|
+
``Ctrl+S`` or ``Ctrl+Enter`` saves; ``Escape`` cancels.
|
|
101
|
+
Arrow keys and Backspace work normally; Enter inserts a newline.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
VISIBLE_ROWS = 12
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
title: str,
|
|
109
|
+
prefill: str = "",
|
|
110
|
+
on_commit: Callable[[str], None] | None = None,
|
|
111
|
+
on_cancel: Callable[[], None] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._title = title
|
|
114
|
+
self._lines: list[str] = prefill.splitlines() or [""]
|
|
115
|
+
self._cursor_row = len(self._lines) - 1
|
|
116
|
+
self._cursor_col = len(self._lines[-1])
|
|
117
|
+
self._scroll_top = 0
|
|
118
|
+
self._on_commit = on_commit
|
|
119
|
+
self._on_cancel = on_cancel
|
|
120
|
+
|
|
121
|
+
# ── Cursor helpers ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _current_line(self) -> str:
|
|
124
|
+
return self._lines[self._cursor_row]
|
|
125
|
+
|
|
126
|
+
def _clamp_scroll(self) -> None:
|
|
127
|
+
if self._cursor_row < self._scroll_top:
|
|
128
|
+
self._scroll_top = self._cursor_row
|
|
129
|
+
elif self._cursor_row >= self._scroll_top + self.VISIBLE_ROWS:
|
|
130
|
+
self._scroll_top = self._cursor_row - self.VISIBLE_ROWS + 1
|
|
131
|
+
|
|
132
|
+
# ── Component ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def render(self, width: int) -> list[str]:
|
|
135
|
+
inner_w = max(1, width - 4)
|
|
136
|
+
self._clamp_scroll()
|
|
137
|
+
|
|
138
|
+
visible = self._lines[self._scroll_top: self._scroll_top + self.VISIBLE_ROWS]
|
|
139
|
+
rows: list[str] = []
|
|
140
|
+
for ri, line in enumerate(visible):
|
|
141
|
+
abs_row = self._scroll_top + ri
|
|
142
|
+
if abs_row == self._cursor_row:
|
|
143
|
+
before = line[: self._cursor_col]
|
|
144
|
+
after = line[self._cursor_col:]
|
|
145
|
+
content = before + "█" + after
|
|
146
|
+
else:
|
|
147
|
+
content = line
|
|
148
|
+
rows.append("│ " + pad(content[:inner_w], inner_w) + " │")
|
|
149
|
+
|
|
150
|
+
# scroll indicator
|
|
151
|
+
total = len(self._lines)
|
|
152
|
+
if total > self.VISIBLE_ROWS:
|
|
153
|
+
pct = int(self._scroll_top / max(1, total - self.VISIBLE_ROWS) * 100)
|
|
154
|
+
rows.append("│" + DIM + f" ↕ {pct}%".rjust(width - 2) + RESET + "│")
|
|
155
|
+
else:
|
|
156
|
+
rows.append("│" + " " * (width - 2) + "│")
|
|
157
|
+
|
|
158
|
+
hint = f" {DIM}Ctrl+S to save · Esc to cancel{RESET}"
|
|
159
|
+
rows.append("│ " + pad(hint, inner_w) + " │")
|
|
160
|
+
|
|
161
|
+
return _box(rows, self._title, width)
|
|
162
|
+
|
|
163
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
164
|
+
if not isinstance(event, KeyEvent):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
k = event.key
|
|
168
|
+
if k in ("ctrl+s", "ctrl+enter"):
|
|
169
|
+
text = "\n".join(self._lines)
|
|
170
|
+
if self._on_commit is not None:
|
|
171
|
+
self._on_commit(text)
|
|
172
|
+
return True
|
|
173
|
+
if k == "escape":
|
|
174
|
+
if self._on_cancel is not None:
|
|
175
|
+
self._on_cancel()
|
|
176
|
+
return True
|
|
177
|
+
if k == "enter":
|
|
178
|
+
line = self._lines[self._cursor_row]
|
|
179
|
+
before, after = line[: self._cursor_col], line[self._cursor_col:]
|
|
180
|
+
self._lines[self._cursor_row] = before
|
|
181
|
+
self._lines.insert(self._cursor_row + 1, after)
|
|
182
|
+
self._cursor_row += 1
|
|
183
|
+
self._cursor_col = 0
|
|
184
|
+
return True
|
|
185
|
+
if k == "backspace":
|
|
186
|
+
if self._cursor_col > 0:
|
|
187
|
+
line = self._lines[self._cursor_row]
|
|
188
|
+
self._lines[self._cursor_row] = line[: self._cursor_col - 1] + line[self._cursor_col:]
|
|
189
|
+
self._cursor_col -= 1
|
|
190
|
+
elif self._cursor_row > 0:
|
|
191
|
+
prev = self._lines[self._cursor_row - 1]
|
|
192
|
+
merged = prev + self._lines.pop(self._cursor_row)
|
|
193
|
+
self._cursor_row -= 1
|
|
194
|
+
self._cursor_col = len(prev)
|
|
195
|
+
self._lines[self._cursor_row] = merged
|
|
196
|
+
return True
|
|
197
|
+
if k == "up":
|
|
198
|
+
if self._cursor_row > 0:
|
|
199
|
+
self._cursor_row -= 1
|
|
200
|
+
self._cursor_col = min(self._cursor_col, len(self._current_line()))
|
|
201
|
+
return True
|
|
202
|
+
if k == "down":
|
|
203
|
+
if self._cursor_row < len(self._lines) - 1:
|
|
204
|
+
self._cursor_row += 1
|
|
205
|
+
self._cursor_col = min(self._cursor_col, len(self._current_line()))
|
|
206
|
+
return True
|
|
207
|
+
if k == "left":
|
|
208
|
+
if self._cursor_col > 0:
|
|
209
|
+
self._cursor_col -= 1
|
|
210
|
+
elif self._cursor_row > 0:
|
|
211
|
+
self._cursor_row -= 1
|
|
212
|
+
self._cursor_col = len(self._current_line())
|
|
213
|
+
return True
|
|
214
|
+
if k == "right":
|
|
215
|
+
line = self._current_line()
|
|
216
|
+
if self._cursor_col < len(line):
|
|
217
|
+
self._cursor_col += 1
|
|
218
|
+
elif self._cursor_row < len(self._lines) - 1:
|
|
219
|
+
self._cursor_row += 1
|
|
220
|
+
self._cursor_col = 0
|
|
221
|
+
return True
|
|
222
|
+
if k == "home":
|
|
223
|
+
self._cursor_col = 0
|
|
224
|
+
return True
|
|
225
|
+
if k == "end":
|
|
226
|
+
self._cursor_col = len(self._current_line())
|
|
227
|
+
return True
|
|
228
|
+
if len(k) == 1 and k.isprintable():
|
|
229
|
+
line = self._lines[self._cursor_row]
|
|
230
|
+
self._lines[self._cursor_row] = line[: self._cursor_col] + k + line[self._cursor_col:]
|
|
231
|
+
self._cursor_col += 1
|
|
232
|
+
return True
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def invalidate(self) -> None:
|
|
236
|
+
pass
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
_MEDIA_UUID_PATTERN = re.compile(r"\[(?:image|audio|video):([^\]]+)\]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cleanup_session_media(session_path: Path) -> None:
|
|
12
|
+
"""Delete media files that were only referenced by the given session file.
|
|
13
|
+
|
|
14
|
+
Scans the deleted session for [image/audio/video:{uuid}] markers, then checks
|
|
15
|
+
all sibling sessions in the same project dir. Any UUID not referenced elsewhere
|
|
16
|
+
has its media file removed from session_dir/media/.
|
|
17
|
+
"""
|
|
18
|
+
session_dir = session_path.parent
|
|
19
|
+
media_dir = session_dir / "media"
|
|
20
|
+
if not media_dir.is_dir():
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
deleted_uuids: set[str] = set()
|
|
24
|
+
try:
|
|
25
|
+
for line in session_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
26
|
+
for m in _MEDIA_UUID_PATTERN.finditer(line):
|
|
27
|
+
deleted_uuids.add(m.group(1))
|
|
28
|
+
except OSError:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if not deleted_uuids:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
live_uuids: set[str] = set()
|
|
35
|
+
for sibling in session_dir.glob("*.jsonl"):
|
|
36
|
+
if sibling == session_path:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
for line in sibling.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
40
|
+
for m in _MEDIA_UUID_PATTERN.finditer(line):
|
|
41
|
+
live_uuids.add(m.group(1))
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
for uid in deleted_uuids - live_uuids:
|
|
46
|
+
for media_file in media_dir.glob(f"{uid}.*"):
|
|
47
|
+
try:
|
|
48
|
+
media_file.unlink(missing_ok=True)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
from tau.tui.ansi import (
|
|
53
|
+
RESET, BOLD, DIM, BRIGHT_BLACK, BRIGHT_WHITE, BRIGHT_RED,
|
|
54
|
+
BRIGHT_YELLOW, CYAN,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _age(dt: datetime) -> str:
|
|
59
|
+
"""Format a datetime as a compact relative age string."""
|
|
60
|
+
if dt.tzinfo is None:
|
|
61
|
+
now = datetime.now()
|
|
62
|
+
else:
|
|
63
|
+
now = datetime.now(tz=timezone.utc)
|
|
64
|
+
secs = max(0, (now - dt).total_seconds())
|
|
65
|
+
mins = int(secs / 60)
|
|
66
|
+
if mins < 1:
|
|
67
|
+
return "now"
|
|
68
|
+
if mins < 60:
|
|
69
|
+
return f"{mins}m"
|
|
70
|
+
hours = mins // 60
|
|
71
|
+
if hours < 24:
|
|
72
|
+
return f"{hours}h"
|
|
73
|
+
days = hours // 24
|
|
74
|
+
if days < 7:
|
|
75
|
+
return f"{days}d"
|
|
76
|
+
if days < 30:
|
|
77
|
+
return f"{days // 7}w"
|
|
78
|
+
if days < 365:
|
|
79
|
+
return f"{days // 30}mo"
|
|
80
|
+
return f"{days // 365}y"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _shorten(path: Path) -> str:
|
|
84
|
+
home = Path.home()
|
|
85
|
+
try:
|
|
86
|
+
return "~/" + str(path.relative_to(home))
|
|
87
|
+
except ValueError:
|
|
88
|
+
return str(path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ResumeModal:
|
|
92
|
+
"""Session resume selector.
|
|
93
|
+
|
|
94
|
+
- Up/Down navigate
|
|
95
|
+
- Enter select session
|
|
96
|
+
- Tab toggle scope (current folder ↔ all)
|
|
97
|
+
- Ctrl+R cycle sort (date desc → date asc → name)
|
|
98
|
+
- Ctrl+D start delete-confirmation
|
|
99
|
+
- Enter/Esc confirm/cancel delete
|
|
100
|
+
- Type search by name / id
|
|
101
|
+
- Backspace delete last search char
|
|
102
|
+
- Escape cancel (when not in delete-confirmation)
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
_SORT_LABELS = ["Recent", "Oldest", "Name"]
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
current_sessions: list,
|
|
110
|
+
all_sessions_loader: Callable[[], list],
|
|
111
|
+
current_session_path: Path | None = None,
|
|
112
|
+
max_visible: int = 10,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._current = list(current_sessions)
|
|
115
|
+
self._all_loader = all_sessions_loader
|
|
116
|
+
self._all: list | None = None
|
|
117
|
+
self._cur_path = current_session_path
|
|
118
|
+
self._max_visible = max_visible
|
|
119
|
+
|
|
120
|
+
self._scope = "current" # "current" | "all"
|
|
121
|
+
self._sort_idx = 0 # index into _SORT_LABELS
|
|
122
|
+
self._search = ""
|
|
123
|
+
self._filtered: list = []
|
|
124
|
+
self._selected = 0
|
|
125
|
+
|
|
126
|
+
self._confirming_delete: Path | None = None
|
|
127
|
+
self._status_msg: str = ""
|
|
128
|
+
|
|
129
|
+
self._refilter()
|
|
130
|
+
|
|
131
|
+
# ── Public state ──────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def confirming_delete(self) -> bool:
|
|
135
|
+
return self._confirming_delete is not None
|
|
136
|
+
|
|
137
|
+
def selected_path(self) -> Path | None:
|
|
138
|
+
if not self._filtered:
|
|
139
|
+
return None
|
|
140
|
+
s = self._filtered[self._selected]
|
|
141
|
+
return Path(s.path) if not isinstance(s.path, Path) else s.path
|
|
142
|
+
|
|
143
|
+
# ── Navigation ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def move_up(self) -> None:
|
|
146
|
+
if self._confirming_delete is None and self._filtered:
|
|
147
|
+
self._selected = max(0, self._selected - 1)
|
|
148
|
+
self._status_msg = ""
|
|
149
|
+
|
|
150
|
+
def move_down(self) -> None:
|
|
151
|
+
if self._confirming_delete is None and self._filtered:
|
|
152
|
+
self._selected = min(len(self._filtered) - 1, self._selected + 1)
|
|
153
|
+
self._status_msg = ""
|
|
154
|
+
|
|
155
|
+
def toggle_scope(self) -> None:
|
|
156
|
+
if self._confirming_delete is not None:
|
|
157
|
+
return
|
|
158
|
+
if self._scope == "current":
|
|
159
|
+
self._scope = "all"
|
|
160
|
+
if self._all is None:
|
|
161
|
+
try:
|
|
162
|
+
self._all = list(self._all_loader())
|
|
163
|
+
except Exception:
|
|
164
|
+
self._all = []
|
|
165
|
+
else:
|
|
166
|
+
self._scope = "current"
|
|
167
|
+
self._selected = 0
|
|
168
|
+
self._refilter()
|
|
169
|
+
|
|
170
|
+
def cycle_sort(self) -> None:
|
|
171
|
+
if self._confirming_delete is not None:
|
|
172
|
+
return
|
|
173
|
+
self._sort_idx = (self._sort_idx + 1) % len(self._SORT_LABELS)
|
|
174
|
+
self._refilter()
|
|
175
|
+
|
|
176
|
+
def start_delete(self) -> None:
|
|
177
|
+
if not self._filtered:
|
|
178
|
+
return
|
|
179
|
+
sel = self._filtered[self._selected]
|
|
180
|
+
sel_path = Path(sel.path) if not isinstance(sel.path, Path) else sel.path
|
|
181
|
+
if self._cur_path and sel_path == self._cur_path:
|
|
182
|
+
self._status_msg = "Cannot delete the active session"
|
|
183
|
+
return
|
|
184
|
+
self._confirming_delete = sel_path
|
|
185
|
+
|
|
186
|
+
def confirm_delete(self) -> None:
|
|
187
|
+
path = self._confirming_delete
|
|
188
|
+
self._confirming_delete = None
|
|
189
|
+
if path is None:
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
_cleanup_session_media(path)
|
|
193
|
+
path.unlink(missing_ok=True)
|
|
194
|
+
self._current = [s for s in self._current if Path(s.path) != path]
|
|
195
|
+
if self._all is not None:
|
|
196
|
+
self._all = [s for s in self._all if Path(s.path) != path]
|
|
197
|
+
self._refilter()
|
|
198
|
+
self._selected = min(self._selected, max(0, len(self._filtered) - 1))
|
|
199
|
+
self._status_msg = "Session deleted"
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
self._status_msg = f"Delete failed: {exc}"
|
|
202
|
+
|
|
203
|
+
def cancel_delete(self) -> None:
|
|
204
|
+
self._confirming_delete = None
|
|
205
|
+
|
|
206
|
+
# ── Search ────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def append_search(self, ch: str) -> None:
|
|
209
|
+
if self._confirming_delete is not None:
|
|
210
|
+
return
|
|
211
|
+
self._search += ch
|
|
212
|
+
self._selected = 0
|
|
213
|
+
self._refilter()
|
|
214
|
+
|
|
215
|
+
def backspace_search(self) -> None:
|
|
216
|
+
if self._confirming_delete is not None:
|
|
217
|
+
return
|
|
218
|
+
if self._search:
|
|
219
|
+
self._search = self._search[:-1]
|
|
220
|
+
self._selected = 0
|
|
221
|
+
self._refilter()
|
|
222
|
+
|
|
223
|
+
# ── Render ────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
def render(self, width: int) -> list[str]:
|
|
226
|
+
divider = BRIGHT_BLACK + "─" * width + RESET
|
|
227
|
+
lines: list[str] = []
|
|
228
|
+
|
|
229
|
+
# Header bar
|
|
230
|
+
scope_label = (
|
|
231
|
+
f"{BRIGHT_WHITE}◉ Folder{RESET} {DIM}○ All{RESET}"
|
|
232
|
+
if self._scope == "current"
|
|
233
|
+
else f"{DIM}○ Folder{RESET} {BRIGHT_WHITE}◉ All{RESET}"
|
|
234
|
+
)
|
|
235
|
+
sort_label = f"{DIM}Sort:{RESET} {CYAN}{self._SORT_LABELS[self._sort_idx]}{RESET}"
|
|
236
|
+
header_right = f"{scope_label} {sort_label}"
|
|
237
|
+
title_left = f" {BOLD}{BRIGHT_WHITE}Resume Session{RESET}"
|
|
238
|
+
# Truncate right to fit
|
|
239
|
+
right_plain_len = _visible_len(header_right)
|
|
240
|
+
pad = max(0, width - _visible_len(title_left) - right_plain_len - 1)
|
|
241
|
+
lines.append(title_left + " " * pad + header_right)
|
|
242
|
+
|
|
243
|
+
# Hints
|
|
244
|
+
if self._confirming_delete is not None:
|
|
245
|
+
del_path = self._confirming_delete
|
|
246
|
+
short = _shorten(del_path)[:width - 20]
|
|
247
|
+
lines.append(
|
|
248
|
+
f" {BRIGHT_RED}Delete '{short}'? enter=yes esc=no{RESET}"
|
|
249
|
+
)
|
|
250
|
+
elif self._status_msg:
|
|
251
|
+
lines.append(f" {BRIGHT_YELLOW}{self._status_msg}{RESET}")
|
|
252
|
+
else:
|
|
253
|
+
hints = (
|
|
254
|
+
f" {DIM}tab scope ctrl+r sort ctrl+d delete type search esc cancel{RESET}"
|
|
255
|
+
)
|
|
256
|
+
lines.append(hints)
|
|
257
|
+
|
|
258
|
+
# Search line
|
|
259
|
+
if self._search:
|
|
260
|
+
lines.append(f" {DIM}/{self._search}█{RESET}")
|
|
261
|
+
|
|
262
|
+
lines.append(divider)
|
|
263
|
+
|
|
264
|
+
# Session list
|
|
265
|
+
if not self._filtered:
|
|
266
|
+
if self._search:
|
|
267
|
+
lines.append(f" {DIM}No sessions match '{self._search}'{RESET}")
|
|
268
|
+
elif self._scope == "current":
|
|
269
|
+
lines.append(f" {DIM}No sessions in current folder — press Tab for all{RESET}")
|
|
270
|
+
else:
|
|
271
|
+
lines.append(f" {DIM}No sessions found{RESET}")
|
|
272
|
+
else:
|
|
273
|
+
count = len(self._filtered)
|
|
274
|
+
visible = min(self._max_visible, count)
|
|
275
|
+
start = max(0, min(self._selected - visible // 2, count - visible))
|
|
276
|
+
|
|
277
|
+
if start > 0:
|
|
278
|
+
lines.append(f" {DIM}↑ {start} more{RESET}")
|
|
279
|
+
|
|
280
|
+
show_cwd = self._scope == "all"
|
|
281
|
+
|
|
282
|
+
for i in range(start, min(start + visible, count)):
|
|
283
|
+
session = self._filtered[i]
|
|
284
|
+
is_sel = i == self._selected
|
|
285
|
+
sel_path = Path(session.path) if not isinstance(session.path, Path) else session.path
|
|
286
|
+
is_del_target = sel_path == self._confirming_delete
|
|
287
|
+
|
|
288
|
+
display = session.name or session.id[:20]
|
|
289
|
+
age_str = _age(session.modified)
|
|
290
|
+
count_str = str(getattr(session, "message_count", 0))
|
|
291
|
+
|
|
292
|
+
right_parts = [count_str, age_str]
|
|
293
|
+
if show_cwd and hasattr(session, "cwd") and session.cwd:
|
|
294
|
+
right_parts.insert(0, _shorten(Path(session.cwd)))
|
|
295
|
+
right = " ".join(right_parts)
|
|
296
|
+
|
|
297
|
+
cursor = "→ " if is_sel else " "
|
|
298
|
+
right_len = _visible_len(right)
|
|
299
|
+
available = width - 4 - right_len - 2
|
|
300
|
+
msg = display[:max(8, available)]
|
|
301
|
+
|
|
302
|
+
if is_del_target:
|
|
303
|
+
msg_styled = f"{BRIGHT_RED}{BOLD}{msg}{RESET}"
|
|
304
|
+
right_styled = f"{BRIGHT_RED}{right}{RESET}"
|
|
305
|
+
elif is_sel:
|
|
306
|
+
msg_styled = f"{BOLD}{BRIGHT_WHITE}{msg}{RESET}"
|
|
307
|
+
right_styled = f"{DIM}{right}{RESET}"
|
|
308
|
+
elif session.name:
|
|
309
|
+
msg_styled = f"{BRIGHT_YELLOW}{msg}{RESET}"
|
|
310
|
+
right_styled = f"{DIM}{right}{RESET}"
|
|
311
|
+
else:
|
|
312
|
+
msg_styled = msg
|
|
313
|
+
right_styled = f"{DIM}{right}{RESET}"
|
|
314
|
+
|
|
315
|
+
cursor_styled = f"{BRIGHT_WHITE}{cursor}{RESET}" if is_sel else cursor
|
|
316
|
+
left = cursor_styled + msg_styled
|
|
317
|
+
spacing = max(1, width - _visible_len(left) - right_len)
|
|
318
|
+
lines.append(left + " " * spacing + right_styled)
|
|
319
|
+
|
|
320
|
+
remaining = count - (start + visible)
|
|
321
|
+
if remaining > 0:
|
|
322
|
+
lines.append(f" {DIM}↓ {remaining} more{RESET}")
|
|
323
|
+
|
|
324
|
+
lines.append(divider)
|
|
325
|
+
return lines
|
|
326
|
+
|
|
327
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
def _active_sessions(self) -> list:
|
|
330
|
+
if self._scope == "all" and self._all is not None:
|
|
331
|
+
return self._all
|
|
332
|
+
return self._current
|
|
333
|
+
|
|
334
|
+
def _refilter(self) -> None:
|
|
335
|
+
sessions = self._active_sessions()
|
|
336
|
+
q = self._search.lower()
|
|
337
|
+
|
|
338
|
+
if q:
|
|
339
|
+
filtered = [
|
|
340
|
+
s for s in sessions
|
|
341
|
+
if q in (s.name or "").lower()
|
|
342
|
+
or q in s.id.lower()
|
|
343
|
+
or q in str(getattr(s, "cwd", "")).lower()
|
|
344
|
+
]
|
|
345
|
+
else:
|
|
346
|
+
filtered = list(sessions)
|
|
347
|
+
|
|
348
|
+
# Exclude active session from resume list
|
|
349
|
+
if self._cur_path:
|
|
350
|
+
filtered = [
|
|
351
|
+
s for s in filtered
|
|
352
|
+
if (Path(s.path) if not isinstance(s.path, Path) else s.path) != self._cur_path
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
# Sort
|
|
356
|
+
label = self._SORT_LABELS[self._sort_idx]
|
|
357
|
+
if label == "Recent":
|
|
358
|
+
filtered.sort(key=lambda s: s.modified.timestamp(), reverse=True)
|
|
359
|
+
elif label == "Oldest":
|
|
360
|
+
filtered.sort(key=lambda s: s.modified.timestamp())
|
|
361
|
+
elif label == "Name":
|
|
362
|
+
filtered.sort(key=lambda s: (s.name or s.id).lower())
|
|
363
|
+
|
|
364
|
+
self._filtered = filtered
|
|
365
|
+
self._selected = min(self._selected, max(0, len(filtered) - 1))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _visible_len(s: str) -> int:
|
|
369
|
+
"""Approximate visible terminal width of a string (strips ANSI escapes)."""
|
|
370
|
+
import re
|
|
371
|
+
plain = re.sub(r"\x1b\[[0-9;]*[mK]|\x1b\][^\x07]*\x07", "", s)
|
|
372
|
+
return len(plain)
|