@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.
Files changed (116) hide show
  1. package/cdh/__pycache__/__main__.cpython-314.pyc +0 -0
  2. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  3. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  4. package/cdh/cli.py +473 -87
  5. package/cdh/scaffold.py +386 -121
  6. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  7. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  8. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  9. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  10. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  11. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  12. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  13. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  14. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  15. package/onecode/agent/agents/types.py +123 -79
  16. package/onecode/agent/cdh_loader.py +26 -13
  17. package/onecode/agent/engine.py +426 -235
  18. package/onecode/agent/onecode_agent_acp.py +200 -73
  19. package/onecode/agent/session.py +8 -13
  20. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  21. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  22. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  23. package/onecode/agent/tools/agent_tools.py +4 -4
  24. package/onecode/agent/tools/todo_tools.py +188 -0
  25. package/onecode/cli.py +3 -3
  26. package/onecode/commands.py +439 -194
  27. package/onecode/config.py +3 -3
  28. package/onecode/config_screen.py +4 -2
  29. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  30. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  31. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  32. package/onecode/models/errors.py +11 -1
  33. package/onecode/models/messages.py +35 -16
  34. package/onecode/models/provider.py +243 -12
  35. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  36. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  37. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  38. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  39. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  40. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  41. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  42. package/onecode/models/providers/anthropic_provider.py +4 -2
  43. package/onecode/models/providers/deepseek_provider.py +2 -0
  44. package/onecode/models/providers/glm_provider.py +2 -1
  45. package/onecode/models/providers/minimax_provider.py +2 -1
  46. package/onecode/models/providers/minimaxi_provider.py +76 -25
  47. package/onecode/models/providers/ollama_provider.py +2 -1
  48. package/onecode/models/providers/openai_provider.py +4 -0
  49. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  50. package/onecode/tasks/__pycache__/__init__.cpython-314.pyc +0 -0
  51. package/onecode/tasks/__pycache__/manager.cpython-314.pyc +0 -0
  52. package/onecode/tasks/__pycache__/models.cpython-314.pyc +0 -0
  53. package/onecode/tasks/manager.py +1 -1
  54. package/package.json +1 -1
  55. package/pyproject.toml +10 -0
  56. package/run.js +24 -7
  57. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  58. package/tui/__pycache__/cli.cpython-314.pyc +0 -0
  59. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  60. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  61. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  62. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  63. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  64. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  65. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  66. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  67. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  68. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  69. package/tui/acp/agent.py +60 -11
  70. package/tui/acp/api.py +6 -0
  71. package/tui/acp/messages.py +11 -0
  72. package/tui/acp/prompt.py +1 -1
  73. package/tui/acp/protocol.py +31 -0
  74. package/tui/app.py +60 -8
  75. package/tui/db.py +23 -0
  76. package/tui/directory_watcher.py +35 -1
  77. package/tui/messages.py +4 -1
  78. package/tui/paths.py +3 -3
  79. package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
  80. package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
  81. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  82. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  83. package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
  84. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  85. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  86. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  87. package/tui/screens/component_picker.py +148 -0
  88. package/tui/screens/main.py +47 -95
  89. package/tui/screens/projects.tcss +10 -0
  90. package/tui/screens/projects_app.py +33 -5
  91. package/tui/screens/projects_screen.py +171 -9
  92. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  93. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  94. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  95. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  96. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  97. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  98. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  99. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  100. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  101. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  102. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  103. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  104. package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
  105. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  106. package/tui/widgets/ask_user.py +1 -1
  107. package/tui/widgets/conversation.py +99 -42
  108. package/tui/widgets/modified_files.py +155 -23
  109. package/tui/widgets/plan.py +22 -0
  110. package/tui/widgets/project_directory_tree.py +22 -10
  111. package/tui/widgets/project_grid_select.py +23 -1
  112. package/tui/widgets/question.py +1 -1
  113. package/tui/widgets/subagent.py +202 -36
  114. package/tui/widgets/subagent_screen.py +186 -0
  115. package/tui/widgets/tool_call.py +5 -8
  116. 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([], recompose=True)
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
- # Initial value is set via data_bind in main.py on_mount.
58
- # Don't use set_reactive here — let _initialize_reactive trigger
59
- # watch_path properly on first reactive access.
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
- """Run git status when path changes."""
105
+ """Schedule a (debounced, async) git status when path changes."""
106
+ self._cancel_debounce()
63
107
  if path is None:
64
- self.files = []
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=5,
166
+ timeout=GIT_STATUS_TIMEOUT,
72
167
  )
73
- if result.returncode == 0:
74
- self.files = [l for l in result.stdout.strip().split("\n") if l.strip()]
75
- else:
76
- self.files = []
77
- except (subprocess.TimeoutExpired, FileNotFoundError):
78
- self.files = []
79
-
80
- def refresh_files(self) -> None:
81
- """Re-run git status manually (e.g. after file system changes)."""
82
- self.watch_path(self.path)
83
-
84
- def compose(self) -> ComposeResult:
85
- if not self.files:
86
- yield Static("No modified files", classes="no-changes")
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
- for line in self.files:
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
- yield Static(f" {filepath}", classes=cls)
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:
@@ -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
- has_cursor = self.cursor_node is not None
55
- self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path)))
56
- await self.reload()
57
- if has_cursor:
58
- self.cursor_line = 0
59
- self.scroll_to(0, 0, animate=False)
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
- path = Path(self.path) if isinstance(self.path, str) else self.path
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
- await self.reload()
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, str(pf), id=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)
@@ -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, layout=True)
250
+ self.refresh(recompose=True)
251
251
 
252
252
  def compose(self) -> ComposeResult:
253
253
 
@@ -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 containers
6
- from textual.widgets import Static, Markdown
10
+ from textual.widgets import Static
7
11
 
8
- from tui.pill import pill
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 SubAgentContent(Markdown):
22
- DEFAULT_CSS = """
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
- &.-status-running SubAgentHeader {
39
- color: $text-warning;
40
- text-style: italic;
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
- &.-status-completed SubAgentHeader {
43
- color: $text-success;
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.status = "running"
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.status}")
63
- label = pill(self.agent_type, "$warning-muted", "$text-warning")
64
- yield SubAgentHeader(
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 append_chunk(self, text: str) -> None:
71
- self._chunks.append(text)
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
- content = self.query_one(SubAgentContent)
74
- content.load("".join(self._chunks))
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 complete(self) -> None:
79
- self.status = "completed"
80
- self.set_class(True, "-status-completed")
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))