@scemoon/cdh 1.0.0 → 1.0.3
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.
- package/cdh/__pycache__/__main__.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/cli.py +473 -87
- package/cdh/scaffold.py +386 -121
- package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
- package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/types.py +123 -79
- package/onecode/agent/cdh_loader.py +26 -13
- package/onecode/agent/engine.py +426 -235
- package/onecode/agent/onecode_agent_acp.py +200 -73
- package/onecode/agent/session.py +8 -13
- package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/agent_tools.py +4 -4
- package/onecode/agent/tools/todo_tools.py +188 -0
- package/onecode/cli.py +3 -3
- package/onecode/commands.py +439 -194
- package/onecode/config.py +3 -3
- package/onecode/config_screen.py +4 -2
- package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/errors.py +11 -1
- package/onecode/models/messages.py +35 -16
- package/onecode/models/provider.py +243 -12
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +4 -2
- package/onecode/models/providers/deepseek_provider.py +2 -0
- package/onecode/models/providers/glm_provider.py +2 -1
- package/onecode/models/providers/minimax_provider.py +2 -1
- package/onecode/models/providers/minimaxi_provider.py +76 -25
- package/onecode/models/providers/ollama_provider.py +2 -1
- package/onecode/models/providers/openai_provider.py +4 -0
- package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/models.cpython-314.pyc +0 -0
- package/onecode/tasks/manager.py +1 -1
- package/package.json +1 -1
- package/pyproject.toml +10 -0
- package/run.js +24 -7
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/cli.cpython-314.pyc +0 -0
- package/tui/__pycache__/db.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
- package/tui/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/acp/agent.py +60 -11
- package/tui/acp/api.py +6 -0
- package/tui/acp/messages.py +11 -0
- package/tui/acp/prompt.py +1 -1
- package/tui/acp/protocol.py +31 -0
- package/tui/app.py +60 -8
- package/tui/db.py +23 -0
- package/tui/directory_watcher.py +35 -1
- package/tui/messages.py +4 -1
- package/tui/paths.py +3 -3
- package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
- package/tui/screens/component_picker.py +148 -0
- package/tui/screens/main.py +47 -95
- package/tui/screens/projects.tcss +10 -0
- package/tui/screens/projects_app.py +33 -5
- package/tui/screens/projects_screen.py +171 -9
- package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/ask_user.py +1 -1
- package/tui/widgets/conversation.py +99 -42
- package/tui/widgets/modified_files.py +155 -23
- package/tui/widgets/plan.py +22 -0
- package/tui/widgets/project_directory_tree.py +22 -10
- package/tui/widgets/project_grid_select.py +23 -1
- package/tui/widgets/question.py +1 -1
- package/tui/widgets/subagent.py +202 -36
- package/tui/widgets/subagent_screen.py +186 -0
- package/tui/widgets/tool_call.py +5 -8
- package/onecode/agent/tools/task_tools.py +0 -249
|
@@ -3,12 +3,37 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
import subprocess
|
|
5
5
|
|
|
6
|
+
from textual import work
|
|
6
7
|
from textual.app import ComposeResult
|
|
7
8
|
from textual.reactive import reactive
|
|
9
|
+
from textual.timer import Timer
|
|
8
10
|
from textual.widgets import Static
|
|
9
11
|
from textual import containers
|
|
10
12
|
|
|
11
13
|
|
|
14
|
+
GIT_STATUS_TIMEOUT = 5.0
|
|
15
|
+
GIT_STATUS_DEBOUNCE = 0.2
|
|
16
|
+
|
|
17
|
+
NOT_A_REPO_TEXT = "Not a git repository"
|
|
18
|
+
NO_CHANGES_TEXT = "No modified files"
|
|
19
|
+
LOADING_TEXT = "Checking…"
|
|
20
|
+
|
|
21
|
+
IGNORED_DIRS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
".venv",
|
|
24
|
+
".git",
|
|
25
|
+
".pytest_cache",
|
|
26
|
+
"__pycache__",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
"node_modules",
|
|
29
|
+
".uv-cache",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
".idea",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
12
37
|
class ModifiedFiles(containers.Vertical):
|
|
13
38
|
DEFAULT_CSS = """
|
|
14
39
|
ModifiedFiles {
|
|
@@ -40,11 +65,21 @@ class ModifiedFiles(containers.Vertical):
|
|
|
40
65
|
color: $text-secondary;
|
|
41
66
|
text-style: italic;
|
|
42
67
|
}
|
|
68
|
+
|
|
69
|
+
.not-a-repo {
|
|
70
|
+
color: $text-secondary;
|
|
71
|
+
text-style: italic;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.-loading {
|
|
75
|
+
color: $text-secondary;
|
|
76
|
+
text-style: italic;
|
|
77
|
+
}
|
|
43
78
|
}
|
|
44
79
|
"""
|
|
45
80
|
|
|
46
81
|
path: reactive[Path | None] = reactive(None)
|
|
47
|
-
files: reactive[list[str]] = reactive(
|
|
82
|
+
files: reactive[list[str]] = reactive(list, recompose=False)
|
|
48
83
|
|
|
49
84
|
def __init__(
|
|
50
85
|
self,
|
|
@@ -54,42 +89,139 @@ class ModifiedFiles(containers.Vertical):
|
|
|
54
89
|
classes: str | None = None,
|
|
55
90
|
) -> None:
|
|
56
91
|
super().__init__(name=name, id=id, classes=classes)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
self._debounce_timer: Timer | None = None
|
|
93
|
+
self._status_kind: str = "no-changes"
|
|
94
|
+
self._status_text: str = NO_CHANGES_TEXT
|
|
95
|
+
self._file_widgets: list[Static] = []
|
|
96
|
+
self._initial_path: Path | None = path
|
|
97
|
+
|
|
98
|
+
def on_mount(self) -> None:
|
|
99
|
+
initial = self._initial_path
|
|
100
|
+
self._initial_path = None
|
|
101
|
+
if initial is not None:
|
|
102
|
+
self.path = initial
|
|
60
103
|
|
|
61
104
|
def watch_path(self, path: Path | None) -> None:
|
|
62
|
-
"""
|
|
105
|
+
"""Schedule a (debounced, async) git status when path changes."""
|
|
106
|
+
self._cancel_debounce()
|
|
63
107
|
if path is None:
|
|
64
|
-
self.
|
|
108
|
+
self._show_status("no-changes", NO_CHANGES_TEXT)
|
|
109
|
+
self._clear_file_widgets()
|
|
65
110
|
return
|
|
111
|
+
self._show_status("-loading", LOADING_TEXT)
|
|
112
|
+
self._schedule_run(path)
|
|
113
|
+
|
|
114
|
+
def refresh_files(self) -> None:
|
|
115
|
+
"""Re-run git status manually (e.g. after file system changes)."""
|
|
116
|
+
if self.path is not None:
|
|
117
|
+
self._cancel_debounce()
|
|
118
|
+
self._show_status("-loading", LOADING_TEXT)
|
|
119
|
+
self._run_git_status(self.path)
|
|
120
|
+
|
|
121
|
+
def _schedule_run(self, path: Path) -> None:
|
|
122
|
+
timer = self.set_timer(
|
|
123
|
+
GIT_STATUS_DEBOUNCE,
|
|
124
|
+
lambda p=path: self._run_git_status(p),
|
|
125
|
+
)
|
|
126
|
+
self._debounce_timer = timer
|
|
127
|
+
|
|
128
|
+
def _cancel_debounce(self) -> None:
|
|
129
|
+
timer = self._debounce_timer
|
|
130
|
+
if timer is not None:
|
|
131
|
+
try:
|
|
132
|
+
timer.stop()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
self._debounce_timer = None
|
|
136
|
+
|
|
137
|
+
@work(exclusive=True, group="modified-files-git-status", thread=True)
|
|
138
|
+
def _run_git_status(self, path: Path) -> None:
|
|
139
|
+
"""Worker entry: do the slow ``git status`` off the UI thread,
|
|
140
|
+
then marshal results back via ``app.call_from_thread``."""
|
|
141
|
+
app = self.app
|
|
142
|
+
outcome = self._do_git_status(path)
|
|
143
|
+
if outcome is None:
|
|
144
|
+
return
|
|
145
|
+
kind, text, lines = outcome
|
|
146
|
+
if lines is None:
|
|
147
|
+
app.call_from_thread(self._show_status, kind, text)
|
|
148
|
+
app.call_from_thread(self._clear_file_widgets)
|
|
149
|
+
else:
|
|
150
|
+
app.call_from_thread(self._render_lines, lines)
|
|
151
|
+
|
|
152
|
+
def _do_git_status(
|
|
153
|
+
self, path: Path
|
|
154
|
+
) -> tuple[str, str, list[str] | None] | None:
|
|
155
|
+
"""Pure helper for ``git status``. Returns
|
|
156
|
+
``(kind, text, lines_or_None)`` for the UI to render, or ``None``
|
|
157
|
+
if the call should silently no-op (e.g. ignored path).
|
|
158
|
+
"""
|
|
159
|
+
if any(part in IGNORED_DIRS for part in path.parts):
|
|
160
|
+
return ("not-a-repo", NOT_A_REPO_TEXT, None)
|
|
66
161
|
try:
|
|
67
162
|
result = subprocess.run(
|
|
68
163
|
["git", "-C", str(path), "status", "--porcelain"],
|
|
69
164
|
capture_output=True,
|
|
70
165
|
text=True,
|
|
71
|
-
timeout=
|
|
166
|
+
timeout=GIT_STATUS_TIMEOUT,
|
|
72
167
|
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
except
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
168
|
+
except subprocess.TimeoutExpired:
|
|
169
|
+
return ("no-changes", NO_CHANGES_TEXT, None)
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
return ("not-a-repo", NOT_A_REPO_TEXT, None)
|
|
172
|
+
except Exception:
|
|
173
|
+
return ("no-changes", NO_CHANGES_TEXT, None)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
return ("not-a-repo", NOT_A_REPO_TEXT, None)
|
|
176
|
+
lines = [
|
|
177
|
+
line for line in result.stdout.splitlines() if line.strip()
|
|
178
|
+
]
|
|
179
|
+
if not lines:
|
|
180
|
+
return ("no-changes", NO_CHANGES_TEXT, [])
|
|
181
|
+
return ("file-modified", "", lines)
|
|
182
|
+
|
|
183
|
+
def _render_lines(self, lines: list[str]) -> None:
|
|
184
|
+
self.files = list(lines)
|
|
185
|
+
if not lines:
|
|
186
|
+
self._show_status("no-changes", NO_CHANGES_TEXT)
|
|
187
|
+
self._clear_file_widgets()
|
|
87
188
|
return
|
|
88
|
-
|
|
189
|
+
self._show_status("file-modified", "")
|
|
190
|
+
self._clear_file_widgets()
|
|
191
|
+
try:
|
|
192
|
+
header = self.query_one("#mf-status", Static)
|
|
193
|
+
except Exception:
|
|
194
|
+
return
|
|
195
|
+
for line in lines:
|
|
89
196
|
status = line[:2]
|
|
90
197
|
filepath = line[3:]
|
|
91
198
|
cls = self._class_for_status(status)
|
|
92
|
-
|
|
199
|
+
widget = Static(f" {filepath}", classes=cls)
|
|
200
|
+
self._file_widgets.append(widget)
|
|
201
|
+
self.mount(widget, after=header)
|
|
202
|
+
|
|
203
|
+
def _show_status(self, kind: str, text: str) -> None:
|
|
204
|
+
self._status_kind = kind
|
|
205
|
+
self._status_text = text
|
|
206
|
+
try:
|
|
207
|
+
header = self.query_one("#mf-status", Static)
|
|
208
|
+
except Exception:
|
|
209
|
+
return
|
|
210
|
+
header.set_classes(kind)
|
|
211
|
+
header.update(text)
|
|
212
|
+
if kind != "file-modified":
|
|
213
|
+
self._clear_file_widgets()
|
|
214
|
+
|
|
215
|
+
def _clear_file_widgets(self) -> None:
|
|
216
|
+
for w in self._file_widgets:
|
|
217
|
+
try:
|
|
218
|
+
w.remove()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
self._file_widgets = []
|
|
222
|
+
|
|
223
|
+
def compose(self) -> ComposeResult:
|
|
224
|
+
yield Static(self._status_text, classes=self._status_kind, id="mf-status")
|
|
93
225
|
|
|
94
226
|
@staticmethod
|
|
95
227
|
def _class_for_status(status: str) -> str:
|
package/tui/widgets/plan.py
CHANGED
|
@@ -186,6 +186,28 @@ class Plan(containers.Grid):
|
|
|
186
186
|
return Content()
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
def entries_from_dicts(raw_entries):
|
|
190
|
+
"""Convert raw plan entries (dicts) into ``Plan.Entry`` dataclass instances.
|
|
191
|
+
|
|
192
|
+
Shared by ``MainScreen`` (sidebar) and ``Conversation`` (inline) so both
|
|
193
|
+
Plan widgets render the same data the same way. Filters entries with
|
|
194
|
+
empty content so the Plan widget never renders a blank row.
|
|
195
|
+
"""
|
|
196
|
+
result = []
|
|
197
|
+
for entry in raw_entries:
|
|
198
|
+
content = (entry.get("content") or "").strip()
|
|
199
|
+
if not content:
|
|
200
|
+
continue
|
|
201
|
+
result.append(
|
|
202
|
+
Plan.Entry(
|
|
203
|
+
Content(content),
|
|
204
|
+
entry.get("priority", "medium"),
|
|
205
|
+
entry.get("status", "pending"),
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
189
211
|
if __name__ == "__main__":
|
|
190
212
|
from textual.app import App
|
|
191
213
|
|
|
@@ -42,26 +42,37 @@ This shows the files in your project directory.
|
|
|
42
42
|
disabled: bool = False,
|
|
43
43
|
) -> None:
|
|
44
44
|
self.path_filter: PathFilter | None = None
|
|
45
|
+
self._reload_lock = asyncio.Lock()
|
|
45
46
|
path = Path(path).resolve() if isinstance(path, str) else path.resolve()
|
|
46
47
|
super().__init__(path, name=name, id=id, classes=classes, disabled=disabled)
|
|
47
48
|
|
|
49
|
+
async def _update_path_filter(self) -> None:
|
|
50
|
+
if self.path is None:
|
|
51
|
+
self.path_filter = None
|
|
52
|
+
return
|
|
53
|
+
path = Path(self.path) if isinstance(self.path, str) else self.path
|
|
54
|
+
path = await asyncio.to_thread(path.resolve)
|
|
55
|
+
self.path_filter = await asyncio.to_thread(PathFilter.from_git_root, path)
|
|
56
|
+
|
|
48
57
|
async def watch_path(self) -> None:
|
|
49
58
|
"""Watch for changes to the `path` of the directory tree.
|
|
50
59
|
|
|
51
60
|
If the path is changed the directory tree will be repopulated using
|
|
52
61
|
the new value as the root.
|
|
53
62
|
"""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
self.
|
|
59
|
-
|
|
63
|
+
if self.path is None:
|
|
64
|
+
return
|
|
65
|
+
async with self._reload_lock:
|
|
66
|
+
has_cursor = self.cursor_node is not None
|
|
67
|
+
self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path)))
|
|
68
|
+
await self._update_path_filter()
|
|
69
|
+
await self.reload()
|
|
70
|
+
if has_cursor:
|
|
71
|
+
self.cursor_line = 0
|
|
72
|
+
self.scroll_to(0, 0, animate=False)
|
|
60
73
|
|
|
61
74
|
async def on_mount(self) -> None:
|
|
62
|
-
|
|
63
|
-
path = await asyncio.to_thread(path.resolve)
|
|
64
|
-
self.path_filter = await asyncio.to_thread(PathFilter.from_git_root, path)
|
|
75
|
+
await self._update_path_filter()
|
|
65
76
|
|
|
66
77
|
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
|
|
67
78
|
"""Filter the paths before adding them to the tree.
|
|
@@ -86,5 +97,6 @@ This shows the files in your project directory.
|
|
|
86
97
|
|
|
87
98
|
@work
|
|
88
99
|
async def action_refresh(self) -> None:
|
|
89
|
-
|
|
100
|
+
async with self._reload_lock:
|
|
101
|
+
await self.reload()
|
|
90
102
|
self.notify("Project directory has been refreshed", title="Directory Tree")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
+
import yaml
|
|
3
4
|
from textual.app import ComposeResult
|
|
4
5
|
from textual import getters
|
|
5
6
|
|
|
@@ -9,6 +10,24 @@ from tui.widgets.project_summary import ProjectSummary
|
|
|
9
10
|
from onecode.config import CLOUD_DEV_HARNESS_DIR
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _read_project_path(pf: Path) -> str:
|
|
14
|
+
"""Return the project directory path stored in ``pf``, or the
|
|
15
|
+
yaml/json file path as a fallback."""
|
|
16
|
+
try:
|
|
17
|
+
if pf.suffix == ".json":
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
data = json.loads(pf.read_text(encoding="utf-8")) or {}
|
|
21
|
+
else:
|
|
22
|
+
data = yaml.safe_load(pf.read_text(encoding="utf-8")) or {}
|
|
23
|
+
path = data.get("path")
|
|
24
|
+
if isinstance(path, str) and path:
|
|
25
|
+
return path
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
return str(pf)
|
|
29
|
+
|
|
30
|
+
|
|
12
31
|
class ProjectGridSelect(GridSelect):
|
|
13
32
|
FOCUS_ON_CLICK = True
|
|
14
33
|
app: getters.app[A2TUIApp] = getters.app(A2TUIApp)
|
|
@@ -32,4 +51,7 @@ class ProjectGridSelect(GridSelect):
|
|
|
32
51
|
projects_dir.mkdir(parents=True, exist_ok=True)
|
|
33
52
|
project_files = list(projects_dir.glob("*.yaml")) + list(projects_dir.glob("*.json"))
|
|
34
53
|
for pf in sorted(project_files):
|
|
35
|
-
yield ProjectSummary(pf.stem,
|
|
54
|
+
yield ProjectSummary(pf.stem, _read_project_path(pf), id=pf.stem)
|
|
55
|
+
|
|
56
|
+
def refresh(self, **kwargs) -> None:
|
|
57
|
+
super().refresh(**kwargs)
|
package/tui/widgets/question.py
CHANGED
|
@@ -247,7 +247,7 @@ class Question(containers.VerticalGroup, can_focus=True):
|
|
|
247
247
|
self.options = ask.options
|
|
248
248
|
self.selection = 0
|
|
249
249
|
self.selected = False
|
|
250
|
-
self.refresh(recompose=True
|
|
250
|
+
self.refresh(recompose=True)
|
|
251
251
|
|
|
252
252
|
def compose(self) -> ComposeResult:
|
|
253
253
|
|
package/tui/widgets/subagent.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from textual import containers
|
|
3
6
|
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.content import Content
|
|
4
9
|
from textual.reactive import var
|
|
5
|
-
from textual import
|
|
6
|
-
from textual.widgets import Static, Markdown
|
|
10
|
+
from textual.widgets import Static
|
|
7
11
|
|
|
8
|
-
from tui.
|
|
12
|
+
from tui.protocol import ExpandProtocol
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
class SubAgentHeader(Static):
|
|
@@ -18,70 +22,232 @@ class SubAgentHeader(Static):
|
|
|
18
22
|
"""
|
|
19
23
|
|
|
20
24
|
|
|
21
|
-
class
|
|
22
|
-
|
|
23
|
-
SubAgentContent {
|
|
24
|
-
margin: 0 1 0 2;
|
|
25
|
-
}
|
|
26
|
-
"""
|
|
25
|
+
class SubAgent(containers.VerticalGroup, can_focus=True):
|
|
26
|
+
"""Sub-agent compact view — animated header + latest line with ctrl+x hint.
|
|
27
27
|
|
|
28
|
+
During streaming: header shows a spinner + latest line updates live.
|
|
29
|
+
After completion: header shows static marker.
|
|
30
|
+
Ctrl+X to open full-screen SubAgentScreen with full structured output.
|
|
31
|
+
"""
|
|
28
32
|
|
|
29
|
-
class SubAgent(containers.VerticalGroup):
|
|
30
33
|
DEFAULT_CLASSES = "block"
|
|
34
|
+
|
|
35
|
+
HELP = """
|
|
36
|
+
## Sub-agent
|
|
37
|
+
|
|
38
|
+
- **ctrl+x** Open full-screen output
|
|
39
|
+
- **up/down** Scroll (if content overflows)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
43
|
+
Binding("ctrl+x", "toggle", "Full screen", show=False, priority=True),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
ALLOW_MAXIMIZE = True
|
|
47
|
+
|
|
31
48
|
DEFAULT_CSS = """
|
|
32
49
|
SubAgent {
|
|
33
|
-
margin: 0 0 0 1;
|
|
34
50
|
height: auto;
|
|
51
|
+
width: 1fr;
|
|
52
|
+
min-height: 2;
|
|
53
|
+
padding: 0 1;
|
|
54
|
+
border-left: thick $secondary;
|
|
55
|
+
background: $surface-darken-1;
|
|
56
|
+
|
|
35
57
|
SubAgentHeader {
|
|
36
58
|
color: $text-secondary;
|
|
59
|
+
text-style: bold;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&.-status-running {
|
|
63
|
+
border-left: thick $warning;
|
|
64
|
+
background: $boost;
|
|
65
|
+
SubAgentHeader {
|
|
66
|
+
color: $warning;
|
|
67
|
+
text-style: bold italic;
|
|
68
|
+
}
|
|
69
|
+
#subagent-latest {
|
|
70
|
+
color: $text;
|
|
71
|
+
}
|
|
37
72
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
73
|
+
|
|
74
|
+
&.-status-completed {
|
|
75
|
+
border-left: thick $success;
|
|
76
|
+
SubAgentHeader {
|
|
77
|
+
color: $success;
|
|
78
|
+
}
|
|
79
|
+
#subagent-latest {
|
|
80
|
+
color: $text-muted;
|
|
81
|
+
}
|
|
41
82
|
}
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
|
|
84
|
+
&.-status-failed {
|
|
85
|
+
border-left: thick $error;
|
|
86
|
+
SubAgentHeader {
|
|
87
|
+
color: $error;
|
|
88
|
+
}
|
|
89
|
+
#subagent-latest {
|
|
90
|
+
color: $error;
|
|
91
|
+
}
|
|
44
92
|
}
|
|
45
93
|
}
|
|
46
94
|
"""
|
|
47
95
|
|
|
48
|
-
agent_type: var[str] = var("")
|
|
49
96
|
status: var[str] = var("running")
|
|
97
|
+
latest_line: var[str] = var("")
|
|
98
|
+
spinner_frame: var[int] = var(0)
|
|
99
|
+
|
|
100
|
+
SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
50
101
|
|
|
51
102
|
def __init__(
|
|
52
103
|
self,
|
|
53
104
|
agent_type: str,
|
|
54
105
|
tool_id: str | None = None,
|
|
106
|
+
prompt: str = "",
|
|
55
107
|
) -> None:
|
|
56
108
|
super().__init__(id=tool_id)
|
|
57
109
|
self.agent_type = agent_type
|
|
58
|
-
self.
|
|
110
|
+
self.prompt = prompt
|
|
59
111
|
self._chunks: list[str] = []
|
|
112
|
+
self._thinking_chunks: list[str] = []
|
|
113
|
+
self._completed = False
|
|
114
|
+
self._status = "running"
|
|
115
|
+
self._error = ""
|
|
116
|
+
self._mounted = False
|
|
117
|
+
self._spin_timer = None
|
|
60
118
|
|
|
61
119
|
def compose(self) -> ComposeResult:
|
|
62
|
-
self.set_class(True, f"-status-{self.
|
|
63
|
-
|
|
64
|
-
yield
|
|
65
|
-
f"🧠 Sub‑agent: {self.agent_type} {label}",
|
|
66
|
-
markup=False,
|
|
67
|
-
)
|
|
68
|
-
yield SubAgentContent("")
|
|
120
|
+
self.set_class(True, f"-status-{self._status}")
|
|
121
|
+
yield SubAgentHeader(self._header_text(), id="subagent-header")
|
|
122
|
+
yield Static(self._latest_content, id="subagent-latest")
|
|
69
123
|
|
|
70
|
-
def
|
|
71
|
-
self.
|
|
124
|
+
def on_mount(self) -> None:
|
|
125
|
+
self._mounted = True
|
|
126
|
+
self.styles.height = "auto"
|
|
127
|
+
self.styles.min_height = 2
|
|
128
|
+
self._start_spinner()
|
|
129
|
+
self._refresh()
|
|
130
|
+
|
|
131
|
+
def _start_spinner(self) -> None:
|
|
132
|
+
if self._spin_timer is not None:
|
|
133
|
+
return
|
|
134
|
+
self._spin_timer = self.set_interval(0.1, self._tick_spinner)
|
|
135
|
+
|
|
136
|
+
def _stop_spinner(self) -> None:
|
|
137
|
+
if self._spin_timer is not None:
|
|
138
|
+
self._spin_timer.stop()
|
|
139
|
+
self._spin_timer = None
|
|
140
|
+
|
|
141
|
+
def _tick_spinner(self) -> None:
|
|
142
|
+
if self._status == "running":
|
|
143
|
+
self.spinner_frame = (self.spinner_frame + 1) % len(self.SPINNER_FRAMES)
|
|
144
|
+
self._refresh_header()
|
|
145
|
+
|
|
146
|
+
def watch_status(self, _old: str, _new: str) -> None:
|
|
147
|
+
if _new != "running":
|
|
148
|
+
self._stop_spinner()
|
|
149
|
+
|
|
150
|
+
def _refresh(self) -> None:
|
|
151
|
+
if not self._mounted:
|
|
152
|
+
return
|
|
72
153
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
154
|
+
self.set_class(True, f"-status-{self._status}")
|
|
155
|
+
if self._status != "running":
|
|
156
|
+
self.set_class(False, "-status-running")
|
|
157
|
+
self.query_one("#subagent-header", SubAgentHeader).update(self._header_text())
|
|
158
|
+
self.query_one("#subagent-latest", Static).update(self._latest_content)
|
|
75
159
|
except Exception:
|
|
76
160
|
pass
|
|
77
161
|
|
|
78
|
-
def
|
|
79
|
-
self.
|
|
80
|
-
|
|
81
|
-
self.set_class(False, "-status-running")
|
|
162
|
+
def _refresh_header(self) -> None:
|
|
163
|
+
if not self._mounted:
|
|
164
|
+
return
|
|
82
165
|
try:
|
|
83
|
-
self.query_one(SubAgentHeader).update(
|
|
84
|
-
f"🧠 Sub‑agent: {self.agent_type} — completed ✅"
|
|
85
|
-
)
|
|
166
|
+
self.query_one("#subagent-header", SubAgentHeader).update(self._header_text())
|
|
86
167
|
except Exception:
|
|
87
168
|
pass
|
|
169
|
+
|
|
170
|
+
def _header_text(self) -> str:
|
|
171
|
+
label = self.agent_type
|
|
172
|
+
status_tag = self._status_label()
|
|
173
|
+
if self._status == "running":
|
|
174
|
+
spin = self.SPINNER_FRAMES[self.spinner_frame]
|
|
175
|
+
return f"{spin} @ {label} [{status_tag}]"
|
|
176
|
+
elif self._status == "completed":
|
|
177
|
+
return f"✓ @ {label} [{status_tag}]"
|
|
178
|
+
elif self._status == "failed":
|
|
179
|
+
return f"✗ @ {label} [{status_tag}]"
|
|
180
|
+
return f"@ {label} [{status_tag}]"
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def _latest_content(self) -> Content:
|
|
184
|
+
text = (self.latest_line or "").strip()
|
|
185
|
+
hint = Content.from_markup(" [$text-muted]ctrl+x 展开查看")
|
|
186
|
+
if text:
|
|
187
|
+
return Content.assemble(text, hint)
|
|
188
|
+
return Content.from_markup("[$text-muted]⏳ 等待输出… ctrl+x 展开查看")
|
|
189
|
+
|
|
190
|
+
def _status_label(self) -> str:
|
|
191
|
+
if self._status == "running":
|
|
192
|
+
return "running"
|
|
193
|
+
elif self._status == "failed":
|
|
194
|
+
return f"failed{': ' + self._error[:40] if self._error else ''}"
|
|
195
|
+
return "completed"
|
|
196
|
+
|
|
197
|
+
def _update_latest_line(self) -> str:
|
|
198
|
+
MAX_LEN = 50
|
|
199
|
+
full = "".join(self._chunks)
|
|
200
|
+
lines = full.split("\n")
|
|
201
|
+
for line in reversed(lines):
|
|
202
|
+
line = line.strip()
|
|
203
|
+
if line:
|
|
204
|
+
if len(line) > MAX_LEN:
|
|
205
|
+
return line[:MAX_LEN] + "..."
|
|
206
|
+
return line
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
def append_chunk(self, text: str) -> None:
|
|
210
|
+
self._chunks.append(text)
|
|
211
|
+
self.latest_line = self._update_latest_line()
|
|
212
|
+
self._refresh_latest()
|
|
213
|
+
|
|
214
|
+
def append_thinking(self, text: str) -> None:
|
|
215
|
+
self._thinking_chunks.append(text)
|
|
216
|
+
|
|
217
|
+
def complete(self, status: str = "completed", error: str = "") -> None:
|
|
218
|
+
if self._completed:
|
|
219
|
+
return
|
|
220
|
+
self._completed = True
|
|
221
|
+
self._status = status
|
|
222
|
+
self._error = error
|
|
223
|
+
if status == "failed":
|
|
224
|
+
self.latest_line = f"Failed: {error[:60]}" if error else "Failed"
|
|
225
|
+
else:
|
|
226
|
+
self.latest_line = self._update_latest_line()
|
|
227
|
+
self._stop_spinner()
|
|
228
|
+
self._refresh()
|
|
229
|
+
|
|
230
|
+
def _refresh_latest(self) -> None:
|
|
231
|
+
if not self._mounted:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
self.query_one("#subagent-latest", Static).update(self._latest_content)
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def can_expand(self) -> bool:
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
def expand_block(self) -> None:
|
|
242
|
+
self.action_toggle()
|
|
243
|
+
|
|
244
|
+
def collapse_block(self) -> None:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def is_block_expanded(self) -> bool:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def action_toggle(self) -> None:
|
|
251
|
+
"""Ctrl+X: open full-screen view (works even while running)."""
|
|
252
|
+
from tui.widgets.subagent_screen import SubAgentScreen
|
|
253
|
+
self.app.push_screen(SubAgentScreen(self))
|