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,1194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from tau.tui.components.autocomplete_manager import AutocompleteManager
|
|
7
|
+
from tau.tui.components.command_palette import CommandPalette
|
|
8
|
+
from tau.tui.components.file_picker import FilePicker
|
|
9
|
+
from tau.tui.components.model_palette import ModelPalette # noqa: F401 (kept for compat)
|
|
10
|
+
from tau.tui.components.inline_selector import InlineSelector
|
|
11
|
+
from tau.tui.components.text_prompt import TextPrompt
|
|
12
|
+
from tau.tui.component import Component, Container
|
|
13
|
+
from tau.tui.input import InputEvent, KeyEvent, MouseEvent, PasteEvent # MouseEvent kept for type narrowing
|
|
14
|
+
from tau.tui.components.message_list import MessageList, MessageBlock
|
|
15
|
+
from tau.tui.components.select_list import SelectItem, SelectList
|
|
16
|
+
from tau.tui.components.spinner import Spinner
|
|
17
|
+
from tau.tui.components.tree_select_list import TreeRow, TreeSelectList
|
|
18
|
+
from tau.tui.components.text_input import TextInput
|
|
19
|
+
from tau.tui.theme import LayoutTheme
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from tau.commands.types import CommandInfo
|
|
23
|
+
from tau.tui.tui import TUI
|
|
24
|
+
from tau.tui.overlay import OverlayHandle, CustomOptions
|
|
25
|
+
from tau.tui.autocomplete import AutocompleteRegistration
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _PendingLines(Component):
|
|
29
|
+
"""Mutable list of pre-rendered lines for the pending-queue display."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.lines: list[str] = []
|
|
33
|
+
|
|
34
|
+
def render(self, width: int) -> list[str]: # noqa: ARG002
|
|
35
|
+
return list(self.lines)
|
|
36
|
+
|
|
37
|
+
def invalidate(self) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Layout(Component):
|
|
42
|
+
"""
|
|
43
|
+
The editor zone — dividers, text input, pickers, palette, and footer.
|
|
44
|
+
|
|
45
|
+
Layout registers itself as a TUI child in ``__init__`` and also manages
|
|
46
|
+
the named Container zones (``header``, ``status``, ``widgets_above``,
|
|
47
|
+
``widgets_below``) that appear as sibling children above/around it.
|
|
48
|
+
TUI owns viewport scroll; Layout returns its full content stream without
|
|
49
|
+
any clipping.
|
|
50
|
+
|
|
51
|
+
Component assembly happens in ``__init__``:
|
|
52
|
+
|
|
53
|
+
┌────────────────────────────┐
|
|
54
|
+
│ header zone (Container) │ ← tui.children[0]
|
|
55
|
+
│ messages │ ← tui.children[1] (MessageList)
|
|
56
|
+
│ spinner │ ← tui.children[2]
|
|
57
|
+
│ pending queue │ ← tui.children[3]
|
|
58
|
+
│ status zone (Container) │ ← tui.children[4]
|
|
59
|
+
│ widgets_above (Container) │ ← tui.children[5]
|
|
60
|
+
│ ────────────────────── │ ─┐
|
|
61
|
+
│ TextInput │ │ tui.children[6] = this Layout
|
|
62
|
+
│ ────────────────────── │ │
|
|
63
|
+
│ pickers / palette │ │
|
|
64
|
+
│ footer │ ─┘
|
|
65
|
+
│ widgets_below (Container) │ ← tui.children[7]
|
|
66
|
+
└────────────────────────────┘
|
|
67
|
+
|
|
68
|
+
Scroll (page_up / mouse-wheel) is handled by TUI._dispatch so
|
|
69
|
+
handle_input() deals only with editor and picker events.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
tui: TUI,
|
|
75
|
+
theme: LayoutTheme | None = None,
|
|
76
|
+
picker_max_visible: int = 8,
|
|
77
|
+
autocomplete_max_visible: int = 5,
|
|
78
|
+
editor_padding_x: int = 0,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Initialize layout with TUI instance and default theme."""
|
|
81
|
+
self._tui = tui
|
|
82
|
+
self._theme = theme or LayoutTheme()
|
|
83
|
+
self._picker_max_visible = picker_max_visible
|
|
84
|
+
|
|
85
|
+
self.messages = MessageList(
|
|
86
|
+
theme=self._theme.message,
|
|
87
|
+
user_prefix=self._theme.input.prefix,
|
|
88
|
+
)
|
|
89
|
+
self.spinner = Spinner(tui, theme=self._theme.spinner)
|
|
90
|
+
self.footer: Container = Container()
|
|
91
|
+
self.palette = CommandPalette()
|
|
92
|
+
self.input = TextInput(
|
|
93
|
+
prefix=self._theme.input.prefix,
|
|
94
|
+
placeholder=self._theme.input.placeholder,
|
|
95
|
+
padding_x=editor_padding_x,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# ── Public Container zones ──────────────────────────────────────────
|
|
99
|
+
# Add children directly or use the helper APIs below.
|
|
100
|
+
#
|
|
101
|
+
# layout.header.add_child(Banner()) # above messages
|
|
102
|
+
# layout.footer.add_child(my_widget) # inside the footer (above status bar)
|
|
103
|
+
# layout.status.add_child(my_status) # above editor
|
|
104
|
+
# layout.widgets_above.add_child(widget) # between status & editor
|
|
105
|
+
# layout.widgets_below.add_child(widget) # below editor
|
|
106
|
+
self.header: Container = Container()
|
|
107
|
+
self.status: Container = Container()
|
|
108
|
+
self.widgets_above: Container = Container()
|
|
109
|
+
self.widgets_below: Container = Container()
|
|
110
|
+
|
|
111
|
+
self._all_commands: list[CommandInfo] = []
|
|
112
|
+
|
|
113
|
+
# Returns True while the agent is streaming; lets handle_input distinguish
|
|
114
|
+
# "ESC clears the editor" (idle) from "ESC aborts the run" (busy — must
|
|
115
|
+
# fall through to the global key handler). Defaults to never-busy.
|
|
116
|
+
self._is_busy: Callable[[], bool] = lambda: False
|
|
117
|
+
|
|
118
|
+
# Pending queue display — steering/followup messages waiting to be consumed
|
|
119
|
+
self._pending_lines: _PendingLines = _PendingLines()
|
|
120
|
+
|
|
121
|
+
# Key-tracked widget maps — set_widget/remove_widget manage these;
|
|
122
|
+
# the components are also held in the Container zones above.
|
|
123
|
+
self._widgets_above_map: dict[str, Component] = {}
|
|
124
|
+
self._widgets_below_map: dict[str, Component] = {}
|
|
125
|
+
|
|
126
|
+
# Key-tracked status lines — set_status() manages this;
|
|
127
|
+
# rendered as dim text immediately above the editor.
|
|
128
|
+
self._status_map: dict[str, str] = {}
|
|
129
|
+
|
|
130
|
+
# Cached submit/followup/dequeue callbacks — re-wired after editor replacement
|
|
131
|
+
self._stored_submit_cb: Callable[[str], None] | None = None
|
|
132
|
+
self._stored_followup_cb: Callable[[str], None] | None = None
|
|
133
|
+
self._stored_dequeue_cb: Callable[[], None] | None = None
|
|
134
|
+
self._custom_input_factory: Callable[[Any, Any], Any] | None = None
|
|
135
|
+
|
|
136
|
+
# File picker — shown when user types '@'
|
|
137
|
+
self.file_picker = FilePicker()
|
|
138
|
+
self._at_pos: int = 0 # char index of '@' in input text when picker opened
|
|
139
|
+
|
|
140
|
+
# Inline pickers — rendered in the content stream below the input
|
|
141
|
+
self._active_selector: InlineSelector | None = None
|
|
142
|
+
self._settings_panel: list[str] | None = None
|
|
143
|
+
self._prompt = TextPrompt()
|
|
144
|
+
self._oauth_status_lines: list[str] | None = None
|
|
145
|
+
|
|
146
|
+
# Model palette — inline filtered model list for '/model <name> <provider>'
|
|
147
|
+
|
|
148
|
+
# Extension autocomplete + command argument completions
|
|
149
|
+
self._autocomplete = AutocompleteManager(
|
|
150
|
+
max_visible=autocomplete_max_visible,
|
|
151
|
+
request_render=tui.request_render,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Register zones with TUI in render order. widgets_above, widgets_below,
|
|
155
|
+
# and _status_map are rendered by Layout itself (they sit between the
|
|
156
|
+
# dividers and pickers); the remaining zones become TUI children.
|
|
157
|
+
tui.add_child(self.header)
|
|
158
|
+
tui.add_child(self.messages)
|
|
159
|
+
tui.add_child(self.spinner)
|
|
160
|
+
tui.add_child(self._pending_lines)
|
|
161
|
+
tui.add_child(self.status)
|
|
162
|
+
tui.add_child(self) # Layout = EditorZone (status_map + dividers + input + pickers + footer)
|
|
163
|
+
|
|
164
|
+
# -------------------------------------------------------------------------
|
|
165
|
+
# Attach / detach (for full-screen takeovers and TrustScreen)
|
|
166
|
+
# -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def attach(self, tui: TUI) -> None:
|
|
169
|
+
"""Re-add all zones to ``tui`` in the correct render order."""
|
|
170
|
+
tui.add_child(self.header)
|
|
171
|
+
tui.add_child(self.messages)
|
|
172
|
+
tui.add_child(self.spinner)
|
|
173
|
+
tui.add_child(self._pending_lines)
|
|
174
|
+
tui.add_child(self.status)
|
|
175
|
+
tui.add_child(self)
|
|
176
|
+
|
|
177
|
+
def detach(self, tui: TUI) -> None:
|
|
178
|
+
"""Remove all zones from ``tui`` (used before installing a full-screen takeover)."""
|
|
179
|
+
tui.clear()
|
|
180
|
+
|
|
181
|
+
# -------------------------------------------------------------------------
|
|
182
|
+
# Component
|
|
183
|
+
# -------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def render(self, width: int) -> list[str]:
|
|
186
|
+
"""Render the editor zone: status-map, dividers, input, pickers, footer."""
|
|
187
|
+
# ── Build the editor content stream ───────────────────────────────
|
|
188
|
+
content: list[str] = []
|
|
189
|
+
|
|
190
|
+
# Status zone — keyed status lines above the editor
|
|
191
|
+
if self._status_map:
|
|
192
|
+
from tau.tui.ansi import DIM, RESET
|
|
193
|
+
for text in self._status_map.values():
|
|
194
|
+
content.append(f" {DIM}{text}{RESET}")
|
|
195
|
+
|
|
196
|
+
# Widgets above editor
|
|
197
|
+
if self.widgets_above.children:
|
|
198
|
+
content.extend(self.widgets_above.render(width))
|
|
199
|
+
|
|
200
|
+
# Modals that replace the input box (input disappears when a modal is active).
|
|
201
|
+
# The file picker is NOT a modal — like the '/' palette it renders below the
|
|
202
|
+
# input while the editor stays visible and receives the query keystrokes.
|
|
203
|
+
any_modal = (
|
|
204
|
+
self._active_selector is not None
|
|
205
|
+
or self._settings_panel is not None
|
|
206
|
+
or self._prompt.active
|
|
207
|
+
or self._oauth_status_lines is not None
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Divider color: when a modal is active use plain divider; otherwise reflect input state
|
|
211
|
+
_text = self.input.text
|
|
212
|
+
if any_modal:
|
|
213
|
+
_divider = self._theme.divider
|
|
214
|
+
elif _text.startswith("!"):
|
|
215
|
+
_divider = self._theme.divider_execute
|
|
216
|
+
elif _text.startswith("/"):
|
|
217
|
+
_divider = self._theme.divider_command
|
|
218
|
+
else:
|
|
219
|
+
_divider = self._theme.divider
|
|
220
|
+
|
|
221
|
+
content.append(_divider("─" * width))
|
|
222
|
+
|
|
223
|
+
if any_modal:
|
|
224
|
+
# Modal replaces the input between the two dividers
|
|
225
|
+
if self._active_selector is not None:
|
|
226
|
+
content.extend(self._active_selector.render(width))
|
|
227
|
+
elif self._settings_panel is not None:
|
|
228
|
+
content.extend(self._settings_panel)
|
|
229
|
+
elif self._prompt.active:
|
|
230
|
+
content.extend(self._prompt.render(width))
|
|
231
|
+
elif self._oauth_status_lines is not None:
|
|
232
|
+
content.extend(self._oauth_status_lines)
|
|
233
|
+
else:
|
|
234
|
+
# Normal: show the input editor
|
|
235
|
+
content.extend(self.input.render(width))
|
|
236
|
+
|
|
237
|
+
content.append(_divider("─" * width))
|
|
238
|
+
|
|
239
|
+
# Widgets below editor (only when no modal)
|
|
240
|
+
if not any_modal and self.widgets_below.children:
|
|
241
|
+
content.extend(self.widgets_below.render(width))
|
|
242
|
+
|
|
243
|
+
# Palette, file picker, and autocomplete — always below (driven by live input
|
|
244
|
+
# text); hidden during modals. The file picker renders here so it appears under
|
|
245
|
+
# the input, the same way the '/' command palette does.
|
|
246
|
+
any_inline = any_modal or self.palette.active or self.file_picker.active
|
|
247
|
+
if not any_modal:
|
|
248
|
+
content.extend(self.palette.render(width))
|
|
249
|
+
content.extend(self.file_picker.render(width))
|
|
250
|
+
content.extend(self._autocomplete.render(width))
|
|
251
|
+
|
|
252
|
+
# Footer — hidden when any picker/modal is active
|
|
253
|
+
if not any_inline:
|
|
254
|
+
content.extend(self.footer.render(width))
|
|
255
|
+
|
|
256
|
+
return content
|
|
257
|
+
|
|
258
|
+
def handle_input(self, event: InputEvent) -> bool:
|
|
259
|
+
"""Process input event and return True if consumed.
|
|
260
|
+
|
|
261
|
+
Scroll and mouse events are handled by TUI._dispatch before reaching here,
|
|
262
|
+
so this method deals only with editor and picker navigation.
|
|
263
|
+
"""
|
|
264
|
+
if isinstance(event, MouseEvent):
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# Bracketed paste while a modal is open: route the text into the active
|
|
268
|
+
# selector's search/edit buffer (e.g. pasting an API key into a settings
|
|
269
|
+
# text field) instead of letting it fall through to the unfocused editor.
|
|
270
|
+
if isinstance(event, PasteEvent) and self._active_selector is not None:
|
|
271
|
+
appender = getattr(self._active_selector.selector, "append_search", None)
|
|
272
|
+
if appender is not None:
|
|
273
|
+
appender(event.text.replace("\r", ""))
|
|
274
|
+
self._tui.request_render()
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
if isinstance(event, KeyEvent):
|
|
278
|
+
# Generic inline selector (model, theme, effort, resume, tree) — modal
|
|
279
|
+
if self._active_selector is not None:
|
|
280
|
+
sel = self._active_selector
|
|
281
|
+
tree_sel = sel.selector if sel.kind == "tree" else None
|
|
282
|
+
model_sel = sel.selector if sel.kind == "model" else None
|
|
283
|
+
simple_sel = sel.selector if sel.kind in ("theme", "effort") else None
|
|
284
|
+
|
|
285
|
+
# Tree label-editing sub-mode: route all keys directly
|
|
286
|
+
if tree_sel is not None and getattr(tree_sel, "label_editing", False):
|
|
287
|
+
tree_sel.label_edit_key(event)
|
|
288
|
+
self._tui.request_render()
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# Simple list modal (theme, effort): owns navigation
|
|
292
|
+
if simple_sel is not None:
|
|
293
|
+
match event.key:
|
|
294
|
+
case "up":
|
|
295
|
+
simple_sel.move_up()
|
|
296
|
+
case "down":
|
|
297
|
+
simple_sel.move_down()
|
|
298
|
+
case "enter":
|
|
299
|
+
val = simple_sel.selected_value()
|
|
300
|
+
cb, cancel_cb = sel.on_commit, sel.on_cancel
|
|
301
|
+
self._active_selector = None
|
|
302
|
+
if val is not None:
|
|
303
|
+
cb(val)
|
|
304
|
+
else:
|
|
305
|
+
cancel_cb()
|
|
306
|
+
case "escape":
|
|
307
|
+
cb = sel.on_cancel
|
|
308
|
+
self._active_selector = None
|
|
309
|
+
cb()
|
|
310
|
+
self._tui.request_render()
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
# Model selector: owns search, scope, navigation
|
|
314
|
+
if model_sel is not None:
|
|
315
|
+
match event.key:
|
|
316
|
+
case "up":
|
|
317
|
+
model_sel.move_up()
|
|
318
|
+
case "down":
|
|
319
|
+
model_sel.move_down()
|
|
320
|
+
case "tab":
|
|
321
|
+
model_sel.toggle_scope()
|
|
322
|
+
case "enter":
|
|
323
|
+
val = model_sel.selected_value()
|
|
324
|
+
cb, cancel_cb = sel.on_commit, sel.on_cancel
|
|
325
|
+
self._active_selector = None
|
|
326
|
+
if val is not None:
|
|
327
|
+
cb(val)
|
|
328
|
+
else:
|
|
329
|
+
cancel_cb()
|
|
330
|
+
case "escape":
|
|
331
|
+
cb = sel.on_cancel
|
|
332
|
+
self._active_selector = None
|
|
333
|
+
cb()
|
|
334
|
+
case "backspace":
|
|
335
|
+
model_sel.backspace_search()
|
|
336
|
+
case ch if len(ch) == 1 and ch.isprintable():
|
|
337
|
+
model_sel.append_search(ch)
|
|
338
|
+
self._tui.request_render()
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
# Settings modal: interactive settings list
|
|
342
|
+
settings_sel = sel.selector if sel.kind == "settings" else None
|
|
343
|
+
if settings_sel is not None:
|
|
344
|
+
match event.key:
|
|
345
|
+
case "up":
|
|
346
|
+
settings_sel.move_up()
|
|
347
|
+
case "down":
|
|
348
|
+
settings_sel.move_down()
|
|
349
|
+
case "enter" | " ":
|
|
350
|
+
settings_sel.activate()
|
|
351
|
+
case "escape":
|
|
352
|
+
if settings_sel.in_submenu:
|
|
353
|
+
settings_sel.cancel_submenu()
|
|
354
|
+
else:
|
|
355
|
+
cb = sel.on_cancel
|
|
356
|
+
self._active_selector = None
|
|
357
|
+
cb()
|
|
358
|
+
case "backspace":
|
|
359
|
+
settings_sel.backspace_search()
|
|
360
|
+
case ch if len(ch) == 1 and ch.isprintable():
|
|
361
|
+
# Prefer event.char so shifted keys keep their case
|
|
362
|
+
# (e.g. capitals in a case-sensitive API key).
|
|
363
|
+
settings_sel.append_search(event.char or ch)
|
|
364
|
+
self._tui.request_render()
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
# Resume modal: session picker with search/scope/delete
|
|
368
|
+
resume_sel = sel.selector if sel.kind == "resume" else None
|
|
369
|
+
if resume_sel is not None:
|
|
370
|
+
match event.key:
|
|
371
|
+
case "up":
|
|
372
|
+
resume_sel.move_up()
|
|
373
|
+
case "down":
|
|
374
|
+
resume_sel.move_down()
|
|
375
|
+
case "tab":
|
|
376
|
+
resume_sel.toggle_scope()
|
|
377
|
+
case "r" if event.ctrl:
|
|
378
|
+
resume_sel.cycle_sort()
|
|
379
|
+
case "d" if event.ctrl:
|
|
380
|
+
resume_sel.start_delete()
|
|
381
|
+
case "enter":
|
|
382
|
+
if resume_sel.confirming_delete:
|
|
383
|
+
resume_sel.confirm_delete()
|
|
384
|
+
else:
|
|
385
|
+
path = resume_sel.selected_path()
|
|
386
|
+
cb, cancel_cb = sel.on_commit, sel.on_cancel
|
|
387
|
+
self._active_selector = None
|
|
388
|
+
if path is not None:
|
|
389
|
+
cb(path)
|
|
390
|
+
else:
|
|
391
|
+
cancel_cb()
|
|
392
|
+
case "escape":
|
|
393
|
+
if resume_sel.confirming_delete:
|
|
394
|
+
resume_sel.cancel_delete()
|
|
395
|
+
else:
|
|
396
|
+
cb = sel.on_cancel
|
|
397
|
+
self._active_selector = None
|
|
398
|
+
cb()
|
|
399
|
+
case "backspace":
|
|
400
|
+
resume_sel.backspace_search()
|
|
401
|
+
case ch if len(ch) == 1 and ch.isprintable():
|
|
402
|
+
resume_sel.append_search(ch)
|
|
403
|
+
self._tui.request_render()
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
match event.key:
|
|
407
|
+
case "up":
|
|
408
|
+
sel.nav(-1)
|
|
409
|
+
case "down":
|
|
410
|
+
sel.nav(1)
|
|
411
|
+
# Page up/down for tree (page_up/page_down keys, or ctrl+←/→ as alias)
|
|
412
|
+
case "page_up" if tree_sel is not None:
|
|
413
|
+
tree_sel.page_up()
|
|
414
|
+
case "page_down" if tree_sel is not None:
|
|
415
|
+
tree_sel.page_down()
|
|
416
|
+
case "left" if tree_sel is not None and (event.ctrl or event.alt):
|
|
417
|
+
tree_sel.page_up()
|
|
418
|
+
case "right" if tree_sel is not None and (event.ctrl or event.alt):
|
|
419
|
+
tree_sel.page_down()
|
|
420
|
+
# ←/→ (plain) fold/unfold — standard tree behavior; alt+←/→ as alias
|
|
421
|
+
case "left" if tree_sel is not None:
|
|
422
|
+
tree_sel.fold_or_up()
|
|
423
|
+
case "right" if tree_sel is not None:
|
|
424
|
+
tree_sel.unfold_or_down()
|
|
425
|
+
case "enter" | "tab":
|
|
426
|
+
val = sel.selected_value()
|
|
427
|
+
cb, cancel_cb = sel.on_commit, sel.on_cancel
|
|
428
|
+
self._active_selector = None
|
|
429
|
+
if val is not None:
|
|
430
|
+
cb(val)
|
|
431
|
+
else:
|
|
432
|
+
cancel_cb()
|
|
433
|
+
case "escape":
|
|
434
|
+
cb = sel.on_cancel
|
|
435
|
+
self._active_selector = None
|
|
436
|
+
cb()
|
|
437
|
+
# Tree filter shortcuts (Ctrl+D/T/U/L/A)
|
|
438
|
+
case "d" if event.ctrl and tree_sel is not None:
|
|
439
|
+
tree_sel.set_filter("default")
|
|
440
|
+
case "t" if event.ctrl and tree_sel is not None:
|
|
441
|
+
tree_sel.toggle_filter("no-tools")
|
|
442
|
+
case "u" if event.ctrl and tree_sel is not None:
|
|
443
|
+
tree_sel.toggle_filter("user-only")
|
|
444
|
+
case "l" if event.ctrl and tree_sel is not None:
|
|
445
|
+
tree_sel.toggle_filter("labeled-only")
|
|
446
|
+
case "a" if event.ctrl and tree_sel is not None:
|
|
447
|
+
tree_sel.toggle_filter("all")
|
|
448
|
+
# Cycle filter (ctrl+f = forward, ctrl+o kept for compat)
|
|
449
|
+
case "f" if event.ctrl and tree_sel is not None:
|
|
450
|
+
tree_sel.cycle_filter()
|
|
451
|
+
# Label editing (shift+L)
|
|
452
|
+
case "l" if event.shift and tree_sel is not None:
|
|
453
|
+
tree_sel.start_label_edit()
|
|
454
|
+
# Label timestamp toggle (shift+T)
|
|
455
|
+
case "t" if event.shift and tree_sel is not None:
|
|
456
|
+
tree_sel.toggle_label_timestamps()
|
|
457
|
+
case "backspace" if sel.searchable:
|
|
458
|
+
sel.backspace_search()
|
|
459
|
+
case ch if sel.searchable and len(ch) == 1 and ch.isprintable():
|
|
460
|
+
sel.append_search(ch)
|
|
461
|
+
self._tui.request_render()
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
# Settings panel: Esc closes
|
|
465
|
+
if self._settings_panel is not None:
|
|
466
|
+
if event.key == "escape":
|
|
467
|
+
self._settings_panel = None
|
|
468
|
+
self._tui.request_render()
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
# Text prompt
|
|
472
|
+
if self._prompt.active:
|
|
473
|
+
self._prompt.handle_input(event)
|
|
474
|
+
self._tui.request_render()
|
|
475
|
+
return True
|
|
476
|
+
|
|
477
|
+
# File picker navigation (active when '@' is in progress)
|
|
478
|
+
if self.file_picker.active:
|
|
479
|
+
if event.key in ("up",):
|
|
480
|
+
self.file_picker.move_up()
|
|
481
|
+
return True
|
|
482
|
+
if event.key in ("down",):
|
|
483
|
+
self.file_picker.move_down()
|
|
484
|
+
return True
|
|
485
|
+
if event.key == "tab":
|
|
486
|
+
self._accept_file_or_descend()
|
|
487
|
+
return True
|
|
488
|
+
if event.key == "escape":
|
|
489
|
+
self.file_picker.close()
|
|
490
|
+
self._tui.request_render()
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
# Autocomplete navigation (extension triggers + command arg completions)
|
|
494
|
+
consumed, new_text = self._autocomplete.handle_input(
|
|
495
|
+
event, self.input.text, self.input._cursor
|
|
496
|
+
)
|
|
497
|
+
if consumed:
|
|
498
|
+
if new_text is not None:
|
|
499
|
+
self.input.set_text(new_text)
|
|
500
|
+
self._tui.request_render()
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
# Palette navigation
|
|
504
|
+
if self.palette.active:
|
|
505
|
+
if event.key == "up":
|
|
506
|
+
self.palette.move_up()
|
|
507
|
+
return True
|
|
508
|
+
if event.key == "down":
|
|
509
|
+
self.palette.move_down()
|
|
510
|
+
return True
|
|
511
|
+
if event.key == "escape":
|
|
512
|
+
self.input.clear()
|
|
513
|
+
self._sync_pickers()
|
|
514
|
+
return True
|
|
515
|
+
if event.key in ("tab", "right"):
|
|
516
|
+
self._accept_palette_selection()
|
|
517
|
+
return True
|
|
518
|
+
if event.key == "enter":
|
|
519
|
+
self._execute_palette_selection()
|
|
520
|
+
return True
|
|
521
|
+
|
|
522
|
+
result = self.input.handle_input(event)
|
|
523
|
+
# Escape in normal mode (no modal open, agent idle): clear the editor.
|
|
524
|
+
# While the agent is streaming, leave ESC unconsumed so it reaches the
|
|
525
|
+
# global key handler, which turns it into an abort.
|
|
526
|
+
if (
|
|
527
|
+
isinstance(event, KeyEvent)
|
|
528
|
+
and event.key == "escape"
|
|
529
|
+
and not result
|
|
530
|
+
and not self._is_busy()
|
|
531
|
+
):
|
|
532
|
+
self.input.clear()
|
|
533
|
+
result = True
|
|
534
|
+
self._sync_pickers()
|
|
535
|
+
return result
|
|
536
|
+
|
|
537
|
+
def invalidate(self) -> None:
|
|
538
|
+
"""Invalidate message rendering cache."""
|
|
539
|
+
self.messages.invalidate()
|
|
540
|
+
|
|
541
|
+
# -------------------------------------------------------------------------
|
|
542
|
+
# Command palette + file picker sync
|
|
543
|
+
# -------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def set_commands(self, commands: list[CommandInfo]) -> None:
|
|
546
|
+
"""Update the list of available commands for the palette."""
|
|
547
|
+
self._all_commands = list(commands)
|
|
548
|
+
|
|
549
|
+
def set_busy_check(self, is_busy: Callable[[], bool]) -> None:
|
|
550
|
+
"""Wire a predicate reporting whether the agent is currently streaming.
|
|
551
|
+
|
|
552
|
+
Used so ESC clears the editor only when idle, and otherwise propagates
|
|
553
|
+
to the global key handler to abort the run.
|
|
554
|
+
"""
|
|
555
|
+
self._is_busy = is_busy
|
|
556
|
+
|
|
557
|
+
def set_cwd(self, cwd: Path) -> None:
|
|
558
|
+
"""Update the current working directory for file picker."""
|
|
559
|
+
self.file_picker._root = cwd
|
|
560
|
+
self.file_picker._cwd = cwd
|
|
561
|
+
|
|
562
|
+
def set_model_callbacks(
|
|
563
|
+
self,
|
|
564
|
+
commit_cb: Callable[[str, str], None], # noqa: ARG002
|
|
565
|
+
current_key_cb: Callable[[], str], # noqa: ARG002
|
|
566
|
+
) -> None:
|
|
567
|
+
pass # inline model palette removed; /model now opens a modal
|
|
568
|
+
|
|
569
|
+
def _update_arg_hint(self, text: str) -> None:
|
|
570
|
+
"""Show per-placeholder ghost text after the cursor, dropping each token as the user types."""
|
|
571
|
+
import re
|
|
572
|
+
import shlex
|
|
573
|
+
if not text.startswith("/") or " " not in text:
|
|
574
|
+
self.input._arg_hint = ""
|
|
575
|
+
return
|
|
576
|
+
space_idx = text.index(" ")
|
|
577
|
+
cmd_name = text[1:space_idx]
|
|
578
|
+
args_part = text[space_idx + 1:]
|
|
579
|
+
cmd = next((c for c in self._all_commands if c.name == cmd_name or cmd_name in (c.aliases or [])), None)
|
|
580
|
+
if cmd is None or not cmd.argument_hint:
|
|
581
|
+
self.input._arg_hint = ""
|
|
582
|
+
return
|
|
583
|
+
hint_raw = cmd.argument_hint.strip()
|
|
584
|
+
placeholders = re.findall(r"<[^>]+>", hint_raw)
|
|
585
|
+
if not placeholders:
|
|
586
|
+
self.input._arg_hint = "" if args_part.strip() else hint_raw
|
|
587
|
+
return
|
|
588
|
+
# Use shlex so quoted "multi word arg" counts as one token (matches expand.py)
|
|
589
|
+
try:
|
|
590
|
+
n_started = len(shlex.split(args_part))
|
|
591
|
+
except ValueError:
|
|
592
|
+
n_started = len(args_part.split())
|
|
593
|
+
# A token being actively typed is already "consumed" — only hint what's still needed
|
|
594
|
+
remaining = placeholders[n_started:]
|
|
595
|
+
if remaining:
|
|
596
|
+
mid_token = bool(args_part) and not args_part[-1].isspace()
|
|
597
|
+
self.input._arg_hint = (" " if mid_token else "") + " ".join(remaining)
|
|
598
|
+
else:
|
|
599
|
+
self.input._arg_hint = ""
|
|
600
|
+
|
|
601
|
+
def _sync_pickers(self) -> None:
|
|
602
|
+
"""After every keystroke, decide which picker (if any) should be visible."""
|
|
603
|
+
text = self.input.text
|
|
604
|
+
self._update_arg_hint(text)
|
|
605
|
+
|
|
606
|
+
# File picker: activated by '@' with no space between '@' and cursor.
|
|
607
|
+
# Suppressed while browsing history so past @mentions don't hijack focus.
|
|
608
|
+
at_info = self._find_at_query(text) if self.input._history_idx == -1 else None
|
|
609
|
+
if at_info is not None:
|
|
610
|
+
at_pos, query = at_info
|
|
611
|
+
if not self.file_picker.active:
|
|
612
|
+
self.file_picker.open()
|
|
613
|
+
self._at_pos = at_pos
|
|
614
|
+
self.file_picker.set_query(query)
|
|
615
|
+
if len(query) >= 2 and not self.file_picker._entries:
|
|
616
|
+
self.file_picker.close()
|
|
617
|
+
self.palette.set_commands([])
|
|
618
|
+
self._autocomplete.clear()
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
if self.file_picker.active:
|
|
622
|
+
self.file_picker.close()
|
|
623
|
+
|
|
624
|
+
# Command palette: activated by leading '/' with no space.
|
|
625
|
+
# Suppressed while the user is browsing history so navigating past
|
|
626
|
+
# a previously-run slash command doesn't hijack focus.
|
|
627
|
+
if text.startswith("/") and " " not in text and self.input._history_idx == -1:
|
|
628
|
+
self.palette.set_commands(self._all_commands)
|
|
629
|
+
self.palette.set_query(text[1:])
|
|
630
|
+
self._autocomplete.clear()
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
self.palette.set_commands([])
|
|
634
|
+
|
|
635
|
+
# Delegate all autocomplete logic (cmd args + extension triggers) to the manager
|
|
636
|
+
self._autocomplete.sync(text, self.input._cursor, self._all_commands)
|
|
637
|
+
|
|
638
|
+
def _find_at_query(self, text: str) -> tuple[int, str] | None:
|
|
639
|
+
"""
|
|
640
|
+
Find the rightmost '@' before the cursor whose following text has no spaces.
|
|
641
|
+
Returns (at_pos, query_after_@) or None.
|
|
642
|
+
"""
|
|
643
|
+
cursor = self.input._cursor
|
|
644
|
+
before = text[:cursor]
|
|
645
|
+
at_pos = before.rfind("@")
|
|
646
|
+
if at_pos == -1:
|
|
647
|
+
return None
|
|
648
|
+
if at_pos > 0 and before[at_pos - 1] not in (" ", "\n"):
|
|
649
|
+
return None
|
|
650
|
+
after_at = before[at_pos + 1:]
|
|
651
|
+
if " " in after_at or ":" in after_at:
|
|
652
|
+
return None
|
|
653
|
+
return at_pos, after_at
|
|
654
|
+
|
|
655
|
+
def _accept_file_or_descend(self) -> None:
|
|
656
|
+
"""Tab pressed while file picker is active."""
|
|
657
|
+
entry = self.file_picker.enter_selected()
|
|
658
|
+
text = self.input.text
|
|
659
|
+
cursor = self.input._cursor
|
|
660
|
+
|
|
661
|
+
if entry is None:
|
|
662
|
+
rel = self.file_picker.cwd_relative_path
|
|
663
|
+
new_text = text[: self._at_pos] + "@" + rel + "/" + text[cursor:]
|
|
664
|
+
self.input.set_text(new_text)
|
|
665
|
+
self._at_pos = self._at_pos
|
|
666
|
+
else:
|
|
667
|
+
rel_path = self.file_picker.relative_path(entry)
|
|
668
|
+
new_text = text[: self._at_pos] + "@" + rel_path + " " + text[cursor:]
|
|
669
|
+
self.input.set_text(new_text)
|
|
670
|
+
self.file_picker.close()
|
|
671
|
+
|
|
672
|
+
def _accept_palette_selection(self) -> None:
|
|
673
|
+
"""Insert selected command into input with trailing space."""
|
|
674
|
+
sel = self.palette.selected
|
|
675
|
+
if sel:
|
|
676
|
+
self.input.set_text(f"/{sel.name} ")
|
|
677
|
+
self.palette.set_commands([])
|
|
678
|
+
self._sync_pickers()
|
|
679
|
+
|
|
680
|
+
def _execute_palette_selection(self) -> None:
|
|
681
|
+
"""Execute selected command immediately."""
|
|
682
|
+
sel = self.palette.selected
|
|
683
|
+
if sel:
|
|
684
|
+
self.input.set_text(f"/{sel.name}")
|
|
685
|
+
self.palette.set_commands([])
|
|
686
|
+
self.input._submit()
|
|
687
|
+
|
|
688
|
+
# -------------------------------------------------------------------------
|
|
689
|
+
# Helpers for the app layer
|
|
690
|
+
# -------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
def add_message(self, message: object, streaming: bool = False) -> MessageBlock:
|
|
693
|
+
"""Add a message to the message list."""
|
|
694
|
+
block = self.messages.add_message(message, streaming=streaming)
|
|
695
|
+
self._tui.notify_content_added()
|
|
696
|
+
return block
|
|
697
|
+
|
|
698
|
+
def clear_messages(self) -> None:
|
|
699
|
+
"""Clear all messages and pending lines."""
|
|
700
|
+
self.messages.clear()
|
|
701
|
+
self._pending_lines.lines = []
|
|
702
|
+
|
|
703
|
+
def on_submit(self, callback: Callable[[str], None]) -> None:
|
|
704
|
+
"""Register callback for user submission."""
|
|
705
|
+
self._stored_submit_cb = callback
|
|
706
|
+
self.input._on_submit = callback
|
|
707
|
+
|
|
708
|
+
def on_followup(self, callback: Callable[[str], None]) -> None:
|
|
709
|
+
"""Register callback for follow-up messages."""
|
|
710
|
+
self._stored_followup_cb = callback
|
|
711
|
+
self.input._on_followup = callback
|
|
712
|
+
|
|
713
|
+
def on_dequeue(self, callback: Callable[[], None]) -> None:
|
|
714
|
+
"""Register callback for dequeue action."""
|
|
715
|
+
self._stored_dequeue_cb = callback
|
|
716
|
+
self.input._on_dequeue = callback
|
|
717
|
+
|
|
718
|
+
# -------------------------------------------------------------------------
|
|
719
|
+
# Extension customization
|
|
720
|
+
# -------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
def set_widget(self, id: str, widget: Any, placement: str = "above_editor") -> None:
|
|
723
|
+
"""Add or replace a keyed widget in the layout.
|
|
724
|
+
|
|
725
|
+
``widget`` can be a ``Component`` instance or a list of strings
|
|
726
|
+
(rendered as a StaticComponent). Use ``placement="below_editor"``
|
|
727
|
+
to place the widget below the text input instead of above it.
|
|
728
|
+
|
|
729
|
+
Alternatively you can add components directly to the Container zones::
|
|
730
|
+
|
|
731
|
+
layout.widgets_above.add_child(MyWidget())
|
|
732
|
+
layout.widgets_below.add_child(MyOtherWidget())
|
|
733
|
+
"""
|
|
734
|
+
from tau.tui.component import StaticComponent
|
|
735
|
+
component = widget if isinstance(widget, Component) else StaticComponent(widget)
|
|
736
|
+
if placement == "below_editor":
|
|
737
|
+
old = self._widgets_below_map.pop(id, None)
|
|
738
|
+
if old is not None:
|
|
739
|
+
self.widgets_below.remove_child(old)
|
|
740
|
+
self._widgets_below_map[id] = component
|
|
741
|
+
self.widgets_below.add_child(component)
|
|
742
|
+
else:
|
|
743
|
+
old = self._widgets_above_map.pop(id, None)
|
|
744
|
+
if old is not None:
|
|
745
|
+
self.widgets_above.remove_child(old)
|
|
746
|
+
self._widgets_above_map[id] = component
|
|
747
|
+
self.widgets_above.add_child(component)
|
|
748
|
+
self._tui.request_render()
|
|
749
|
+
|
|
750
|
+
def remove_widget(self, id: str) -> None:
|
|
751
|
+
"""Remove a keyed widget from the layout."""
|
|
752
|
+
above = self._widgets_above_map.pop(id, None)
|
|
753
|
+
if above is not None:
|
|
754
|
+
self.widgets_above.remove_child(above)
|
|
755
|
+
below = self._widgets_below_map.pop(id, None)
|
|
756
|
+
if below is not None:
|
|
757
|
+
self.widgets_below.remove_child(below)
|
|
758
|
+
self._tui.request_render()
|
|
759
|
+
|
|
760
|
+
def set_footer(self, component_or_factory: "Component | Callable[[], Component] | None") -> None:
|
|
761
|
+
"""
|
|
762
|
+
Replace the footer contents with a custom component, or clear it.
|
|
763
|
+
|
|
764
|
+
Accepts a ``Component`` instance, a zero-argument factory, or ``None``
|
|
765
|
+
to clear the footer entirely::
|
|
766
|
+
|
|
767
|
+
layout.set_footer(MyFooter()) # component instance
|
|
768
|
+
layout.set_footer(lambda: MyFooter()) # factory
|
|
769
|
+
layout.set_footer(None) # clear footer
|
|
770
|
+
"""
|
|
771
|
+
if component_or_factory is None:
|
|
772
|
+
self.footer.clear()
|
|
773
|
+
self._tui.request_render()
|
|
774
|
+
return
|
|
775
|
+
elif callable(component_or_factory) and not isinstance(component_or_factory, Component):
|
|
776
|
+
replacement = component_or_factory()
|
|
777
|
+
else:
|
|
778
|
+
replacement = component_or_factory # type: ignore[assignment]
|
|
779
|
+
|
|
780
|
+
# Replace the entire footer Container child (the Row) with the custom component
|
|
781
|
+
self.footer.clear()
|
|
782
|
+
self.footer.add_child(replacement)
|
|
783
|
+
self._tui.request_render()
|
|
784
|
+
|
|
785
|
+
def set_custom_footer(self, component: "Component | None") -> None:
|
|
786
|
+
"""Backwards-compatible alias for set_footer()."""
|
|
787
|
+
self.set_footer(component)
|
|
788
|
+
|
|
789
|
+
def register_autocomplete_provider(self, registration: "AutocompleteRegistration") -> None:
|
|
790
|
+
"""Wire an extension autocomplete provider into the layout."""
|
|
791
|
+
self._autocomplete.register_provider(registration)
|
|
792
|
+
|
|
793
|
+
def set_header(self, component_or_factory: "Component | Callable[[], Component] | None") -> None:
|
|
794
|
+
"""
|
|
795
|
+
Set the header component rendered above the message list.
|
|
796
|
+
|
|
797
|
+
Replaces whatever is currently in the header Container with a single
|
|
798
|
+
component. For more control (multiple items, spacers…) access
|
|
799
|
+
``layout.header`` directly::
|
|
800
|
+
|
|
801
|
+
layout.header.add_child(Banner())
|
|
802
|
+
layout.header.add_child(Spacer(1))
|
|
803
|
+
|
|
804
|
+
Pass ``None`` to clear the header entirely.
|
|
805
|
+
"""
|
|
806
|
+
self.header.clear()
|
|
807
|
+
if component_or_factory is None:
|
|
808
|
+
pass
|
|
809
|
+
elif callable(component_or_factory) and not isinstance(component_or_factory, Component):
|
|
810
|
+
self.header.add_child(component_or_factory())
|
|
811
|
+
else:
|
|
812
|
+
self.header.add_child(component_or_factory) # type: ignore[arg-type]
|
|
813
|
+
self._tui.request_render()
|
|
814
|
+
|
|
815
|
+
def set_status(self, key: str, text: "str | None") -> None:
|
|
816
|
+
"""
|
|
817
|
+
Set or clear a keyed status line shown above the editor.
|
|
818
|
+
|
|
819
|
+
Status lines are rendered as dim text in the zone between the chat
|
|
820
|
+
area and the editor — a dedicated slot separate from the footer.
|
|
821
|
+
They persist until explicitly cleared::
|
|
822
|
+
|
|
823
|
+
layout.set_status("git", "main (3 commits ahead)")
|
|
824
|
+
layout.set_status("git", None) # clear
|
|
825
|
+
"""
|
|
826
|
+
if text is None:
|
|
827
|
+
self._status_map.pop(key, None)
|
|
828
|
+
else:
|
|
829
|
+
self._status_map[key] = text
|
|
830
|
+
self._tui.request_render()
|
|
831
|
+
|
|
832
|
+
def set_title(self, title: str) -> None:
|
|
833
|
+
"""Set the terminal window title bar text."""
|
|
834
|
+
self._tui.terminal.set_title(title)
|
|
835
|
+
|
|
836
|
+
async def custom(
|
|
837
|
+
self,
|
|
838
|
+
factory: "Callable[[TUI, Callable[[Any], None]], Component]",
|
|
839
|
+
options: "CustomOptions | None" = None,
|
|
840
|
+
) -> "Any":
|
|
841
|
+
"""
|
|
842
|
+
Show a custom component with keyboard focus.
|
|
843
|
+
|
|
844
|
+
Show a custom component with keyboard focus.
|
|
845
|
+
|
|
846
|
+
Without ``options.overlay=True`` (default) the TUI root is swapped
|
|
847
|
+
to the custom component for a full-screen takeover; when ``done(result)``
|
|
848
|
+
is called the layout is restored and the awaited value is returned::
|
|
849
|
+
|
|
850
|
+
result = await layout.custom(
|
|
851
|
+
lambda tui, done: MyScreen(on_close=done)
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
With ``options.overlay=True`` the component is shown as a floating
|
|
855
|
+
overlay on top of the existing layout::
|
|
856
|
+
|
|
857
|
+
result = await layout.custom(
|
|
858
|
+
lambda tui, done: MyDialog(on_close=done),
|
|
859
|
+
CustomOptions(overlay=True, overlay_options=OverlayOptions(width="60%")),
|
|
860
|
+
)
|
|
861
|
+
"""
|
|
862
|
+
import asyncio
|
|
863
|
+
from tau.tui.overlay import CustomOptions as _CustomOptions
|
|
864
|
+
|
|
865
|
+
opts = options or _CustomOptions()
|
|
866
|
+
loop = asyncio.get_event_loop()
|
|
867
|
+
future: asyncio.Future = loop.create_future()
|
|
868
|
+
|
|
869
|
+
def _done(result: Any = None) -> None:
|
|
870
|
+
if not future.done():
|
|
871
|
+
future.set_result(result)
|
|
872
|
+
|
|
873
|
+
component = factory(self._tui, _done)
|
|
874
|
+
|
|
875
|
+
if opts.overlay:
|
|
876
|
+
handle = self._tui.show_overlay(component, opts.overlay_options)
|
|
877
|
+
if opts.on_handle:
|
|
878
|
+
opts.on_handle(handle)
|
|
879
|
+
try:
|
|
880
|
+
return await future
|
|
881
|
+
finally:
|
|
882
|
+
handle.close()
|
|
883
|
+
else:
|
|
884
|
+
# Full-screen takeover: detach all layout zones and install component
|
|
885
|
+
self.detach(self._tui)
|
|
886
|
+
self._tui.add_child(component)
|
|
887
|
+
self._tui.set_focus(component)
|
|
888
|
+
try:
|
|
889
|
+
return await future
|
|
890
|
+
finally:
|
|
891
|
+
self.detach(self._tui)
|
|
892
|
+
self.attach(self._tui)
|
|
893
|
+
self._tui.set_focus(self)
|
|
894
|
+
|
|
895
|
+
def get_editor_text(self) -> str:
|
|
896
|
+
"""Get the current text in the input editor."""
|
|
897
|
+
return self.input.text
|
|
898
|
+
|
|
899
|
+
def set_editor_text(self, text: str) -> None:
|
|
900
|
+
"""Replace the input editor text."""
|
|
901
|
+
self.input.set_text(text)
|
|
902
|
+
self._tui.request_render()
|
|
903
|
+
|
|
904
|
+
def paste_to_editor(self, text: str) -> None:
|
|
905
|
+
"""Insert text at the cursor position in the editor."""
|
|
906
|
+
self.input.insert_at_cursor(text)
|
|
907
|
+
self._tui.request_render()
|
|
908
|
+
|
|
909
|
+
def set_custom_input(self, factory: Callable[[Any, Any], Any] | None) -> None:
|
|
910
|
+
"""Replace the input widget with a custom implementation."""
|
|
911
|
+
from tau.tui.keybindings import get_keybindings
|
|
912
|
+
self._custom_input_factory = factory
|
|
913
|
+
if factory is None:
|
|
914
|
+
from tau.tui.components.text_input import TextInput
|
|
915
|
+
new_input: Any = TextInput(
|
|
916
|
+
prefix=self._theme.input.prefix,
|
|
917
|
+
placeholder=self._theme.input.placeholder,
|
|
918
|
+
)
|
|
919
|
+
else:
|
|
920
|
+
new_input = factory(self._theme.input, get_keybindings())
|
|
921
|
+
self.input = new_input
|
|
922
|
+
if self._stored_submit_cb is not None:
|
|
923
|
+
self.input._on_submit = self._stored_submit_cb
|
|
924
|
+
if self._stored_followup_cb is not None:
|
|
925
|
+
self.input._on_followup = self._stored_followup_cb
|
|
926
|
+
if self._stored_dequeue_cb is not None:
|
|
927
|
+
self.input._on_dequeue = self._stored_dequeue_cb
|
|
928
|
+
self._tui.request_render()
|
|
929
|
+
|
|
930
|
+
def set_pending_queue(
|
|
931
|
+
self,
|
|
932
|
+
steering: list[str],
|
|
933
|
+
followup: list[str],
|
|
934
|
+
dequeue_hint: str = "Alt+↑ to edit queued",
|
|
935
|
+
) -> None:
|
|
936
|
+
"""Rebuild the pending-messages display between spinner and input."""
|
|
937
|
+
from tau.tui.ansi import DIM, RESET
|
|
938
|
+
lines: list[str] = []
|
|
939
|
+
for msg in steering:
|
|
940
|
+
preview = msg.replace("\n", " ")[:80]
|
|
941
|
+
lines.append(f" {DIM}Steering:{RESET} {DIM}{preview}{RESET}")
|
|
942
|
+
for msg in followup:
|
|
943
|
+
preview = msg.replace("\n", " ")[:80]
|
|
944
|
+
lines.append(f" {DIM}Follow-up:{RESET} {DIM}{preview}{RESET}")
|
|
945
|
+
if lines:
|
|
946
|
+
lines.append(f" {DIM}↳ {dequeue_hint}{RESET}")
|
|
947
|
+
self._pending_lines.lines = lines
|
|
948
|
+
|
|
949
|
+
def restore_queued_to_editor(self, messages: list[str]) -> None:
|
|
950
|
+
"""Put all queued message text back into the editor (joined by blank lines)."""
|
|
951
|
+
combined = "\n\n".join(messages)
|
|
952
|
+
current = self.input.text.strip()
|
|
953
|
+
merged = "\n\n".join(filter(None, [combined, current]))
|
|
954
|
+
self.input.set_text(merged)
|
|
955
|
+
|
|
956
|
+
# -------------------------------------------------------------------------
|
|
957
|
+
# Theme
|
|
958
|
+
# -------------------------------------------------------------------------
|
|
959
|
+
|
|
960
|
+
def set_theme(self, theme: LayoutTheme) -> None:
|
|
961
|
+
"""Swap the active theme and propagate it to every child component."""
|
|
962
|
+
self._theme = theme
|
|
963
|
+
self.messages.set_theme(theme.message)
|
|
964
|
+
self.messages.set_user_prefix(theme.input.prefix)
|
|
965
|
+
self.spinner.set_theme(theme.spinner)
|
|
966
|
+
self.input._prefix = theme.input.prefix
|
|
967
|
+
self.input._placeholder = theme.input.placeholder
|
|
968
|
+
self._tui.request_render()
|
|
969
|
+
|
|
970
|
+
# -------------------------------------------------------------------------
|
|
971
|
+
# Inline pickers — rendered in the content stream, no floating windows
|
|
972
|
+
# -------------------------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
def _make_select_list(
|
|
975
|
+
self, items: list[SelectItem], current_label: str | None = None
|
|
976
|
+
) -> SelectList:
|
|
977
|
+
"""Build a SelectList with theme/visibility settings and optional initial selection."""
|
|
978
|
+
selector = SelectList(items, max_visible=self._picker_max_visible, theme=self._theme.select_list)
|
|
979
|
+
if current_label is not None:
|
|
980
|
+
labels = [item.label for item in items]
|
|
981
|
+
if current_label in labels:
|
|
982
|
+
selector._selected = labels.index(current_label)
|
|
983
|
+
return selector
|
|
984
|
+
|
|
985
|
+
def open_model_selector(
|
|
986
|
+
self,
|
|
987
|
+
models: list,
|
|
988
|
+
current_key: str,
|
|
989
|
+
on_commit: Callable[[tuple[str, str]], None],
|
|
990
|
+
on_cancel: Callable[[], None],
|
|
991
|
+
) -> None:
|
|
992
|
+
"""Open the model selector modal."""
|
|
993
|
+
from tau.tui.components.model_palette import ModelSelectorModal
|
|
994
|
+
modal = ModelSelectorModal(models, current_key)
|
|
995
|
+
self._active_selector = InlineSelector(
|
|
996
|
+
kind="model", selector=modal,
|
|
997
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
998
|
+
)
|
|
999
|
+
self._tui.request_render()
|
|
1000
|
+
|
|
1001
|
+
@property
|
|
1002
|
+
def theme_selector_active(self) -> bool:
|
|
1003
|
+
return self._active_selector is not None and self._active_selector.kind == "theme"
|
|
1004
|
+
|
|
1005
|
+
def open_theme_selector(
|
|
1006
|
+
self,
|
|
1007
|
+
names: list[str],
|
|
1008
|
+
current: str,
|
|
1009
|
+
on_preview: Callable[[str], None],
|
|
1010
|
+
on_commit: Callable[[str], None],
|
|
1011
|
+
on_cancel: Callable[[], None],
|
|
1012
|
+
) -> None:
|
|
1013
|
+
"""Open a theme selector with live preview support."""
|
|
1014
|
+
from tau.tui.components.modal import ListModal
|
|
1015
|
+
modal = ListModal(names, current, "Theme", "Select color theme", on_preview=on_preview)
|
|
1016
|
+
self._active_selector = InlineSelector(
|
|
1017
|
+
kind="theme", selector=modal,
|
|
1018
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
1019
|
+
)
|
|
1020
|
+
self._tui.request_render()
|
|
1021
|
+
|
|
1022
|
+
def open_effort_selector(
|
|
1023
|
+
self,
|
|
1024
|
+
levels: list[str],
|
|
1025
|
+
current: str,
|
|
1026
|
+
on_commit: Callable[[str], None],
|
|
1027
|
+
on_cancel: Callable[[], None],
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""Open an effort/thinking level selector modal."""
|
|
1030
|
+
from tau.tui.components.modal import ListModal
|
|
1031
|
+
modal = ListModal(levels, current, "Thinking Effort", "Select effort level")
|
|
1032
|
+
self._active_selector = InlineSelector(
|
|
1033
|
+
kind="effort", selector=modal,
|
|
1034
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
1035
|
+
)
|
|
1036
|
+
self._tui.request_render()
|
|
1037
|
+
|
|
1038
|
+
def open_settings_selector(
|
|
1039
|
+
self,
|
|
1040
|
+
modal: object,
|
|
1041
|
+
on_cancel: Callable[[], None],
|
|
1042
|
+
) -> None:
|
|
1043
|
+
"""Open the interactive settings modal."""
|
|
1044
|
+
self._active_selector = InlineSelector(
|
|
1045
|
+
kind="settings", selector=modal,
|
|
1046
|
+
on_commit=lambda _: None, on_cancel=on_cancel,
|
|
1047
|
+
)
|
|
1048
|
+
self._tui.request_render()
|
|
1049
|
+
|
|
1050
|
+
def open_resume_selector(
|
|
1051
|
+
self,
|
|
1052
|
+
sessions: list,
|
|
1053
|
+
on_commit: Callable[[Path], None],
|
|
1054
|
+
on_cancel: Callable[[], None],
|
|
1055
|
+
all_sessions_loader: Callable[[], list] | None = None,
|
|
1056
|
+
current_session_path: Path | None = None,
|
|
1057
|
+
) -> None:
|
|
1058
|
+
"""Open the session resume selector with search, scope toggle, and delete."""
|
|
1059
|
+
from tau.tui.components.resume_modal import ResumeModal
|
|
1060
|
+
modal = ResumeModal(
|
|
1061
|
+
current_sessions=sessions,
|
|
1062
|
+
all_sessions_loader=all_sessions_loader or (lambda: []),
|
|
1063
|
+
current_session_path=current_session_path,
|
|
1064
|
+
max_visible=self._picker_max_visible,
|
|
1065
|
+
)
|
|
1066
|
+
self._active_selector = InlineSelector(
|
|
1067
|
+
kind="resume", selector=modal,
|
|
1068
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
1069
|
+
)
|
|
1070
|
+
self._tui.request_render()
|
|
1071
|
+
|
|
1072
|
+
def open_tree_selector(
|
|
1073
|
+
self,
|
|
1074
|
+
items: list[SelectItem[str]],
|
|
1075
|
+
on_commit: Callable[[str], None],
|
|
1076
|
+
on_cancel: Callable[[], None],
|
|
1077
|
+
) -> None:
|
|
1078
|
+
"""Open a tree/list selector modal."""
|
|
1079
|
+
self._active_selector = InlineSelector(
|
|
1080
|
+
kind="tree", selector=self._make_select_list(items),
|
|
1081
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
1082
|
+
)
|
|
1083
|
+
self._tui.request_render()
|
|
1084
|
+
|
|
1085
|
+
def open_branch_tree_selector(
|
|
1086
|
+
self,
|
|
1087
|
+
rows: list[TreeRow[str]],
|
|
1088
|
+
on_commit: Callable[[str], None],
|
|
1089
|
+
on_cancel: Callable[[], None],
|
|
1090
|
+
) -> None:
|
|
1091
|
+
"""Open the session branch-history tree selector (role-colored, tree-connector rows)."""
|
|
1092
|
+
m = self._theme.message
|
|
1093
|
+
|
|
1094
|
+
def role_color(role: str, text: str) -> Callable[[str], str]:
|
|
1095
|
+
if role == "user":
|
|
1096
|
+
return m.you_label
|
|
1097
|
+
if role == "assistant":
|
|
1098
|
+
return m.assistant_label
|
|
1099
|
+
if role == "branch_summary":
|
|
1100
|
+
return m.tool_arrow
|
|
1101
|
+
if role == "tool":
|
|
1102
|
+
return m.tool_result_err if text.startswith("[error]") else m.tool_result_ok
|
|
1103
|
+
return m.dim
|
|
1104
|
+
|
|
1105
|
+
# Size the tree to half the terminal height (min 5), not a fixed picker size.
|
|
1106
|
+
tree_max_visible = max(5, self._tui.terminal.height // 2)
|
|
1107
|
+
selector = TreeSelectList(
|
|
1108
|
+
rows,
|
|
1109
|
+
role_color=role_color,
|
|
1110
|
+
accent_color=m.you_label,
|
|
1111
|
+
dim_color=m.dim,
|
|
1112
|
+
max_visible=tree_max_visible,
|
|
1113
|
+
selected_bg=self._theme.select_list.selected_bg,
|
|
1114
|
+
)
|
|
1115
|
+
self._active_selector = InlineSelector(
|
|
1116
|
+
kind="tree", selector=selector,
|
|
1117
|
+
on_commit=on_commit, on_cancel=on_cancel,
|
|
1118
|
+
searchable=True,
|
|
1119
|
+
)
|
|
1120
|
+
self._tui.request_render()
|
|
1121
|
+
|
|
1122
|
+
# ── Text prompt ───────────────────────────────────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
def open_prompt(
|
|
1125
|
+
self,
|
|
1126
|
+
label: str,
|
|
1127
|
+
on_commit: Callable[[str], None],
|
|
1128
|
+
on_cancel: Callable[[], None],
|
|
1129
|
+
*,
|
|
1130
|
+
secret: bool = False,
|
|
1131
|
+
) -> None:
|
|
1132
|
+
"""Open a text input prompt modal."""
|
|
1133
|
+
self._prompt.open(label, on_commit, on_cancel, secret=secret)
|
|
1134
|
+
self._tui.request_render()
|
|
1135
|
+
|
|
1136
|
+
# ── Multi-line editor overlay ─────────────────────────────────────────────
|
|
1137
|
+
|
|
1138
|
+
def open_editor(
|
|
1139
|
+
self,
|
|
1140
|
+
title: str,
|
|
1141
|
+
prefill: str,
|
|
1142
|
+
on_commit: Callable[[str], None],
|
|
1143
|
+
on_cancel: Callable[[], None],
|
|
1144
|
+
) -> None:
|
|
1145
|
+
"""Open a floating multi-line text editor overlay."""
|
|
1146
|
+
from tau.tui.components.prompt_overlay import EditorOverlay
|
|
1147
|
+
from tau.tui.overlay import OverlayOptions
|
|
1148
|
+
|
|
1149
|
+
handle_ref: list[OverlayHandle] = []
|
|
1150
|
+
|
|
1151
|
+
def _commit(value: str) -> None:
|
|
1152
|
+
if handle_ref:
|
|
1153
|
+
handle_ref[0].close()
|
|
1154
|
+
on_commit(value)
|
|
1155
|
+
|
|
1156
|
+
def _cancel() -> None:
|
|
1157
|
+
if handle_ref:
|
|
1158
|
+
handle_ref[0].close()
|
|
1159
|
+
on_cancel()
|
|
1160
|
+
|
|
1161
|
+
editor = EditorOverlay(title, prefill=prefill, on_commit=_commit, on_cancel=_cancel)
|
|
1162
|
+
opts = OverlayOptions(width="80%", max_height="70%", anchor="center")
|
|
1163
|
+
handle = self._tui.show_overlay(editor, opts)
|
|
1164
|
+
handle_ref.append(handle)
|
|
1165
|
+
|
|
1166
|
+
# ── Settings panel ────────────────────────────────────────────────────────
|
|
1167
|
+
|
|
1168
|
+
def open_settings_panel(self, lines: list[str]) -> None:
|
|
1169
|
+
"""Show a read-only settings panel inline. Esc closes it."""
|
|
1170
|
+
self._settings_panel = lines
|
|
1171
|
+
self._tui.request_render()
|
|
1172
|
+
|
|
1173
|
+
def close_settings_panel(self) -> None:
|
|
1174
|
+
"""Close the settings panel."""
|
|
1175
|
+
self._settings_panel = None
|
|
1176
|
+
self._tui.request_render()
|
|
1177
|
+
|
|
1178
|
+
# ── OAuth status (inline) ─────────────────────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
def open_oauth_status(self, lines: list[str]) -> None:
|
|
1181
|
+
"""Show OAuth progress inline in the content stream."""
|
|
1182
|
+
self._oauth_status_lines = list(lines)
|
|
1183
|
+
self._tui.request_render()
|
|
1184
|
+
|
|
1185
|
+
def update_oauth_status(self, line: str) -> None:
|
|
1186
|
+
"""Append a progress line to the OAuth status display."""
|
|
1187
|
+
if self._oauth_status_lines is not None:
|
|
1188
|
+
self._oauth_status_lines.append(line)
|
|
1189
|
+
self._tui.request_render()
|
|
1190
|
+
|
|
1191
|
+
def close_oauth_status(self) -> None:
|
|
1192
|
+
"""Clear the OAuth status display."""
|
|
1193
|
+
self._oauth_status_lines = None
|
|
1194
|
+
self._tui.request_render()
|