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,580 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Callable, Generic, Literal, TypeVar
|
|
5
|
+
|
|
6
|
+
from tau.tui.ansi import BOLD, RESET, truncate, visible_width
|
|
7
|
+
from tau.tui.fuzzy import fuzzy_filter
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
ColorFn = Callable[[str], str]
|
|
12
|
+
|
|
13
|
+
FilterMode = Literal["default", "no-tools", "user-only", "labeled-only", "all"]
|
|
14
|
+
|
|
15
|
+
_TOOL_ROLES = frozenset({
|
|
16
|
+
"tool", "terminal", "terminal_execution", "error", "skill", "template",
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
_SETTINGS_ROLES = frozenset({"label", "model", "thinking_level"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TreeRow(Generic[T]):
|
|
24
|
+
"""A single row in a TreeSelectList: tree connectors + a role-colored snippet."""
|
|
25
|
+
prefix: str
|
|
26
|
+
role: str
|
|
27
|
+
text: str
|
|
28
|
+
on_active_path: bool = False
|
|
29
|
+
is_current: bool = False
|
|
30
|
+
selectable: bool = True
|
|
31
|
+
value: T | None = None
|
|
32
|
+
parent_value: T | None = None # actual parent entry ID (for visible-tree maps)
|
|
33
|
+
has_children: bool = False # has children in the full (unfiltered) tree
|
|
34
|
+
label: str | None = None
|
|
35
|
+
label_timestamp: str | None = None
|
|
36
|
+
search_text: str = field(default="")
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not self.search_text:
|
|
40
|
+
self.search_text = f"{self.role} {self.text}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TreeSelectList(Generic[T]):
|
|
44
|
+
"""
|
|
45
|
+
Picker that renders full pre-built rows (tree connectors + role-colored
|
|
46
|
+
content) on one line each. Supports fuzzy search, filter modes, folding,
|
|
47
|
+
labels, and centered-viewport scrolling.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
rows: list[TreeRow[T]],
|
|
53
|
+
role_color: Callable[[str, str], ColorFn],
|
|
54
|
+
accent_color: ColorFn,
|
|
55
|
+
dim_color: ColorFn,
|
|
56
|
+
max_visible: int = 10,
|
|
57
|
+
selected_bg: ColorFn | None = None,
|
|
58
|
+
on_label_change: Callable[[T, str | None], None] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._all_rows: list[TreeRow[T]] = rows
|
|
61
|
+
self._filtered: list[TreeRow[T]] = list(rows)
|
|
62
|
+
self._role_color = role_color
|
|
63
|
+
self._accent_color = accent_color
|
|
64
|
+
self._dim_color = dim_color
|
|
65
|
+
self._max_visible = max(1, max_visible)
|
|
66
|
+
self._selected_bg = selected_bg
|
|
67
|
+
self._on_label_change = on_label_change
|
|
68
|
+
|
|
69
|
+
# Selection state
|
|
70
|
+
self._selected = next((i for i, r in enumerate(rows) if r.is_current), 0)
|
|
71
|
+
self._last_selected_value: T | None = None
|
|
72
|
+
|
|
73
|
+
# Search / filter
|
|
74
|
+
self._query = ""
|
|
75
|
+
self._filter_mode: FilterMode = "default"
|
|
76
|
+
|
|
77
|
+
# Fold state
|
|
78
|
+
self._folded_nodes: set[T] = set()
|
|
79
|
+
|
|
80
|
+
# Visible tree maps (rebuilt after each filter/fold change)
|
|
81
|
+
# These maps reflect which nodes are VISIBLE (not filtered or folded away).
|
|
82
|
+
self._visible_parent_map: dict[T, T | None] = {}
|
|
83
|
+
self._visible_children_map: dict[T | None, list[T]] = {}
|
|
84
|
+
|
|
85
|
+
# In-memory label store: value → (label_text, iso_timestamp)
|
|
86
|
+
self._labels: dict[T, tuple[str, str]] = {}
|
|
87
|
+
self._show_label_timestamps: bool = False
|
|
88
|
+
|
|
89
|
+
# Label editing state
|
|
90
|
+
self._label_editing: bool = False
|
|
91
|
+
self._label_input: str = ""
|
|
92
|
+
self._label_editing_value: T | None = None
|
|
93
|
+
|
|
94
|
+
self._apply_filter()
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Public interface (duck-types SelectList subset used by InlineSelector)
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def selected_item(self) -> TreeRow[T] | None:
|
|
102
|
+
if not self._filtered:
|
|
103
|
+
return None
|
|
104
|
+
return self._filtered[self._selected]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def label_editing(self) -> bool:
|
|
108
|
+
return self._label_editing
|
|
109
|
+
|
|
110
|
+
def move_up(self) -> None:
|
|
111
|
+
self._move(-1)
|
|
112
|
+
|
|
113
|
+
def move_down(self) -> None:
|
|
114
|
+
self._move(1)
|
|
115
|
+
|
|
116
|
+
def page_up(self) -> None:
|
|
117
|
+
self._selected = max(0, self._selected - self._max_visible)
|
|
118
|
+
|
|
119
|
+
def page_down(self) -> None:
|
|
120
|
+
n = len(self._filtered)
|
|
121
|
+
if n:
|
|
122
|
+
self._selected = min(n - 1, self._selected + self._max_visible)
|
|
123
|
+
|
|
124
|
+
def fold_or_up(self) -> None:
|
|
125
|
+
"""Fold the current node if foldable and open; else jump to segment start upward."""
|
|
126
|
+
item = self.selected_item
|
|
127
|
+
if item is None or item.value is None:
|
|
128
|
+
return
|
|
129
|
+
val = item.value
|
|
130
|
+
if self._is_foldable(val) and val not in self._folded_nodes:
|
|
131
|
+
self._folded_nodes.add(val)
|
|
132
|
+
self._apply_filter()
|
|
133
|
+
else:
|
|
134
|
+
self._selected = self._find_branch_segment_start("up")
|
|
135
|
+
|
|
136
|
+
def unfold_or_down(self) -> None:
|
|
137
|
+
"""Unfold the current node if folded; else jump to segment start downward."""
|
|
138
|
+
item = self.selected_item
|
|
139
|
+
if item is None or item.value is None:
|
|
140
|
+
return
|
|
141
|
+
val = item.value
|
|
142
|
+
if val in self._folded_nodes:
|
|
143
|
+
self._folded_nodes.discard(val)
|
|
144
|
+
self._apply_filter()
|
|
145
|
+
else:
|
|
146
|
+
self._selected = self._find_branch_segment_start("down")
|
|
147
|
+
|
|
148
|
+
def cycle_filter(self) -> None:
|
|
149
|
+
modes: list[FilterMode] = ["default", "no-tools", "user-only", "labeled-only", "all"]
|
|
150
|
+
idx = modes.index(self._filter_mode)
|
|
151
|
+
self._filter_mode = modes[(idx + 1) % len(modes)]
|
|
152
|
+
self._folded_nodes.clear()
|
|
153
|
+
self._apply_filter()
|
|
154
|
+
|
|
155
|
+
def set_filter(self, mode: FilterMode) -> None:
|
|
156
|
+
self._filter_mode = mode
|
|
157
|
+
self._folded_nodes.clear()
|
|
158
|
+
self._apply_filter()
|
|
159
|
+
|
|
160
|
+
def toggle_filter(self, mode: FilterMode) -> None:
|
|
161
|
+
self._filter_mode = "default" if self._filter_mode == mode else mode
|
|
162
|
+
self._folded_nodes.clear()
|
|
163
|
+
self._apply_filter()
|
|
164
|
+
|
|
165
|
+
def set_query(self, query: str) -> None:
|
|
166
|
+
if query == self._query:
|
|
167
|
+
return
|
|
168
|
+
self._query = query
|
|
169
|
+
self._folded_nodes.clear()
|
|
170
|
+
self._apply_filter()
|
|
171
|
+
|
|
172
|
+
def toggle_label_timestamps(self) -> None:
|
|
173
|
+
self._show_label_timestamps = not self._show_label_timestamps
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Label editing
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def start_label_edit(self) -> None:
|
|
180
|
+
item = self.selected_item
|
|
181
|
+
if item is None or item.value is None:
|
|
182
|
+
return
|
|
183
|
+
self._label_editing = True
|
|
184
|
+
self._label_editing_value = item.value
|
|
185
|
+
existing = self._labels.get(item.value)
|
|
186
|
+
self._label_input = existing[0] if existing else (item.label or "")
|
|
187
|
+
|
|
188
|
+
def label_edit_key(self, event: object) -> None:
|
|
189
|
+
"""Route a KeyEvent into the label editing sub-mode."""
|
|
190
|
+
key = getattr(event, "key", "")
|
|
191
|
+
char = getattr(event, "char", None)
|
|
192
|
+
ctrl = getattr(event, "ctrl", False)
|
|
193
|
+
alt = getattr(event, "alt", False)
|
|
194
|
+
if key == "enter":
|
|
195
|
+
self.commit_label_edit()
|
|
196
|
+
elif key in ("escape",) or (key == "c" and ctrl):
|
|
197
|
+
self.cancel_label_edit()
|
|
198
|
+
elif key == "backspace":
|
|
199
|
+
self._label_input = self._label_input[:-1]
|
|
200
|
+
elif char and char.isprintable() and not ctrl and not alt:
|
|
201
|
+
self._label_input += char
|
|
202
|
+
|
|
203
|
+
def commit_label_edit(self) -> None:
|
|
204
|
+
if self._label_editing_value is not None:
|
|
205
|
+
text = self._label_input.strip() or None
|
|
206
|
+
self._set_label(self._label_editing_value, text)
|
|
207
|
+
self._label_editing = False
|
|
208
|
+
self._label_input = ""
|
|
209
|
+
self._label_editing_value = None
|
|
210
|
+
|
|
211
|
+
def cancel_label_edit(self) -> None:
|
|
212
|
+
self._label_editing = False
|
|
213
|
+
self._label_input = ""
|
|
214
|
+
self._label_editing_value = None
|
|
215
|
+
|
|
216
|
+
def update_label(self, value: T, label: str | None, timestamp: str | None = None) -> None:
|
|
217
|
+
"""External update (e.g. from session storage on load)."""
|
|
218
|
+
self._set_label(value, label, timestamp)
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Internal helpers
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _set_label(self, value: T, label: str | None, timestamp: str | None = None) -> None:
|
|
225
|
+
if label:
|
|
226
|
+
from datetime import datetime, timezone
|
|
227
|
+
ts = timestamp or datetime.now(tz=timezone.utc).isoformat()
|
|
228
|
+
self._labels[value] = (label, ts)
|
|
229
|
+
else:
|
|
230
|
+
self._labels.pop(value, None)
|
|
231
|
+
# Propagate to the all_rows entry so search_text picks up the label
|
|
232
|
+
for row in self._all_rows:
|
|
233
|
+
if row.value == value:
|
|
234
|
+
row.label = label
|
|
235
|
+
row.label_timestamp = self._labels.get(value, (None, None))[1] # type: ignore[misc]
|
|
236
|
+
break
|
|
237
|
+
if self._on_label_change is not None:
|
|
238
|
+
self._on_label_change(value, label)
|
|
239
|
+
|
|
240
|
+
def _move(self, direction: int) -> None:
|
|
241
|
+
n = len(self._filtered)
|
|
242
|
+
if not n:
|
|
243
|
+
return
|
|
244
|
+
for _ in range(n):
|
|
245
|
+
self._selected = (self._selected + direction) % n
|
|
246
|
+
if self._filtered[self._selected].selectable:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
def _build_all_children_map(self) -> dict[T, list[T]]:
|
|
250
|
+
"""parent_value → [child values] across ALL rows (for fold descendant exclusion)."""
|
|
251
|
+
m: dict[T, list[T]] = {}
|
|
252
|
+
for r in self._all_rows:
|
|
253
|
+
if r.value is not None and r.parent_value is not None:
|
|
254
|
+
m.setdefault(r.parent_value, []).append(r.value)
|
|
255
|
+
return m
|
|
256
|
+
|
|
257
|
+
def _build_visible_maps(self) -> None:
|
|
258
|
+
"""Rebuild _visible_parent_map and _visible_children_map from _filtered."""
|
|
259
|
+
visible_values: set[T] = {r.value for r in self._filtered if r.value is not None}
|
|
260
|
+
all_by_value: dict[T, TreeRow[T]] = {
|
|
261
|
+
r.value: r for r in self._all_rows if r.value is not None
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def find_visible_ancestor(val: T) -> T | None:
|
|
265
|
+
row = all_by_value.get(val)
|
|
266
|
+
if row is None:
|
|
267
|
+
return None
|
|
268
|
+
parent = row.parent_value
|
|
269
|
+
while parent is not None:
|
|
270
|
+
if parent in visible_values:
|
|
271
|
+
return parent
|
|
272
|
+
prow = all_by_value.get(parent)
|
|
273
|
+
if prow is None:
|
|
274
|
+
break
|
|
275
|
+
parent = prow.parent_value
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
parent_map: dict[T, T | None] = {}
|
|
279
|
+
children_map: dict[T | None, list[T]] = {None: []}
|
|
280
|
+
|
|
281
|
+
for row in self._filtered:
|
|
282
|
+
if row.value is None:
|
|
283
|
+
continue
|
|
284
|
+
anc = find_visible_ancestor(row.value)
|
|
285
|
+
parent_map[row.value] = anc
|
|
286
|
+
children_map.setdefault(anc, []).append(row.value)
|
|
287
|
+
|
|
288
|
+
self._visible_parent_map = parent_map
|
|
289
|
+
self._visible_children_map = children_map
|
|
290
|
+
|
|
291
|
+
def _apply_filter(self) -> None:
|
|
292
|
+
"""Rebuild _filtered from _all_rows, applying mode + folds + fuzzy query."""
|
|
293
|
+
# Save current selection value for preservation
|
|
294
|
+
if self._filtered and 0 <= self._selected < len(self._filtered):
|
|
295
|
+
self._last_selected_value = self._filtered[self._selected].value
|
|
296
|
+
|
|
297
|
+
rows = self._all_rows
|
|
298
|
+
|
|
299
|
+
if self._filter_mode == "default":
|
|
300
|
+
rows = [
|
|
301
|
+
r for r in rows
|
|
302
|
+
if r.role not in _TOOL_ROLES
|
|
303
|
+
and r.role not in _SETTINGS_ROLES
|
|
304
|
+
and not r.role.startswith("info:")
|
|
305
|
+
and r.selectable
|
|
306
|
+
]
|
|
307
|
+
elif self._filter_mode == "no-tools":
|
|
308
|
+
rows = [r for r in rows if r.role not in _TOOL_ROLES]
|
|
309
|
+
elif self._filter_mode == "user-only":
|
|
310
|
+
rows = [r for r in rows if r.role == "user"]
|
|
311
|
+
elif self._filter_mode == "labeled-only":
|
|
312
|
+
rows = [
|
|
313
|
+
r for r in rows
|
|
314
|
+
if r.value is not None and (
|
|
315
|
+
r.value in self._labels or r.label is not None
|
|
316
|
+
)
|
|
317
|
+
]
|
|
318
|
+
# "all": keep everything
|
|
319
|
+
|
|
320
|
+
# Exclude descendants of folded nodes
|
|
321
|
+
if self._folded_nodes:
|
|
322
|
+
all_children = self._build_all_children_map()
|
|
323
|
+
skip: set[T] = set()
|
|
324
|
+
queue: list[T] = []
|
|
325
|
+
for fid in self._folded_nodes:
|
|
326
|
+
queue.extend(all_children.get(fid, []))
|
|
327
|
+
while queue:
|
|
328
|
+
nid = queue.pop()
|
|
329
|
+
skip.add(nid)
|
|
330
|
+
queue.extend(all_children.get(nid, []))
|
|
331
|
+
rows = [r for r in rows if r.value not in skip]
|
|
332
|
+
|
|
333
|
+
if self._query:
|
|
334
|
+
rows = fuzzy_filter(rows, self._query, lambda r: r.search_text)
|
|
335
|
+
|
|
336
|
+
self._filtered = rows
|
|
337
|
+
|
|
338
|
+
# Restore selection: exact match first, then nearest visible ancestor
|
|
339
|
+
if self._last_selected_value is not None and rows:
|
|
340
|
+
for i, r in enumerate(rows):
|
|
341
|
+
if r.value == self._last_selected_value:
|
|
342
|
+
self._selected = i
|
|
343
|
+
self._build_visible_maps()
|
|
344
|
+
return
|
|
345
|
+
self._selected = self._find_nearest_visible_index(self._last_selected_value)
|
|
346
|
+
else:
|
|
347
|
+
self._selected = min(self._selected, max(0, len(rows) - 1)) if rows else 0
|
|
348
|
+
|
|
349
|
+
self._build_visible_maps()
|
|
350
|
+
|
|
351
|
+
def _find_nearest_visible_index(self, value: T) -> int:
|
|
352
|
+
"""Walk up the parent chain until we find a visible ancestor; fallback to last."""
|
|
353
|
+
if not self._filtered:
|
|
354
|
+
return 0
|
|
355
|
+
visible: dict[T, int] = {
|
|
356
|
+
r.value: i for i, r in enumerate(self._filtered) if r.value is not None
|
|
357
|
+
}
|
|
358
|
+
all_by_value: dict[T, TreeRow[T]] = {
|
|
359
|
+
r.value: r for r in self._all_rows if r.value is not None
|
|
360
|
+
}
|
|
361
|
+
cur: T | None = value
|
|
362
|
+
while cur is not None:
|
|
363
|
+
if cur in visible:
|
|
364
|
+
return visible[cur]
|
|
365
|
+
row = all_by_value.get(cur)
|
|
366
|
+
if row is None:
|
|
367
|
+
break
|
|
368
|
+
cur = row.parent_value
|
|
369
|
+
return max(0, len(self._filtered) - 1)
|
|
370
|
+
|
|
371
|
+
def _is_foldable(self, value: T) -> bool:
|
|
372
|
+
"""A node is foldable if it has visible children AND is a root or segment start."""
|
|
373
|
+
children = self._visible_children_map.get(value)
|
|
374
|
+
if not children:
|
|
375
|
+
return False
|
|
376
|
+
parent = self._visible_parent_map.get(value)
|
|
377
|
+
if parent is None:
|
|
378
|
+
return True # root node is always foldable if it has children
|
|
379
|
+
siblings = self._visible_children_map.get(parent)
|
|
380
|
+
return siblings is not None and len(siblings) > 1
|
|
381
|
+
|
|
382
|
+
def _find_branch_segment_start(self, direction: str) -> int:
|
|
383
|
+
"""Jump to the start or end of the current branch segment."""
|
|
384
|
+
item = self.selected_item
|
|
385
|
+
if item is None or item.value is None:
|
|
386
|
+
return self._selected
|
|
387
|
+
|
|
388
|
+
index_by_value: dict[T, int] = {
|
|
389
|
+
r.value: i for i, r in enumerate(self._filtered) if r.value is not None
|
|
390
|
+
}
|
|
391
|
+
current = item.value
|
|
392
|
+
|
|
393
|
+
if direction == "down":
|
|
394
|
+
while True:
|
|
395
|
+
children = self._visible_children_map.get(current, [])
|
|
396
|
+
if not children:
|
|
397
|
+
return index_by_value.get(current, self._selected)
|
|
398
|
+
if len(children) > 1:
|
|
399
|
+
return index_by_value.get(children[0], self._selected)
|
|
400
|
+
current = children[0]
|
|
401
|
+
else: # up
|
|
402
|
+
while True:
|
|
403
|
+
parent = self._visible_parent_map.get(current)
|
|
404
|
+
if parent is None:
|
|
405
|
+
return index_by_value.get(current, self._selected)
|
|
406
|
+
siblings = self._visible_children_map.get(parent, [])
|
|
407
|
+
if len(siblings) > 1:
|
|
408
|
+
seg = index_by_value.get(current, self._selected)
|
|
409
|
+
if seg < self._selected:
|
|
410
|
+
return seg
|
|
411
|
+
current = parent
|
|
412
|
+
|
|
413
|
+
def _scroll_start(self) -> int:
|
|
414
|
+
"""Center the selected row in the viewport."""
|
|
415
|
+
count = len(self._filtered)
|
|
416
|
+
visible = min(self._max_visible, count)
|
|
417
|
+
center = self._selected - visible // 2
|
|
418
|
+
return max(0, min(center, count - visible))
|
|
419
|
+
|
|
420
|
+
def _status_label(self) -> str:
|
|
421
|
+
match self._filter_mode:
|
|
422
|
+
case "default": return ""
|
|
423
|
+
case "no-tools": return " [no-tools]"
|
|
424
|
+
case "user-only": return " [user]"
|
|
425
|
+
case "labeled-only": return " [labeled]"
|
|
426
|
+
case "all": return " [all]"
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _help_lines(dim: ColorFn, width: int) -> list[str]:
|
|
431
|
+
"""Render key-hint chunks onto as many lines as needed (greedy wrap)."""
|
|
432
|
+
hints = [
|
|
433
|
+
"↑/↓ move",
|
|
434
|
+
"←/→ fold/expand",
|
|
435
|
+
"pgup/pgdn page",
|
|
436
|
+
"shift+L label",
|
|
437
|
+
"shift+T label time",
|
|
438
|
+
"ctrl+d/t/u/l/a filters",
|
|
439
|
+
"ctrl+f cycle",
|
|
440
|
+
"type to search",
|
|
441
|
+
]
|
|
442
|
+
sep = " · "
|
|
443
|
+
indent = " "
|
|
444
|
+
out: list[str] = []
|
|
445
|
+
current = ""
|
|
446
|
+
for hint in hints:
|
|
447
|
+
candidate = (indent + hint) if not current else (current + sep + hint)
|
|
448
|
+
if not current or len(candidate) <= width:
|
|
449
|
+
current = candidate
|
|
450
|
+
else:
|
|
451
|
+
out.append(dim(current))
|
|
452
|
+
current = indent + hint
|
|
453
|
+
if current:
|
|
454
|
+
out.append(dim(current))
|
|
455
|
+
return out
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _format_label_ts(ts: str) -> str:
|
|
459
|
+
"""Format a label ISO timestamp (HH:MM or M/D HH:MM or YY/M/D HH:MM)."""
|
|
460
|
+
try:
|
|
461
|
+
from datetime import datetime, timezone
|
|
462
|
+
dt = datetime.fromisoformat(ts)
|
|
463
|
+
now = datetime.now(tz=timezone.utc)
|
|
464
|
+
hh_mm = dt.strftime("%H:%M")
|
|
465
|
+
if dt.date() == now.date():
|
|
466
|
+
return hh_mm
|
|
467
|
+
if dt.year == now.year:
|
|
468
|
+
return f"{dt.month}/{dt.day} {hh_mm}"
|
|
469
|
+
return f"{str(dt.year)[-2:]}/{dt.month}/{dt.day} {hh_mm}"
|
|
470
|
+
except Exception:
|
|
471
|
+
return ts
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Render
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def render(self, width: int) -> list[str]:
|
|
478
|
+
title = f"{BOLD} Session Tree{RESET}"
|
|
479
|
+
|
|
480
|
+
if self._query:
|
|
481
|
+
search_display = (
|
|
482
|
+
self._dim_color(" Type to search: ")
|
|
483
|
+
+ self._accent_color(self._query)
|
|
484
|
+
+ self._dim_color("█")
|
|
485
|
+
)
|
|
486
|
+
else:
|
|
487
|
+
search_display = self._dim_color(" Type to search:")
|
|
488
|
+
|
|
489
|
+
lines: list[str] = (
|
|
490
|
+
[title]
|
|
491
|
+
+ self._help_lines(self._dim_color, width)
|
|
492
|
+
+ [search_display, self._dim_color("─" * width), ""]
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Label-editing sub-mode: replace tree items with input prompt
|
|
496
|
+
if self._label_editing:
|
|
497
|
+
lines.append(self._dim_color(" Label (empty to remove):"))
|
|
498
|
+
lines.append(f" {self._label_input}█")
|
|
499
|
+
lines.append(self._dim_color(" enter: save · esc: cancel"))
|
|
500
|
+
lines.append("")
|
|
501
|
+
return lines
|
|
502
|
+
|
|
503
|
+
items = self._filtered
|
|
504
|
+
if not items:
|
|
505
|
+
lines.append(self._dim_color(" no matches"))
|
|
506
|
+
lines.append(self._dim_color(f" (0/0){self._status_label()}"))
|
|
507
|
+
lines.append("")
|
|
508
|
+
return lines
|
|
509
|
+
|
|
510
|
+
count = len(items)
|
|
511
|
+
visible = min(self._max_visible, count)
|
|
512
|
+
start = self._scroll_start()
|
|
513
|
+
end = start + visible
|
|
514
|
+
|
|
515
|
+
for i in range(start, end):
|
|
516
|
+
row = items[i]
|
|
517
|
+
is_sel = i == self._selected
|
|
518
|
+
val = row.value
|
|
519
|
+
|
|
520
|
+
cursor = self._accent_color("› ") if is_sel else " "
|
|
521
|
+
|
|
522
|
+
# Fold marker: replace ─ in connector with ⊟ (foldable) or ⊞ (folded)
|
|
523
|
+
is_folded = val in self._folded_nodes if val is not None else False
|
|
524
|
+
is_foldable = self._is_foldable(val) if val is not None else False
|
|
525
|
+
has_connector = "├─" in row.prefix or "└─" in row.prefix
|
|
526
|
+
raw_prefix = row.prefix
|
|
527
|
+
if has_connector:
|
|
528
|
+
if is_folded:
|
|
529
|
+
raw_prefix = raw_prefix.replace("├─", "├⊞", 1).replace("└─", "└⊞", 1)
|
|
530
|
+
elif is_foldable:
|
|
531
|
+
raw_prefix = raw_prefix.replace("├─", "├⊟", 1).replace("└─", "└⊟", 1)
|
|
532
|
+
prefix = self._dim_color(raw_prefix)
|
|
533
|
+
|
|
534
|
+
# Folded root nodes (no connector) get a ⊞ fold marker before the path marker
|
|
535
|
+
fold_root_marker = ""
|
|
536
|
+
if is_folded and not has_connector:
|
|
537
|
+
fold_root_marker = self._accent_color("⊞ ")
|
|
538
|
+
|
|
539
|
+
# Active path: "" for non-active nodes (no reserved space)
|
|
540
|
+
marker = self._accent_color("• ") if row.on_active_path else ""
|
|
541
|
+
|
|
542
|
+
# Label (in-memory override takes precedence over row.label)
|
|
543
|
+
label_str = ""
|
|
544
|
+
stored = self._labels.get(val) if val is not None else None
|
|
545
|
+
lbl_text = (stored[0] if stored else None) or row.label
|
|
546
|
+
if lbl_text:
|
|
547
|
+
label_str = f"\x1b[33m[{lbl_text}]\x1b[0m " # warning yellow
|
|
548
|
+
if self._show_label_timestamps:
|
|
549
|
+
ts = (stored[1] if stored else None) or row.label_timestamp
|
|
550
|
+
if ts:
|
|
551
|
+
label_str += self._dim_color(self._format_label_ts(ts) + " ")
|
|
552
|
+
|
|
553
|
+
# Entry content
|
|
554
|
+
color = self._dim_color if not row.selectable else self._role_color(row.role, row.text)
|
|
555
|
+
if row.role in ("user", "assistant"):
|
|
556
|
+
content = color(f"{row.role}: ") + row.text
|
|
557
|
+
elif row.text:
|
|
558
|
+
content = color(f"[{row.role}]: {row.text}")
|
|
559
|
+
else:
|
|
560
|
+
content = color(f"[{row.role}]")
|
|
561
|
+
if not row.selectable:
|
|
562
|
+
content += self._dim_color(" (pending tool result — can't branch here)")
|
|
563
|
+
elif row.is_current:
|
|
564
|
+
content += self._dim_color(" (current)")
|
|
565
|
+
|
|
566
|
+
line = truncate(cursor + prefix + fold_root_marker + marker + label_str + content, width)
|
|
567
|
+
if is_sel and self._selected_bg is not None:
|
|
568
|
+
fill = max(0, width - visible_width(line))
|
|
569
|
+
line = self._selected_bg(line + " " * fill)
|
|
570
|
+
|
|
571
|
+
lines.append(line)
|
|
572
|
+
|
|
573
|
+
# counter then blank line
|
|
574
|
+
status = self._status_label()
|
|
575
|
+
if self._show_label_timestamps:
|
|
576
|
+
status += " [+label time]"
|
|
577
|
+
lines.append(self._dim_color(f" ({self._selected + 1}/{count}){status}"))
|
|
578
|
+
lines.append("")
|
|
579
|
+
|
|
580
|
+
return lines
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from tau.tui.ansi import RESET, BOLD, BRIGHT_BLACK, BRIGHT_WHITE, CYAN
|
|
6
|
+
from tau.tui.component import Component
|
|
7
|
+
from tau.tui.input import InputEvent, KeyEvent
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from tau.trust.manager import TrustOption
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TrustScreen(Component):
|
|
14
|
+
"""Full-screen trust prompt shown before the normal TUI layout.
|
|
15
|
+
|
|
16
|
+
Replaces the TUI root until the user makes a trust decision.
|
|
17
|
+
Navigation: up/down arrows, Enter to confirm, Esc to cancel.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
cwd: str,
|
|
23
|
+
options: list[TrustOption],
|
|
24
|
+
on_commit: Callable[[TrustOption | None], None],
|
|
25
|
+
) -> None:
|
|
26
|
+
self._cwd = cwd
|
|
27
|
+
self._options = options
|
|
28
|
+
self._selected = 0
|
|
29
|
+
self._on_commit = on_commit
|
|
30
|
+
|
|
31
|
+
# -------------------------------------------------------------------------
|
|
32
|
+
# Component
|
|
33
|
+
# -------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def render(self, width: int) -> list[str]:
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
indent = " "
|
|
38
|
+
|
|
39
|
+
lines.append("")
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
lines.append(indent + BOLD + BRIGHT_WHITE + "Trust project folder?" + RESET)
|
|
43
|
+
lines.append("")
|
|
44
|
+
|
|
45
|
+
cwd_display = self._cwd
|
|
46
|
+
if len(cwd_display) > width - len(indent) - 2:
|
|
47
|
+
cwd_display = "…" + cwd_display[-(width - len(indent) - 3):]
|
|
48
|
+
lines.append(indent + CYAN + cwd_display + RESET)
|
|
49
|
+
lines.append("")
|
|
50
|
+
|
|
51
|
+
lines.append(indent + BRIGHT_BLACK + "This allows tau to load .py settings and resources," + RESET)
|
|
52
|
+
lines.append(indent + BRIGHT_BLACK + "install missing project packages, and run project extensions." + RESET)
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append("")
|
|
55
|
+
|
|
56
|
+
for i, opt in enumerate(self._options):
|
|
57
|
+
is_sel = i == self._selected
|
|
58
|
+
prefix = "› " if is_sel else " "
|
|
59
|
+
label = opt.label
|
|
60
|
+
row = indent + prefix + label
|
|
61
|
+
if is_sel:
|
|
62
|
+
row = BOLD + BRIGHT_WHITE + row + RESET
|
|
63
|
+
else:
|
|
64
|
+
row = BRIGHT_BLACK + row + RESET
|
|
65
|
+
lines.append(row)
|
|
66
|
+
|
|
67
|
+
lines.append("")
|
|
68
|
+
lines.append("")
|
|
69
|
+
|
|
70
|
+
lines.append(indent + BRIGHT_BLACK + "↑↓ navigate · Enter select · Esc cancel" + RESET)
|
|
71
|
+
|
|
72
|
+
return lines
|
|
73
|
+
|
|
74
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
75
|
+
if not isinstance(event, KeyEvent):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if event.key == "up":
|
|
79
|
+
self._selected = (self._selected - 1) % len(self._options)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
if event.key == "down":
|
|
83
|
+
self._selected = (self._selected + 1) % len(self._options)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
if event.key in ("enter", "return"):
|
|
87
|
+
self._on_commit(self._options[self._selected])
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
if event.key == "escape":
|
|
91
|
+
self._on_commit(None)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def invalidate(self) -> None:
|
|
97
|
+
pass
|