ccmd 2.0.1__tar.gz → 2.0.3__tar.gz
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.
- {ccmd-2.0.1/ccmd.egg-info → ccmd-2.0.3}/PKG-INFO +1 -1
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/__init__.py +1 -1
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/agents.py +32 -10
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/browser.py +72 -7
- ccmd-2.0.3/ccmd/tui/screens/hub.py +473 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/theme.py +12 -0
- {ccmd-2.0.1 → ccmd-2.0.3/ccmd.egg-info}/PKG-INFO +1 -1
- {ccmd-2.0.1 → ccmd-2.0.3}/pyproject.toml +1 -1
- ccmd-2.0.1/ccmd/tui/screens/hub.py +0 -238
- {ccmd-2.0.1 → ccmd-2.0.3}/LICENSE +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/MANIFEST.in +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/README.md +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/__main__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/cli.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/config/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/config/paths.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/config/schema.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/aider.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/base.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/chatgpt.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/claude.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/codex.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/cursor.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/goose.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/agents/grok.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/fs.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/launcher.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/migrate.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/profiles.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/projects.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/scan.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/sessions.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/core/ssh.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/mcp/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/mcp/server.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/app.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/ascii.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/aliases.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/project_edit.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/sessions.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/screens/ssh.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/widgets/__init__.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd/tui/widgets/spinner.py +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd.egg-info/SOURCES.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd.egg-info/dependency_links.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd.egg-info/entry_points.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd.egg-info/requires.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/ccmd.egg-info/top_level.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/commands.yaml +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/requirements.txt +0 -0
- {ccmd-2.0.1 → ccmd-2.0.3}/setup.cfg +0 -0
|
@@ -8,9 +8,8 @@ from textual.containers import Grid, Vertical
|
|
|
8
8
|
from textual.screen import Screen
|
|
9
9
|
from textual.widgets import Footer, Static
|
|
10
10
|
|
|
11
|
-
from ccmd.core.agents import
|
|
11
|
+
from ccmd.core.agents import detect_agents
|
|
12
12
|
from ccmd.core.projects import Project
|
|
13
|
-
from ccmd.tui.ascii import agent_tile
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class AgentScreen(Screen[str | None]):
|
|
@@ -20,26 +19,43 @@ class AgentScreen(Screen[str | None]):
|
|
|
20
19
|
Binding("right", "right", show=False),
|
|
21
20
|
Binding("up", "up", show=False),
|
|
22
21
|
Binding("down", "down", show=False),
|
|
23
|
-
Binding("enter", "select", "
|
|
22
|
+
Binding("enter", "select", "Select", show=True),
|
|
24
23
|
Binding("i", "hint", "Install hint", show=True),
|
|
25
24
|
]
|
|
26
25
|
|
|
27
|
-
def __init__(
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
project: Project | None = None,
|
|
29
|
+
mode: str = "launch", # launch | set
|
|
30
|
+
) -> None:
|
|
28
31
|
super().__init__()
|
|
29
32
|
self.project = project
|
|
33
|
+
self.mode = mode
|
|
30
34
|
self._agents = detect_agents()
|
|
31
35
|
self._index = 0
|
|
36
|
+
# prefer project's default
|
|
37
|
+
if project:
|
|
38
|
+
for i, a in enumerate(self._agents):
|
|
39
|
+
if a["id"] == project.default_agent:
|
|
40
|
+
self._index = i
|
|
41
|
+
break
|
|
32
42
|
|
|
33
43
|
def compose(self) -> ComposeResult:
|
|
34
|
-
|
|
44
|
+
if self.mode == "set":
|
|
45
|
+
title = "set default agent"
|
|
46
|
+
else:
|
|
47
|
+
title = "choose agent · saves default · launches"
|
|
35
48
|
if self.project:
|
|
36
|
-
title = f"
|
|
49
|
+
title = f"{title} · [bold #00ffc8]{self.project.name}[/]"
|
|
37
50
|
yield Static(title, id="subtitle")
|
|
38
51
|
yield Static("", id="spinner-line")
|
|
39
52
|
with Vertical():
|
|
40
53
|
yield Grid(id="agent-grid")
|
|
41
54
|
yield Static("", id="agent-detail")
|
|
42
|
-
yield Static(
|
|
55
|
+
yield Static(
|
|
56
|
+
"←→↑↓ move · ↵ select · i install hint · esc back",
|
|
57
|
+
id="footer-bar",
|
|
58
|
+
)
|
|
43
59
|
yield Footer()
|
|
44
60
|
|
|
45
61
|
def on_mount(self) -> None:
|
|
@@ -47,7 +63,8 @@ class AgentScreen(Screen[str | None]):
|
|
|
47
63
|
self._render_grid()
|
|
48
64
|
installed = sum(1 for a in self._agents if a["installed"])
|
|
49
65
|
self.query_one("#spinner-line", Static).update(
|
|
50
|
-
f"✦ {installed}/{len(self._agents)} agents ready"
|
|
66
|
+
f"✦ {installed}/{len(self._agents)} agents ready · "
|
|
67
|
+
f"not just claude — pick any"
|
|
51
68
|
)
|
|
52
69
|
|
|
53
70
|
def _render_grid(self) -> None:
|
|
@@ -59,8 +76,9 @@ class AgentScreen(Screen[str | None]):
|
|
|
59
76
|
if i == self._index:
|
|
60
77
|
classes.append("-focus")
|
|
61
78
|
color = a["color"] if a["installed"] else "#6a6a80"
|
|
79
|
+
num = i + 1
|
|
62
80
|
body = (
|
|
63
|
-
f"[{color}]{a['logo']}[/]\n"
|
|
81
|
+
f"[#6a6a80]{num}[/] [{color}]{a['logo']}[/]\n"
|
|
64
82
|
f"[bold]{a['name']}[/]\n"
|
|
65
83
|
f"{'ready' if a['installed'] else 'not found'}"
|
|
66
84
|
)
|
|
@@ -69,12 +87,14 @@ class AgentScreen(Screen[str | None]):
|
|
|
69
87
|
|
|
70
88
|
def _update_detail(self) -> None:
|
|
71
89
|
a = self._agents[self._index]
|
|
90
|
+
action = "set default only" if self.mode == "set" else "set default + launch"
|
|
72
91
|
lines = [
|
|
73
92
|
f"[bold]{a['logo']} {a['name']}[/] ({a['id']})",
|
|
74
93
|
f"status {'installed' if a['installed'] else 'missing'}",
|
|
75
94
|
f"version {a['version'] or '—'}",
|
|
76
95
|
f"binary {a['binary'] or '—'}",
|
|
77
96
|
f"hint {a['hint']}",
|
|
97
|
+
f"action {action}",
|
|
78
98
|
]
|
|
79
99
|
self.query_one("#agent-detail", Static).update("\n".join(lines))
|
|
80
100
|
|
|
@@ -98,7 +118,9 @@ class AgentScreen(Screen[str | None]):
|
|
|
98
118
|
def action_select(self) -> None:
|
|
99
119
|
a = self._agents[self._index]
|
|
100
120
|
if not a["installed"] and a["id"] != "chatgpt":
|
|
101
|
-
self.notify(
|
|
121
|
+
self.notify(
|
|
122
|
+
f"{a['name']} not installed — press i for hint", severity="warning"
|
|
123
|
+
)
|
|
102
124
|
return
|
|
103
125
|
self.dismiss(a["id"])
|
|
104
126
|
|
|
@@ -13,9 +13,12 @@ from textual.widgets import Footer, Input, OptionList, Static
|
|
|
13
13
|
from textual.widgets.option_list import Option
|
|
14
14
|
|
|
15
15
|
from ccmd.config.schema import load_config
|
|
16
|
+
from ccmd.core.agents import all_agents, get_agent
|
|
16
17
|
from ccmd.core.fs import default_browse_roots, is_projectish, list_dir_entries
|
|
17
18
|
from ccmd.core.projects import ProjectRegistry
|
|
18
19
|
|
|
20
|
+
_AGENT_IDS = [a.id for a in all_agents()]
|
|
21
|
+
|
|
19
22
|
|
|
20
23
|
class DirBrowserScreen(Screen[bool | None]):
|
|
21
24
|
"""Browse the filesystem; select a directory to register as a project.
|
|
@@ -41,6 +44,15 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
41
44
|
Binding("slash", "focus_filter", "Filter", show=False),
|
|
42
45
|
Binding("/", "focus_filter", "Filter", show=True),
|
|
43
46
|
Binding("g", "go_path", "Go path", show=True),
|
|
47
|
+
Binding("left_square_bracket", "agent_prev", "Agent−", show=True),
|
|
48
|
+
Binding("right_square_bracket", "agent_next", "Agent+", show=True),
|
|
49
|
+
Binding("1", "agent_n_1", show=False),
|
|
50
|
+
Binding("2", "agent_n_2", show=False),
|
|
51
|
+
Binding("3", "agent_n_3", show=False),
|
|
52
|
+
Binding("4", "agent_n_4", show=False),
|
|
53
|
+
Binding("5", "agent_n_5", show=False),
|
|
54
|
+
Binding("6", "agent_n_6", show=False),
|
|
55
|
+
Binding("7", "agent_n_7", show=False),
|
|
44
56
|
]
|
|
45
57
|
|
|
46
58
|
def __init__(self, start: Path | None = None) -> None:
|
|
@@ -68,7 +80,7 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
68
80
|
yield Static("preview", classes="panel-title")
|
|
69
81
|
yield Static("", id="preview")
|
|
70
82
|
yield Static(
|
|
71
|
-
"↵ open · ⌫ up · s
|
|
83
|
+
"↵ open · ⌫ up · s add project · ]/[ agent · 1-7 agent · r roots · g path · esc",
|
|
72
84
|
id="footer-bar",
|
|
73
85
|
)
|
|
74
86
|
yield Footer()
|
|
@@ -83,8 +95,11 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
83
95
|
bar = self.query_one("#path-bar", Static)
|
|
84
96
|
mark = " · project?" if is_projectish(self._cwd) else ""
|
|
85
97
|
hidden = " · hidden" if self._show_hidden else ""
|
|
98
|
+
ag = get_agent(self._agent)
|
|
99
|
+
ag_s = f"{ag.ascii_logo} {self._agent}" if ag else self._agent
|
|
86
100
|
bar.update(
|
|
87
|
-
f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden}"
|
|
101
|
+
f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden} · "
|
|
102
|
+
f"agent [bold #c84bff]{ag_s}[/]"
|
|
88
103
|
+ (f" [#6a6a80]{extra}[/]" if extra else "")
|
|
89
104
|
)
|
|
90
105
|
|
|
@@ -248,15 +263,57 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
248
263
|
self.query_one("#filter", Input).value = ""
|
|
249
264
|
self._render_list()
|
|
250
265
|
|
|
266
|
+
def action_agent_prev(self) -> None:
|
|
267
|
+
try:
|
|
268
|
+
i = _AGENT_IDS.index(self._agent)
|
|
269
|
+
except ValueError:
|
|
270
|
+
i = 0
|
|
271
|
+
self._agent = _AGENT_IDS[(i - 1) % len(_AGENT_IDS)]
|
|
272
|
+
self._set_path_bar()
|
|
273
|
+
self.notify(f"agent → {self._agent}", severity="information", timeout=1)
|
|
274
|
+
|
|
275
|
+
def action_agent_next(self) -> None:
|
|
276
|
+
try:
|
|
277
|
+
i = _AGENT_IDS.index(self._agent)
|
|
278
|
+
except ValueError:
|
|
279
|
+
i = 0
|
|
280
|
+
self._agent = _AGENT_IDS[(i + 1) % len(_AGENT_IDS)]
|
|
281
|
+
self._set_path_bar()
|
|
282
|
+
self.notify(f"agent → {self._agent}", severity="information", timeout=1)
|
|
283
|
+
|
|
284
|
+
def _agent_n(self, n: int) -> None:
|
|
285
|
+
if 1 <= n <= len(_AGENT_IDS):
|
|
286
|
+
self._agent = _AGENT_IDS[n - 1]
|
|
287
|
+
self._set_path_bar()
|
|
288
|
+
self.notify(f"agent → {self._agent}", severity="information", timeout=1)
|
|
289
|
+
|
|
290
|
+
def action_agent_n_1(self) -> None:
|
|
291
|
+
self._agent_n(1)
|
|
292
|
+
|
|
293
|
+
def action_agent_n_2(self) -> None:
|
|
294
|
+
self._agent_n(2)
|
|
295
|
+
|
|
296
|
+
def action_agent_n_3(self) -> None:
|
|
297
|
+
self._agent_n(3)
|
|
298
|
+
|
|
299
|
+
def action_agent_n_4(self) -> None:
|
|
300
|
+
self._agent_n(4)
|
|
301
|
+
|
|
302
|
+
def action_agent_n_5(self) -> None:
|
|
303
|
+
self._agent_n(5)
|
|
304
|
+
|
|
305
|
+
def action_agent_n_6(self) -> None:
|
|
306
|
+
self._agent_n(6)
|
|
307
|
+
|
|
308
|
+
def action_agent_n_7(self) -> None:
|
|
309
|
+
self._agent_n(7)
|
|
310
|
+
|
|
251
311
|
def action_select(self) -> None:
|
|
252
312
|
"""Register highlighted path, or current directory if nothing useful."""
|
|
253
313
|
path = self._selected_path()
|
|
254
314
|
# if on .. or empty, use cwd
|
|
255
315
|
if path is None or (path == self._cwd.parent and self._mode == "browse"):
|
|
256
316
|
path = self._cwd
|
|
257
|
-
if self._mode == "roots" and path:
|
|
258
|
-
# selecting a root without entering — still ok as project
|
|
259
|
-
pass
|
|
260
317
|
if not path or not path.is_dir():
|
|
261
318
|
self.notify("not a directory", severity="error")
|
|
262
319
|
return
|
|
@@ -269,7 +326,12 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
269
326
|
reg = ProjectRegistry()
|
|
270
327
|
existing = reg.find_by_path(resolved)
|
|
271
328
|
if existing:
|
|
272
|
-
|
|
329
|
+
existing.default_agent = self._agent
|
|
330
|
+
reg.save(existing)
|
|
331
|
+
self.notify(
|
|
332
|
+
f"already had {existing.id} — default agent → {self._agent}",
|
|
333
|
+
severity="information",
|
|
334
|
+
)
|
|
273
335
|
self.dismiss(True)
|
|
274
336
|
return
|
|
275
337
|
|
|
@@ -280,7 +342,10 @@ class DirBrowserScreen(Screen[bool | None]):
|
|
|
280
342
|
default_agent=self._agent,
|
|
281
343
|
notes="added via browser",
|
|
282
344
|
)
|
|
283
|
-
self.notify(
|
|
345
|
+
self.notify(
|
|
346
|
+
f"added {p.id} · agent {self._agent} → {resolved}",
|
|
347
|
+
severity="information",
|
|
348
|
+
)
|
|
284
349
|
self.dismiss(True)
|
|
285
350
|
|
|
286
351
|
def action_go_path(self) -> None:
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Main project hub screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import on, work
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import Screen
|
|
12
|
+
from textual.widgets import Footer, Input, OptionList, Static
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
from ccmd import __version__
|
|
16
|
+
from ccmd.core.agents import all_agents, detect_agents, get_agent
|
|
17
|
+
from ccmd.core.launcher import LaunchError, launch_project
|
|
18
|
+
from ccmd.core.projects import Project, ProjectRegistry
|
|
19
|
+
from ccmd.tui.ascii import BANNER, BANNER_COMPACT
|
|
20
|
+
|
|
21
|
+
# Order for 1–7 hotkeys
|
|
22
|
+
AGENT_ORDER = [a.id for a in all_agents()]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _agent_bar() -> str:
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
for i, a in enumerate(detect_agents(), start=1):
|
|
28
|
+
if a["installed"]:
|
|
29
|
+
parts.append(f"[{a['color']}]{i}:{a['logo']}{a['id']}[/]")
|
|
30
|
+
else:
|
|
31
|
+
parts.append(f"[#6a6a80]{i}:{a['id']}[/]")
|
|
32
|
+
return " ".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HubScreen(Screen):
|
|
36
|
+
# Filter Input is can_focus=False until / is pressed, so f/b/a never type into it.
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
Binding("enter", "open_project", "Open", show=True),
|
|
39
|
+
Binding("a", "pick_agent", "Agents", show=True),
|
|
40
|
+
Binding("A", "set_agent_only", "Set agent", show=True),
|
|
41
|
+
Binding("n", "new_project", "New", show=True),
|
|
42
|
+
Binding("b", "browse", "Browse", show=True),
|
|
43
|
+
Binding("f", "browse_mnt", "Disk /mnt", show=True),
|
|
44
|
+
Binding("s", "sessions", "Sessions", show=True),
|
|
45
|
+
Binding("h", "ssh", "SSH", show=True),
|
|
46
|
+
Binding("l", "aliases", "Aliases", show=True),
|
|
47
|
+
Binding("d", "detach", "Detach", show=True),
|
|
48
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
49
|
+
Binding("q", "quit", "Quit", show=True),
|
|
50
|
+
Binding("left_square_bracket", "agent_prev", "Agent−", show=True),
|
|
51
|
+
Binding("right_square_bracket", "agent_next", "Agent+", show=True),
|
|
52
|
+
Binding("slash", "focus_filter", "Filter", show=False),
|
|
53
|
+
Binding("/", "focus_filter", "Filter", show=True),
|
|
54
|
+
Binding("escape", "blur_filter", "Esc", show=False),
|
|
55
|
+
Binding("1", "agent_n_1", show=False),
|
|
56
|
+
Binding("2", "agent_n_2", show=False),
|
|
57
|
+
Binding("3", "agent_n_3", show=False),
|
|
58
|
+
Binding("4", "agent_n_4", show=False),
|
|
59
|
+
Binding("5", "agent_n_5", show=False),
|
|
60
|
+
Binding("6", "agent_n_6", show=False),
|
|
61
|
+
Binding("7", "agent_n_7", show=False),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.registry = ProjectRegistry()
|
|
67
|
+
self._projects: list[Project] = []
|
|
68
|
+
self._filtered: list[Project] = []
|
|
69
|
+
self._agent_info = detect_agents()
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
yield Static(BANNER.strip("\n"), id="banner")
|
|
73
|
+
yield Static(
|
|
74
|
+
f"project hub · v{__version__} · b/f browse disk · ] cycle agent · 1–7 set agent",
|
|
75
|
+
id="subtitle",
|
|
76
|
+
)
|
|
77
|
+
yield Static(_agent_bar(), id="agent-bar")
|
|
78
|
+
# can_focus stays False until / — prevents f/b/a landing in the box
|
|
79
|
+
yield Input(
|
|
80
|
+
placeholder="press / to filter projects…",
|
|
81
|
+
id="filter",
|
|
82
|
+
)
|
|
83
|
+
with Horizontal(id="main"):
|
|
84
|
+
with Vertical(id="projects-panel"):
|
|
85
|
+
yield Static("projects (registry — not whole disk)", classes="panel-title")
|
|
86
|
+
yield OptionList(id="project-list")
|
|
87
|
+
with Vertical(id="detail-panel"):
|
|
88
|
+
yield Static("detail", classes="panel-title")
|
|
89
|
+
yield Static("Select a project", id="detail")
|
|
90
|
+
yield Static("", id="status")
|
|
91
|
+
yield Static(
|
|
92
|
+
"↵ open · f /mnt · b browse · / filter · esc list · a agent · ] cycle · q quit",
|
|
93
|
+
id="footer-bar",
|
|
94
|
+
)
|
|
95
|
+
yield Footer()
|
|
96
|
+
|
|
97
|
+
def on_mount(self) -> None:
|
|
98
|
+
filt = self.query_one("#filter", Input)
|
|
99
|
+
filt.can_focus = False
|
|
100
|
+
self._load_projects()
|
|
101
|
+
self.query_one("#project-list", OptionList).focus()
|
|
102
|
+
if not self._projects:
|
|
103
|
+
self.notify(
|
|
104
|
+
"no projects — opening disk browser (or press f for /mnt)",
|
|
105
|
+
severity="information",
|
|
106
|
+
timeout=4,
|
|
107
|
+
)
|
|
108
|
+
self.set_timer(0.3, self.action_browse)
|
|
109
|
+
else:
|
|
110
|
+
if all(p.default_agent == "claude" for p in self._projects[:20]):
|
|
111
|
+
self.notify(
|
|
112
|
+
"tip: f = browse /mnt · / = filter · ] = cycle agent",
|
|
113
|
+
severity="information",
|
|
114
|
+
timeout=5,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _load_projects(self, query: str = "") -> None:
|
|
118
|
+
self._projects = self.registry.list()
|
|
119
|
+
if query.strip():
|
|
120
|
+
self._filtered = self.registry.fuzzy(query)
|
|
121
|
+
else:
|
|
122
|
+
self._filtered = list(self._projects)
|
|
123
|
+
ol = self.query_one("#project-list", OptionList)
|
|
124
|
+
# preserve highlight id
|
|
125
|
+
prev_id = None
|
|
126
|
+
if ol.highlighted is not None:
|
|
127
|
+
try:
|
|
128
|
+
prev_id = ol.get_option_at_index(ol.highlighted).id
|
|
129
|
+
except Exception:
|
|
130
|
+
prev_id = None
|
|
131
|
+
ol.clear_options()
|
|
132
|
+
if not self._filtered:
|
|
133
|
+
ol.add_option(Option(" (no projects — press b or f to browse disk)", id="__empty__"))
|
|
134
|
+
self.query_one("#status", Static).update(
|
|
135
|
+
"empty registry · b browse · f /mnt · n type path"
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
restore_idx = 0
|
|
139
|
+
for i, p in enumerate(self._filtered):
|
|
140
|
+
mark = "✓" if p.exists() else "!"
|
|
141
|
+
agent = get_agent(p.default_agent)
|
|
142
|
+
logo = agent.ascii_logo if agent else "?"
|
|
143
|
+
# short root hint so non-vylth stands out
|
|
144
|
+
root_hint = _root_label(p.path)
|
|
145
|
+
label = f" {mark} {logo} {p.name:<18} {p.default_agent:<8} {root_hint} {p.path}"
|
|
146
|
+
ol.add_option(Option(label, id=p.id))
|
|
147
|
+
if prev_id and p.id == prev_id:
|
|
148
|
+
restore_idx = i
|
|
149
|
+
ol.highlighted = restore_idx
|
|
150
|
+
self._update_detail()
|
|
151
|
+
agents_used = sorted({p.default_agent for p in self._projects})
|
|
152
|
+
self.query_one("#status", Static).update(
|
|
153
|
+
f"{len(self._filtered)}/{len(self._projects)} projects · "
|
|
154
|
+
f"defaults: {', '.join(agents_used)} · "
|
|
155
|
+
f"disk: b / f not this list"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _selected(self) -> Project | None:
|
|
159
|
+
ol = self.query_one("#project-list", OptionList)
|
|
160
|
+
if ol.highlighted is None:
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
opt = ol.get_option_at_index(ol.highlighted)
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
if opt.id in (None, "__empty__"):
|
|
167
|
+
return None
|
|
168
|
+
return self.registry.get(str(opt.id))
|
|
169
|
+
|
|
170
|
+
def _update_detail(self) -> None:
|
|
171
|
+
p = self._selected()
|
|
172
|
+
detail = self.query_one("#detail", Static)
|
|
173
|
+
if not p:
|
|
174
|
+
detail.update(
|
|
175
|
+
"Select a project\n\n"
|
|
176
|
+
"[#6a6a80]This list is your saved registry.\n"
|
|
177
|
+
"Press [bold]f[/] to browse /mnt and subdirs,\n"
|
|
178
|
+
"or [bold]b[/] for home + all roots.\n"
|
|
179
|
+
"[bold]][/] cycles default agent on selection.[/]"
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
agent = get_agent(p.default_agent)
|
|
183
|
+
agent_line = (
|
|
184
|
+
f"{agent.ascii_logo} {agent.display_name}"
|
|
185
|
+
if agent
|
|
186
|
+
else p.default_agent
|
|
187
|
+
)
|
|
188
|
+
exists = "yes" if p.exists() else "MISSING"
|
|
189
|
+
# agent roster
|
|
190
|
+
roster: list[str] = []
|
|
191
|
+
for i, a in enumerate(self._agent_info, start=1):
|
|
192
|
+
mark = "●" if a["id"] == p.default_agent else "·"
|
|
193
|
+
ready = "✓" if a["installed"] else "✗"
|
|
194
|
+
roster.append(f" {mark} {i} {a['logo']} {a['id']:<8} {ready}")
|
|
195
|
+
|
|
196
|
+
lines = [
|
|
197
|
+
f"[bold #00ffc8]{p.name}[/]",
|
|
198
|
+
f"id {p.id}",
|
|
199
|
+
f"path {p.path}",
|
|
200
|
+
f"root {_root_label(p.path)}",
|
|
201
|
+
f"exists {exists}",
|
|
202
|
+
f"agent [bold]{agent_line}[/] (default)",
|
|
203
|
+
f"ssh {p.ssh_host or '—'}",
|
|
204
|
+
f"env {', '.join(p.env_files) or '—'}",
|
|
205
|
+
f"tags {', '.join(p.tags) or '—'}",
|
|
206
|
+
f"opened {p.last_opened_at or 'never'}",
|
|
207
|
+
"",
|
|
208
|
+
"[bold #c84bff]agents[/] ]/[ cycle · 1-7 set · A set only · a launch",
|
|
209
|
+
*roster,
|
|
210
|
+
"",
|
|
211
|
+
"[#6a6a80]↵ open · d detach · b/f add from disk[/]",
|
|
212
|
+
]
|
|
213
|
+
detail.update("\n".join(lines))
|
|
214
|
+
|
|
215
|
+
def _set_default_agent(self, agent_id: str, *, notify: bool = True) -> None:
|
|
216
|
+
p = self._selected()
|
|
217
|
+
if not p:
|
|
218
|
+
self.notify("select a project first", severity="warning")
|
|
219
|
+
return
|
|
220
|
+
if get_agent(agent_id) is None:
|
|
221
|
+
self.notify(f"unknown agent {agent_id}", severity="error")
|
|
222
|
+
return
|
|
223
|
+
p.default_agent = agent_id
|
|
224
|
+
self.registry.save(p)
|
|
225
|
+
if notify:
|
|
226
|
+
self.notify(f"{p.id} → default agent {agent_id}", severity="information")
|
|
227
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
228
|
+
|
|
229
|
+
def _cycle_agent(self, delta: int) -> None:
|
|
230
|
+
p = self._selected()
|
|
231
|
+
if not p:
|
|
232
|
+
self.notify("select a project first", severity="warning")
|
|
233
|
+
return
|
|
234
|
+
ids = AGENT_ORDER
|
|
235
|
+
try:
|
|
236
|
+
idx = ids.index(p.default_agent)
|
|
237
|
+
except ValueError:
|
|
238
|
+
idx = 0
|
|
239
|
+
new_id = ids[(idx + delta) % len(ids)]
|
|
240
|
+
self._set_default_agent(new_id)
|
|
241
|
+
|
|
242
|
+
def _agent_n(self, n: int) -> None:
|
|
243
|
+
if 1 <= n <= len(AGENT_ORDER):
|
|
244
|
+
self._set_default_agent(AGENT_ORDER[n - 1])
|
|
245
|
+
|
|
246
|
+
# ── events ───────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
@on(Input.Changed, "#filter")
|
|
249
|
+
def on_filter(self, event: Input.Changed) -> None:
|
|
250
|
+
self._load_projects(event.value)
|
|
251
|
+
|
|
252
|
+
@on(OptionList.OptionHighlighted)
|
|
253
|
+
def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
|
|
254
|
+
self._update_detail()
|
|
255
|
+
|
|
256
|
+
@on(OptionList.OptionSelected)
|
|
257
|
+
def on_select(self, event: OptionList.OptionSelected) -> None:
|
|
258
|
+
self.action_open_project()
|
|
259
|
+
|
|
260
|
+
# ── actions ──────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def action_focus_filter(self) -> None:
|
|
263
|
+
"""Only way into the filter — so f never types into the box by accident."""
|
|
264
|
+
filt = self.query_one("#filter", Input)
|
|
265
|
+
filt.can_focus = True
|
|
266
|
+
filt.focus()
|
|
267
|
+
|
|
268
|
+
def action_blur_filter(self) -> None:
|
|
269
|
+
"""Esc: leave filter, give keys back to hub (f/b/a work again)."""
|
|
270
|
+
filt = self.query_one("#filter", Input)
|
|
271
|
+
if self.focused is filt or filt.has_focus:
|
|
272
|
+
filt.can_focus = False
|
|
273
|
+
self.query_one("#project-list", OptionList).focus()
|
|
274
|
+
return
|
|
275
|
+
# if not in filter, esc clears filter text
|
|
276
|
+
if filt.value:
|
|
277
|
+
filt.value = ""
|
|
278
|
+
self._load_projects("")
|
|
279
|
+
self.query_one("#project-list", OptionList).focus()
|
|
280
|
+
|
|
281
|
+
def action_refresh(self) -> None:
|
|
282
|
+
self._agent_info = detect_agents()
|
|
283
|
+
try:
|
|
284
|
+
self.query_one("#agent-bar", Static).update(_agent_bar())
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
288
|
+
self.notify("refreshed", severity="information")
|
|
289
|
+
|
|
290
|
+
def action_agent_prev(self) -> None:
|
|
291
|
+
self._cycle_agent(-1)
|
|
292
|
+
|
|
293
|
+
def action_agent_next(self) -> None:
|
|
294
|
+
self._cycle_agent(1)
|
|
295
|
+
|
|
296
|
+
def action_agent_n_1(self) -> None:
|
|
297
|
+
self._agent_n(1)
|
|
298
|
+
|
|
299
|
+
def action_agent_n_2(self) -> None:
|
|
300
|
+
self._agent_n(2)
|
|
301
|
+
|
|
302
|
+
def action_agent_n_3(self) -> None:
|
|
303
|
+
self._agent_n(3)
|
|
304
|
+
|
|
305
|
+
def action_agent_n_4(self) -> None:
|
|
306
|
+
self._agent_n(4)
|
|
307
|
+
|
|
308
|
+
def action_agent_n_5(self) -> None:
|
|
309
|
+
self._agent_n(5)
|
|
310
|
+
|
|
311
|
+
def action_agent_n_6(self) -> None:
|
|
312
|
+
self._agent_n(6)
|
|
313
|
+
|
|
314
|
+
def action_agent_n_7(self) -> None:
|
|
315
|
+
self._agent_n(7)
|
|
316
|
+
|
|
317
|
+
def action_open_project(self) -> None:
|
|
318
|
+
p = self._selected()
|
|
319
|
+
if not p:
|
|
320
|
+
self.notify("no project selected", severity="warning")
|
|
321
|
+
return
|
|
322
|
+
if not p.exists():
|
|
323
|
+
self.notify(f"path missing: {p.path} — press b to re-add", severity="error")
|
|
324
|
+
return
|
|
325
|
+
self._launch(p, p.default_agent, detach=False)
|
|
326
|
+
|
|
327
|
+
def action_detach(self) -> None:
|
|
328
|
+
p = self._selected()
|
|
329
|
+
if not p:
|
|
330
|
+
self.notify("no project selected", severity="warning")
|
|
331
|
+
return
|
|
332
|
+
if not p.exists():
|
|
333
|
+
self.notify(f"path missing: {p.path}", severity="error")
|
|
334
|
+
return
|
|
335
|
+
self._launch(p, p.default_agent, detach=True)
|
|
336
|
+
|
|
337
|
+
def action_pick_agent(self) -> None:
|
|
338
|
+
"""Pick agent, save as default, then launch."""
|
|
339
|
+
p = self._selected()
|
|
340
|
+
if not p:
|
|
341
|
+
self.notify("select a project first", severity="warning")
|
|
342
|
+
return
|
|
343
|
+
from ccmd.tui.screens.agents import AgentScreen
|
|
344
|
+
|
|
345
|
+
def _done(agent_id: str | None) -> None:
|
|
346
|
+
if not agent_id:
|
|
347
|
+
return
|
|
348
|
+
p2 = self.registry.get(p.id) or p
|
|
349
|
+
p2.default_agent = agent_id
|
|
350
|
+
self.registry.save(p2)
|
|
351
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
352
|
+
self._launch(p2, agent_id, detach=False)
|
|
353
|
+
|
|
354
|
+
self.app.push_screen(AgentScreen(project=p, mode="launch"), _done)
|
|
355
|
+
|
|
356
|
+
def action_set_agent_only(self) -> None:
|
|
357
|
+
"""Pick agent and save as default without launching."""
|
|
358
|
+
p = self._selected()
|
|
359
|
+
if not p:
|
|
360
|
+
self.notify("select a project first", severity="warning")
|
|
361
|
+
return
|
|
362
|
+
from ccmd.tui.screens.agents import AgentScreen
|
|
363
|
+
|
|
364
|
+
def _done(agent_id: str | None) -> None:
|
|
365
|
+
if agent_id:
|
|
366
|
+
self._set_default_agent(agent_id)
|
|
367
|
+
|
|
368
|
+
self.app.push_screen(AgentScreen(project=p, mode="set"), _done)
|
|
369
|
+
|
|
370
|
+
def action_new_project(self) -> None:
|
|
371
|
+
from ccmd.tui.screens.project_edit import ProjectEditScreen
|
|
372
|
+
|
|
373
|
+
def _done(ok: bool | None) -> None:
|
|
374
|
+
if ok:
|
|
375
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
376
|
+
|
|
377
|
+
self.app.push_screen(ProjectEditScreen(), _done)
|
|
378
|
+
|
|
379
|
+
def action_browse(self) -> None:
|
|
380
|
+
from ccmd.tui.screens.browser import DirBrowserScreen
|
|
381
|
+
|
|
382
|
+
def _done(ok: bool | None) -> None:
|
|
383
|
+
if ok:
|
|
384
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
385
|
+
|
|
386
|
+
# start at home; roots list includes /mnt/*
|
|
387
|
+
self.app.push_screen(DirBrowserScreen(start=Path.home()), _done)
|
|
388
|
+
|
|
389
|
+
def action_browse_mnt(self) -> None:
|
|
390
|
+
from ccmd.tui.screens.browser import DirBrowserScreen
|
|
391
|
+
|
|
392
|
+
def _done(ok: bool | None) -> None:
|
|
393
|
+
if ok:
|
|
394
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
395
|
+
|
|
396
|
+
start = Path("/mnt")
|
|
397
|
+
if not start.is_dir():
|
|
398
|
+
start = Path.home()
|
|
399
|
+
self.notify("/mnt not found — browsing home", severity="warning")
|
|
400
|
+
self.app.push_screen(DirBrowserScreen(start=start), _done)
|
|
401
|
+
|
|
402
|
+
def action_sessions(self) -> None:
|
|
403
|
+
from ccmd.tui.screens.sessions import SessionsScreen
|
|
404
|
+
|
|
405
|
+
self.app.push_screen(SessionsScreen())
|
|
406
|
+
|
|
407
|
+
def action_ssh(self) -> None:
|
|
408
|
+
from ccmd.tui.screens.ssh import SSHScreen
|
|
409
|
+
|
|
410
|
+
self.app.push_screen(SSHScreen())
|
|
411
|
+
|
|
412
|
+
def action_aliases(self) -> None:
|
|
413
|
+
from ccmd.tui.screens.aliases import AliasesScreen
|
|
414
|
+
|
|
415
|
+
self.app.push_screen(AliasesScreen())
|
|
416
|
+
|
|
417
|
+
def action_quit(self) -> None:
|
|
418
|
+
self.app.exit()
|
|
419
|
+
|
|
420
|
+
@work(thread=True)
|
|
421
|
+
def _launch(self, project: Project, agent_id: str, detach: bool) -> None:
|
|
422
|
+
self.app.call_from_thread(
|
|
423
|
+
self.query_one("#status", Static).update,
|
|
424
|
+
f"handing off to {agent_id}…",
|
|
425
|
+
)
|
|
426
|
+
try:
|
|
427
|
+
|
|
428
|
+
def _do() -> None:
|
|
429
|
+
try:
|
|
430
|
+
code = launch_project(
|
|
431
|
+
project, agent_id, detach=detach, replace=not detach
|
|
432
|
+
)
|
|
433
|
+
if detach:
|
|
434
|
+
self.notify(
|
|
435
|
+
f"launched {agent_id} (detached)", severity="information"
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
self.notify(
|
|
439
|
+
f"{agent_id} exited ({code})", severity="information"
|
|
440
|
+
)
|
|
441
|
+
self._load_projects(self.query_one("#filter", Input).value)
|
|
442
|
+
except LaunchError as e:
|
|
443
|
+
self.notify(str(e), severity="error")
|
|
444
|
+
|
|
445
|
+
self.app.call_from_thread(_do)
|
|
446
|
+
except Exception as e:
|
|
447
|
+
self.app.call_from_thread(self.notify, str(e), severity="error")
|
|
448
|
+
|
|
449
|
+
def on_resize(self) -> None:
|
|
450
|
+
banner = self.query_one("#banner", Static)
|
|
451
|
+
if self.size.width < 50:
|
|
452
|
+
banner.update(BANNER_COMPACT)
|
|
453
|
+
else:
|
|
454
|
+
banner.update(BANNER.strip("\n"))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _root_label(path: str) -> str:
|
|
458
|
+
"""Short badge for where the project lives."""
|
|
459
|
+
p = path.replace("\\", "/")
|
|
460
|
+
if p.startswith("/mnt/vylth"):
|
|
461
|
+
return "[vylth]"
|
|
462
|
+
if p.startswith("/mnt/c"):
|
|
463
|
+
return "[win-c]"
|
|
464
|
+
if p.startswith("/mnt/d"):
|
|
465
|
+
return "[win-d]"
|
|
466
|
+
if p.startswith("/mnt/"):
|
|
467
|
+
part = p.split("/")[2] if len(p.split("/")) > 2 else "mnt"
|
|
468
|
+
return f"[mnt/{part}]"
|
|
469
|
+
if p.startswith(str(Path.home())):
|
|
470
|
+
return "[home]"
|
|
471
|
+
if p.startswith("/opt"):
|
|
472
|
+
return "[opt]"
|
|
473
|
+
return "[disk]"
|
|
@@ -15,10 +15,22 @@ Screen {
|
|
|
15
15
|
|
|
16
16
|
#subtitle {
|
|
17
17
|
color: #c84bff;
|
|
18
|
+
padding: 0 2 0 2;
|
|
19
|
+
height: 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#agent-bar {
|
|
23
|
+
color: #e8e8f0;
|
|
18
24
|
padding: 0 2 1 2;
|
|
19
25
|
height: 1;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
#path-bar {
|
|
29
|
+
color: #e8e8f0;
|
|
30
|
+
padding: 0 2 1 2;
|
|
31
|
+
height: auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
#filter {
|
|
23
35
|
dock: top;
|
|
24
36
|
margin: 0 1 1 1;
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
"""Main project hub screen."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from textual import on, work
|
|
6
|
-
from textual.app import ComposeResult
|
|
7
|
-
from textual.binding import Binding
|
|
8
|
-
from textual.containers import Horizontal, Vertical
|
|
9
|
-
from textual.screen import Screen
|
|
10
|
-
from textual.widgets import Footer, Input, OptionList, Static
|
|
11
|
-
from textual.widgets.option_list import Option
|
|
12
|
-
|
|
13
|
-
from ccmd import __version__
|
|
14
|
-
from ccmd.core.agents import get_agent
|
|
15
|
-
from ccmd.core.launcher import LaunchError, launch_project
|
|
16
|
-
from ccmd.core.projects import Project, ProjectRegistry
|
|
17
|
-
from ccmd.tui.ascii import BANNER, BANNER_COMPACT
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class HubScreen(Screen):
|
|
21
|
-
BINDINGS = [
|
|
22
|
-
Binding("enter", "open_project", "Open", show=True),
|
|
23
|
-
Binding("a", "pick_agent", "Agents", show=True),
|
|
24
|
-
Binding("n", "new_project", "New", show=True),
|
|
25
|
-
Binding("b", "browse", "Browse", show=True),
|
|
26
|
-
Binding("s", "sessions", "Sessions", show=True),
|
|
27
|
-
Binding("h", "ssh", "SSH", show=True),
|
|
28
|
-
Binding("l", "aliases", "Aliases", show=True),
|
|
29
|
-
Binding("slash", "focus_filter", "Filter", show=False),
|
|
30
|
-
Binding("/", "focus_filter", "Filter", show=True),
|
|
31
|
-
Binding("r", "refresh", "Refresh", show=True),
|
|
32
|
-
Binding("q", "quit", "Quit", show=True),
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
def __init__(self) -> None:
|
|
36
|
-
super().__init__()
|
|
37
|
-
self.registry = ProjectRegistry()
|
|
38
|
-
self._projects: list[Project] = []
|
|
39
|
-
self._filtered: list[Project] = []
|
|
40
|
-
|
|
41
|
-
def compose(self) -> ComposeResult:
|
|
42
|
-
yield Static(BANNER.strip("\n"), id="banner")
|
|
43
|
-
yield Static(f"project hub · v{__version__} · pick → agent → launch", id="subtitle")
|
|
44
|
-
yield Input(placeholder="filter projects… (/ to focus)", id="filter")
|
|
45
|
-
with Horizontal(id="main"):
|
|
46
|
-
with Vertical(id="projects-panel"):
|
|
47
|
-
yield Static("projects", classes="panel-title")
|
|
48
|
-
yield OptionList(id="project-list")
|
|
49
|
-
with Vertical(id="detail-panel"):
|
|
50
|
-
yield Static("detail", classes="panel-title")
|
|
51
|
-
yield Static("Select a project", id="detail")
|
|
52
|
-
yield Static("", id="status")
|
|
53
|
-
yield Static(
|
|
54
|
-
"↵ open · a agents · b browse · n new · s sessions · h ssh · l aliases · / filter · q quit",
|
|
55
|
-
id="footer-bar",
|
|
56
|
-
)
|
|
57
|
-
yield Footer()
|
|
58
|
-
|
|
59
|
-
def on_mount(self) -> None:
|
|
60
|
-
self._load_projects()
|
|
61
|
-
self.query_one("#filter", Input).focus()
|
|
62
|
-
|
|
63
|
-
def _load_projects(self, query: str = "") -> None:
|
|
64
|
-
self._projects = self.registry.list()
|
|
65
|
-
if query.strip():
|
|
66
|
-
self._filtered = self.registry.fuzzy(query)
|
|
67
|
-
else:
|
|
68
|
-
self._filtered = list(self._projects)
|
|
69
|
-
ol = self.query_one("#project-list", OptionList)
|
|
70
|
-
ol.clear_options()
|
|
71
|
-
if not self._filtered:
|
|
72
|
-
ol.add_option(Option(" (no projects — press n to add)", id="__empty__"))
|
|
73
|
-
self.query_one("#status", Static).update(
|
|
74
|
-
"no projects yet · n new · or: ccmd migrate --apply"
|
|
75
|
-
)
|
|
76
|
-
return
|
|
77
|
-
for p in self._filtered:
|
|
78
|
-
mark = "✓" if p.exists() else "!"
|
|
79
|
-
agent = p.default_agent
|
|
80
|
-
label = f" {mark} {p.name:<20} {agent:<8} {p.path}"
|
|
81
|
-
ol.add_option(Option(label, id=p.id))
|
|
82
|
-
ol.highlighted = 0
|
|
83
|
-
self._update_detail()
|
|
84
|
-
self.query_one("#status", Static).update(
|
|
85
|
-
f"{len(self._filtered)}/{len(self._projects)} projects"
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
def _selected(self) -> Project | None:
|
|
89
|
-
ol = self.query_one("#project-list", OptionList)
|
|
90
|
-
if ol.highlighted is None:
|
|
91
|
-
return None
|
|
92
|
-
try:
|
|
93
|
-
opt = ol.get_option_at_index(ol.highlighted)
|
|
94
|
-
except Exception:
|
|
95
|
-
return None
|
|
96
|
-
if opt.id in (None, "__empty__"):
|
|
97
|
-
return None
|
|
98
|
-
return self.registry.get(str(opt.id))
|
|
99
|
-
|
|
100
|
-
def _update_detail(self) -> None:
|
|
101
|
-
p = self._selected()
|
|
102
|
-
detail = self.query_one("#detail", Static)
|
|
103
|
-
if not p:
|
|
104
|
-
detail.update("Select a project")
|
|
105
|
-
return
|
|
106
|
-
agent = get_agent(p.default_agent)
|
|
107
|
-
agent_line = (
|
|
108
|
-
f"{agent.ascii_logo} {agent.display_name}"
|
|
109
|
-
if agent
|
|
110
|
-
else p.default_agent
|
|
111
|
-
)
|
|
112
|
-
exists = "yes" if p.exists() else "MISSING"
|
|
113
|
-
lines = [
|
|
114
|
-
f"[bold #00ffc8]{p.name}[/]",
|
|
115
|
-
f"id {p.id}",
|
|
116
|
-
f"path {p.path}",
|
|
117
|
-
f"exists {exists}",
|
|
118
|
-
f"agent {agent_line}",
|
|
119
|
-
f"ssh {p.ssh_host or '—'}",
|
|
120
|
-
f"env {', '.join(p.env_files) or '—'}",
|
|
121
|
-
f"tags {', '.join(p.tags) or '—'}",
|
|
122
|
-
f"opened {p.last_opened_at or 'never'}",
|
|
123
|
-
"",
|
|
124
|
-
"[#6a6a80]enter open · a choose agent · d detach[/]",
|
|
125
|
-
]
|
|
126
|
-
detail.update("\n".join(lines))
|
|
127
|
-
|
|
128
|
-
@on(Input.Changed, "#filter")
|
|
129
|
-
def on_filter(self, event: Input.Changed) -> None:
|
|
130
|
-
self._load_projects(event.value)
|
|
131
|
-
|
|
132
|
-
@on(OptionList.OptionHighlighted)
|
|
133
|
-
def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
|
|
134
|
-
self._update_detail()
|
|
135
|
-
|
|
136
|
-
@on(OptionList.OptionSelected)
|
|
137
|
-
def on_select(self, event: OptionList.OptionSelected) -> None:
|
|
138
|
-
self.action_open_project()
|
|
139
|
-
|
|
140
|
-
def action_focus_filter(self) -> None:
|
|
141
|
-
self.query_one("#filter", Input).focus()
|
|
142
|
-
|
|
143
|
-
def action_refresh(self) -> None:
|
|
144
|
-
q = self.query_one("#filter", Input).value
|
|
145
|
-
self._load_projects(q)
|
|
146
|
-
self.notify("refreshed", severity="information")
|
|
147
|
-
|
|
148
|
-
def action_open_project(self) -> None:
|
|
149
|
-
p = self._selected()
|
|
150
|
-
if not p:
|
|
151
|
-
self.notify("no project selected", severity="warning")
|
|
152
|
-
return
|
|
153
|
-
if not p.exists():
|
|
154
|
-
self.notify(f"path missing: {p.path}", severity="error")
|
|
155
|
-
return
|
|
156
|
-
self._launch(p, p.default_agent, detach=False)
|
|
157
|
-
|
|
158
|
-
def action_pick_agent(self) -> None:
|
|
159
|
-
p = self._selected()
|
|
160
|
-
if not p:
|
|
161
|
-
self.notify("select a project first", severity="warning")
|
|
162
|
-
return
|
|
163
|
-
from ccmd.tui.screens.agents import AgentScreen
|
|
164
|
-
|
|
165
|
-
def _done(agent_id: str | None) -> None:
|
|
166
|
-
if agent_id:
|
|
167
|
-
self._launch(p, agent_id, detach=False)
|
|
168
|
-
|
|
169
|
-
self.app.push_screen(AgentScreen(project=p), _done)
|
|
170
|
-
|
|
171
|
-
def action_new_project(self) -> None:
|
|
172
|
-
from ccmd.tui.screens.project_edit import ProjectEditScreen
|
|
173
|
-
|
|
174
|
-
def _done(ok: bool | None) -> None:
|
|
175
|
-
if ok:
|
|
176
|
-
self._load_projects(self.query_one("#filter", Input).value)
|
|
177
|
-
|
|
178
|
-
self.app.push_screen(ProjectEditScreen(), _done)
|
|
179
|
-
|
|
180
|
-
def action_browse(self) -> None:
|
|
181
|
-
from ccmd.tui.screens.browser import DirBrowserScreen
|
|
182
|
-
|
|
183
|
-
def _done(ok: bool | None) -> None:
|
|
184
|
-
if ok:
|
|
185
|
-
self._load_projects(self.query_one("#filter", Input).value)
|
|
186
|
-
|
|
187
|
-
self.app.push_screen(DirBrowserScreen(), _done)
|
|
188
|
-
|
|
189
|
-
def action_sessions(self) -> None:
|
|
190
|
-
from ccmd.tui.screens.sessions import SessionsScreen
|
|
191
|
-
|
|
192
|
-
self.app.push_screen(SessionsScreen())
|
|
193
|
-
|
|
194
|
-
def action_ssh(self) -> None:
|
|
195
|
-
from ccmd.tui.screens.ssh import SSHScreen
|
|
196
|
-
|
|
197
|
-
self.app.push_screen(SSHScreen())
|
|
198
|
-
|
|
199
|
-
def action_aliases(self) -> None:
|
|
200
|
-
from ccmd.tui.screens.aliases import AliasesScreen
|
|
201
|
-
|
|
202
|
-
self.app.push_screen(AliasesScreen())
|
|
203
|
-
|
|
204
|
-
def action_quit(self) -> None:
|
|
205
|
-
self.app.exit()
|
|
206
|
-
|
|
207
|
-
@work(thread=True)
|
|
208
|
-
def _launch(self, project: Project, agent_id: str, detach: bool) -> None:
|
|
209
|
-
self.app.call_from_thread(
|
|
210
|
-
self.query_one("#status", Static).update,
|
|
211
|
-
f"handing off to {agent_id}…",
|
|
212
|
-
)
|
|
213
|
-
try:
|
|
214
|
-
# replace process — must run on main for exec; use call_from_thread
|
|
215
|
-
def _do() -> None:
|
|
216
|
-
try:
|
|
217
|
-
code = launch_project(
|
|
218
|
-
project, agent_id, detach=detach, replace=not detach
|
|
219
|
-
)
|
|
220
|
-
if detach:
|
|
221
|
-
self.notify(f"launched {agent_id} (detached)", severity="information")
|
|
222
|
-
else:
|
|
223
|
-
# if returned, agent exited
|
|
224
|
-
self.notify(f"{agent_id} exited ({code})", severity="information")
|
|
225
|
-
self._load_projects(self.query_one("#filter", Input).value)
|
|
226
|
-
except LaunchError as e:
|
|
227
|
-
self.notify(str(e), severity="error")
|
|
228
|
-
|
|
229
|
-
self.app.call_from_thread(_do)
|
|
230
|
-
except Exception as e:
|
|
231
|
-
self.app.call_from_thread(self.notify, str(e), severity="error")
|
|
232
|
-
|
|
233
|
-
def on_resize(self) -> None:
|
|
234
|
-
banner = self.query_one("#banner", Static)
|
|
235
|
-
if self.size.width < 50:
|
|
236
|
-
banner.update(BANNER_COMPACT)
|
|
237
|
-
else:
|
|
238
|
-
banner.update(BANNER.strip("\n"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|