patchfeld 0.2.2__tar.gz → 0.2.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.
- {patchfeld-0.2.2 → patchfeld-0.2.3}/PKG-INFO +1 -1
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/app.py +29 -1
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/orchestrator/session.py +87 -4
- patchfeld-0.2.3/patchfeld/orchestrator/skills.py +193 -0
- patchfeld-0.2.3/patchfeld/orchestrator/slash_completion.py +247 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/chrome.py +84 -1
- patchfeld-0.2.3/patchfeld/widgets/orchestrator_chat.py +140 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/pyproject.toml +1 -1
- patchfeld-0.2.2/patchfeld/widgets/orchestrator_chat.py +0 -73
- {patchfeld-0.2.2 → patchfeld-0.2.3}/.gitignore +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/LICENSE +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/README.md +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/__main__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/actions.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/activity/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/activity/log.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/child_tools.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/fake_sdk_adapter.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/manager.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/permission_grants.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/permission_inbox.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/request_inbox.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/sdk_adapter.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/session.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/sort.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/agents/state.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/config.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/events.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/custom_widgets.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/defaults.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/engine.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/local_widgets.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/registry.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/spec.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/splitter.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/layout/titles.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/orchestrator/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/orchestrator/formatting.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/orchestrator/tabs_tools.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/orchestrator/tools.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/agents_index.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/atomic.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/layout_store.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/layouts_store.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/orchestrator_sessions.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/paths.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/themes_store.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/transcript_store.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/persistence/workspace_store.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/theme/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/theme/engine.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/theme/spec.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/_file_lang.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/_terminal_keys.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/_terminal_render.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/activity_feed.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/agent_table.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/agent_transcript.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/change_cwd_screen.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/diff_viewer.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/file_editor.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/file_tree.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/file_viewer.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/history_screen.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/layout_switcher.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/log_tail.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/markdown.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/new_tab_screen.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/notebook.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/permission_modal.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/permission_request_bar.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/resume_screen.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/rich_transcript.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/system_usage.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/terminal.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/theme_switcher.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/widgets/transcript_screen.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/workspace/__init__.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/patchfeld/workspace/spec.py +0 -0
- {patchfeld-0.2.2 → patchfeld-0.2.3}/website/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchfeld
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: A Textual TUI for managing multiple Claude Code agent sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/jimmymills/patchfeld
|
|
6
6
|
Project-URL: Repository, https://github.com/jimmymills/patchfeld
|
|
@@ -23,6 +23,8 @@ from patchfeld.layout.local_widgets import LocalWidgetLoader, LoadOutcome
|
|
|
23
23
|
from patchfeld.layout.registry import WidgetRegistry
|
|
24
24
|
from patchfeld.layout.spec import LayoutSpec
|
|
25
25
|
from patchfeld.orchestrator.session import OrchestratorSession
|
|
26
|
+
from patchfeld.orchestrator.skills import SkillsIndex, default_skills_index
|
|
27
|
+
from patchfeld.orchestrator.slash_completion import SlashCompleter
|
|
26
28
|
from patchfeld.persistence.layouts_store import NamedLayoutsStore
|
|
27
29
|
from patchfeld.persistence.themes_store import NamedThemesStore
|
|
28
30
|
from patchfeld.persistence.paths import global_config_dir, local_widgets_dir
|
|
@@ -340,6 +342,27 @@ class PatchfeldApp(App):
|
|
|
340
342
|
# Unsub callable for the PermissionRequested handler; None when bypass mode.
|
|
341
343
|
self._unsub_permission_requested: "callable | None" = None
|
|
342
344
|
|
|
345
|
+
# Discover locally-installed skills once at app construction. The
|
|
346
|
+
# set is reused on every orchestrator rebuild (after /cd or
|
|
347
|
+
# /bypass-permissions) so the user sees a stable list of `/<skill>`
|
|
348
|
+
# commands across sessions. To pick up a newly-installed skill,
|
|
349
|
+
# restart the app — same as the widget loader.
|
|
350
|
+
from patchfeld.orchestrator.session import (
|
|
351
|
+
_BUILTIN_COMMAND_NAMES as _ORCH_BUILTINS,
|
|
352
|
+
)
|
|
353
|
+
self._skills_index: SkillsIndex = default_skills_index(
|
|
354
|
+
builtin_command_names=_ORCH_BUILTINS,
|
|
355
|
+
)
|
|
356
|
+
# Tab-completion engine for `/`-prefixed commands. Both the top
|
|
357
|
+
# CommandBar and any OrchestratorChat input read this attribute
|
|
358
|
+
# via `getattr(self.app, "slash_completer", None)`. It's a snapshot
|
|
359
|
+
# taken once here so the candidate list is stable across orchestrator
|
|
360
|
+
# rebuilds (mirrors how _skills_index is reused above).
|
|
361
|
+
self.slash_completer: SlashCompleter = SlashCompleter.build(
|
|
362
|
+
builtin_commands=_ORCH_BUILTINS,
|
|
363
|
+
skills_index=self._skills_index,
|
|
364
|
+
)
|
|
365
|
+
|
|
343
366
|
self.manager = manager or AgentManager(
|
|
344
367
|
cwd=self.cwd,
|
|
345
368
|
bus=self.event_bus,
|
|
@@ -360,6 +383,7 @@ class PatchfeldApp(App):
|
|
|
360
383
|
current_layout=lambda: self._active_layout(),
|
|
361
384
|
app=self,
|
|
362
385
|
permission_grants=self._permission_grants,
|
|
386
|
+
skills=self._skills_index,
|
|
363
387
|
)
|
|
364
388
|
# Production opts in to LLM-summarized session titles.
|
|
365
389
|
self.orchestrator._auto_title_enabled = True
|
|
@@ -1071,6 +1095,7 @@ class PatchfeldApp(App):
|
|
|
1071
1095
|
current_layout=lambda: self._active_layout(),
|
|
1072
1096
|
app=self,
|
|
1073
1097
|
permission_grants=self._permission_grants,
|
|
1098
|
+
skills=self._skills_index,
|
|
1074
1099
|
)
|
|
1075
1100
|
self.orchestrator._auto_title_enabled = True
|
|
1076
1101
|
await self.orchestrator.start()
|
|
@@ -1169,6 +1194,7 @@ class PatchfeldApp(App):
|
|
|
1169
1194
|
current_layout=lambda: self._active_layout(),
|
|
1170
1195
|
app=self,
|
|
1171
1196
|
permission_grants=self._permission_grants,
|
|
1197
|
+
skills=self._skills_index,
|
|
1172
1198
|
)
|
|
1173
1199
|
self.orchestrator._auto_title_enabled = True
|
|
1174
1200
|
await self.orchestrator.start()
|
|
@@ -1341,7 +1367,9 @@ class PatchfeldApp(App):
|
|
|
1341
1367
|
# --- composition & lifecycle -------------------------------------------
|
|
1342
1368
|
|
|
1343
1369
|
def compose(self) -> ComposeResult:
|
|
1344
|
-
yield CommandBar(
|
|
1370
|
+
yield CommandBar(
|
|
1371
|
+
event_bus=self.event_bus, slash_completer=self.slash_completer,
|
|
1372
|
+
)
|
|
1345
1373
|
yield TabbedContent(id="app-tabs")
|
|
1346
1374
|
yield StatusBar(event_bus=self.event_bus)
|
|
1347
1375
|
|
|
@@ -38,6 +38,7 @@ from patchfeld.events import (
|
|
|
38
38
|
UserMessageToOrchestrator,
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
from patchfeld.orchestrator.skills import SkillsIndex
|
|
41
42
|
from patchfeld.orchestrator.tools import build_orchestrator_mcp_server
|
|
42
43
|
from patchfeld.persistence.orchestrator_sessions import (
|
|
43
44
|
OrchestratorSessionEntry,
|
|
@@ -58,8 +59,23 @@ _HELP_RE = re.compile(r"^/help\s*$")
|
|
|
58
59
|
_CD_RE = re.compile(r"^/cd\s+(.+?)\s*$")
|
|
59
60
|
_BYPASS_PERMS_RE = re.compile(r"^/bypass-permissions\s*$")
|
|
60
61
|
_REQUIRE_PERMS_RE = re.compile(r"^/require-permissions\s*$")
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
# /<skill-name> <args...> — dispatch order is: built-in first, then skill,
|
|
63
|
+
# then unknown-slash error. Name alphabet matches `_SKILL_NAME_RE` in
|
|
64
|
+
# `patchfeld.orchestrator.skills`, anchored so we never match accidental
|
|
65
|
+
# leading-slash text like "/path/to/file".
|
|
66
|
+
_SKILL_RE = re.compile(r"^/([A-Za-z0-9][A-Za-z0-9_\-]*)(?:\s+(.*))?$")
|
|
67
|
+
|
|
68
|
+
# Names of all built-in slash commands. Used for two purposes:
|
|
69
|
+
# 1) Skill discovery logs a warning when a skill collides with one of these.
|
|
70
|
+
# 2) An "unknown slash command" reply must NOT fire for these — but in
|
|
71
|
+
# practice the explicit per-command regexes already handle them, so this
|
|
72
|
+
# list is informational. Keep it in sync with the regexes above.
|
|
73
|
+
_BUILTIN_COMMAND_NAMES: frozenset[str] = frozenset({
|
|
74
|
+
"reset", "resume", "rename", "help", "cd",
|
|
75
|
+
"bypass-permissions", "require-permissions",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
_HELP_TEXT_BASE = (
|
|
63
79
|
"Available commands:\n"
|
|
64
80
|
" /reset Start a fresh orchestrator session\n"
|
|
65
81
|
" /resume [<session_id>] Resume a past session (no arg → picker)\n"
|
|
@@ -70,6 +86,42 @@ _HELP_TEXT = (
|
|
|
70
86
|
" /help Show this list"
|
|
71
87
|
)
|
|
72
88
|
|
|
89
|
+
|
|
90
|
+
def _format_skill_invocation(name: str, args: str) -> str:
|
|
91
|
+
"""Translate `/<skill> <args>` into a prose prompt that nudges the LLM
|
|
92
|
+
to call the `Skill` tool with `skill=<name>` and `args=<args>`.
|
|
93
|
+
|
|
94
|
+
Implementation note: the harness's `Skill` tool uses two parameters —
|
|
95
|
+
`skill` (the bare name) and `args` (a free-form string). We surface both
|
|
96
|
+
explicitly. We also tell the model to invoke the skill *immediately* so
|
|
97
|
+
it doesn't ask clarifying questions before reading the SKILL.md body.
|
|
98
|
+
"""
|
|
99
|
+
if args:
|
|
100
|
+
return (
|
|
101
|
+
f"Use the `Skill` tool to invoke the `{name}` skill now. "
|
|
102
|
+
f"Pass the following text as the `args` parameter: {args}"
|
|
103
|
+
)
|
|
104
|
+
return (
|
|
105
|
+
f"Use the `Skill` tool to invoke the `{name}` skill now "
|
|
106
|
+
f"(no extra arguments)."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_help_text(skills: SkillsIndex) -> str:
|
|
111
|
+
"""Compose the /help reply: built-in commands plus a `Skills:` section
|
|
112
|
+
listing discovered skill names. The skills section is omitted entirely
|
|
113
|
+
when the index is empty so the output stays clean on bare installs."""
|
|
114
|
+
text = _HELP_TEXT_BASE
|
|
115
|
+
names = skills.names()
|
|
116
|
+
if not names:
|
|
117
|
+
return text
|
|
118
|
+
skills_line = "Skills: " + ", ".join(f"/{n}" for n in names)
|
|
119
|
+
note = (
|
|
120
|
+
" (run any with `/<name> <args>` to invoke. Built-in commands above "
|
|
121
|
+
"win on name collisions — collided skills are still reachable via prose.)"
|
|
122
|
+
)
|
|
123
|
+
return text + "\n\n" + skills_line + "\n" + note
|
|
124
|
+
|
|
73
125
|
_TITLE_PROMPT = (
|
|
74
126
|
"Summarize the following user message in 5-7 words for use as a session "
|
|
75
127
|
"title. Respond with ONLY the title — no quotes, no punctuation, no "
|
|
@@ -137,6 +189,7 @@ class OrchestratorSession:
|
|
|
137
189
|
current_layout=None,
|
|
138
190
|
app=None,
|
|
139
191
|
permission_grants: PermissionGrants | None = None,
|
|
192
|
+
skills: SkillsIndex | None = None,
|
|
140
193
|
) -> None:
|
|
141
194
|
self._cwd = cwd
|
|
142
195
|
self._bus = bus
|
|
@@ -179,6 +232,9 @@ class OrchestratorSession:
|
|
|
179
232
|
self._auto_title_enabled: bool = False
|
|
180
233
|
self._title_task: asyncio.Task | None = None
|
|
181
234
|
self._grants = permission_grants
|
|
235
|
+
# Skills registry (immutable for the lifetime of the session). Empty
|
|
236
|
+
# default keeps tests and headless callers from having to pass one.
|
|
237
|
+
self._skills: SkillsIndex = skills if skills is not None else SkillsIndex()
|
|
182
238
|
self._can_use_tool_callback: CanUseTool | None = None
|
|
183
239
|
if permission_grants is not None:
|
|
184
240
|
self._perm_inbox: PermissionInbox | None = PermissionInbox(
|
|
@@ -509,9 +565,36 @@ class OrchestratorSession:
|
|
|
509
565
|
)
|
|
510
566
|
return
|
|
511
567
|
if _HELP_RE.match(text):
|
|
512
|
-
self._publish_notice(
|
|
568
|
+
self._publish_notice(_format_help_text(self._skills))
|
|
569
|
+
return
|
|
570
|
+
# --- skill slash commands (after all built-ins) -------------------
|
|
571
|
+
# Built-ins win on name collisions: by the time we get here, every
|
|
572
|
+
# built-in regex has had its chance, so any `/name ...` left over is
|
|
573
|
+
# either a skill or unknown.
|
|
574
|
+
m = _SKILL_RE.match(text)
|
|
575
|
+
if m:
|
|
576
|
+
name = m.group(1)
|
|
577
|
+
args = (m.group(2) or "").strip()
|
|
578
|
+
entry = self._skills.get(name)
|
|
579
|
+
if entry is not None:
|
|
580
|
+
# Translate (design decision a): rewrite the slash line into a
|
|
581
|
+
# prose prompt that nudges the orchestrator's LLM to invoke
|
|
582
|
+
# the matching `Skill` tool. We don't synthesize a tool_use
|
|
583
|
+
# block ourselves — the SDK has no public seam for that.
|
|
584
|
+
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
585
|
+
rewritten = _format_skill_invocation(name, args)
|
|
586
|
+
task = self._inner.queue_send(rewritten)
|
|
587
|
+
self._send_tasks.append(task)
|
|
588
|
+
return
|
|
589
|
+
# Unknown slash — clear error rather than wasting tokens by
|
|
590
|
+
# forwarding `/<typo>` to the LLM as a raw prompt.
|
|
591
|
+
self._publish_notice(
|
|
592
|
+
f"unknown command or skill: /{name}. Try /help."
|
|
593
|
+
)
|
|
513
594
|
return
|
|
514
|
-
# Fall through: ordinary prompt
|
|
595
|
+
# Fall through: ordinary prompt (no leading slash, or a slash followed
|
|
596
|
+
# by something that doesn't look like a skill name — e.g. the user
|
|
597
|
+
# pasted "/path/to/file" as part of a sentence).
|
|
515
598
|
if self._current_session_first_message is None:
|
|
516
599
|
self._current_session_first_message = text
|
|
517
600
|
self._send_tasks = [t for t in self._send_tasks if not t.done()]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Discovery + indexing of locally-installed Claude Code skills.
|
|
2
|
+
|
|
3
|
+
A "skill" lives at one of:
|
|
4
|
+
|
|
5
|
+
- `~/.claude/skills/<name>/SKILL.md`
|
|
6
|
+
User-installed skills. Highest priority on collisions.
|
|
7
|
+
- `~/.claude/plugins/cache/<plugin>/<version>/skills/<name>/SKILL.md`
|
|
8
|
+
Plugin-shipped skills. Walked second; first occurrence per bare name
|
|
9
|
+
wins (subsequent duplicates log a `collision` warning).
|
|
10
|
+
|
|
11
|
+
The orchestrator uses this index to expose `/<skill-name>` slash commands in
|
|
12
|
+
the chat input. Discovery is performed once at orchestrator-session start
|
|
13
|
+
(see `OrchestratorSession.__init__`) — re-scans require a restart, same as
|
|
14
|
+
the existing widget loader. Per the implementation plan we picked design (a)
|
|
15
|
+
"translate": when the user types `/<skill> <args>`, the orchestrator
|
|
16
|
+
synthesizes a prose prompt that nudges the LLM to invoke the matching `Skill`
|
|
17
|
+
tool. Discovery only needs the skill's *name* (not the body) — we never read
|
|
18
|
+
SKILL.md content here.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Iterable
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Reused for the slash-command dispatch — only names matching this pattern
|
|
32
|
+
# are exposed. Keeping it tight avoids weird shell-escaping edge cases. The
|
|
33
|
+
# orchestrator's _SKILL_RE is anchored on this same alphabet.
|
|
34
|
+
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]*$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SkillEntry:
|
|
39
|
+
"""A discovered skill the orchestrator can route slash commands to."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
"""Bare skill name (the directory name, e.g. `kb-query`)."""
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
"""Absolute path to the `SKILL.md` file. Stored for diagnostics; we don't
|
|
46
|
+
need to read the body to invoke a skill via the `Skill` tool."""
|
|
47
|
+
|
|
48
|
+
source: str
|
|
49
|
+
"""Where the skill was found: `"user"` for `~/.claude/skills/<name>` or
|
|
50
|
+
`"plugin"` for `~/.claude/plugins/cache/.../<name>`. Used for logging
|
|
51
|
+
and for collision-resolution rules (user wins)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SkillsIndex:
|
|
56
|
+
"""Bare-name → SkillEntry, populated by `discover_skills`."""
|
|
57
|
+
|
|
58
|
+
entries: dict[str, SkillEntry] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def get(self, name: str) -> SkillEntry | None:
|
|
61
|
+
return self.entries.get(name)
|
|
62
|
+
|
|
63
|
+
def names(self) -> list[str]:
|
|
64
|
+
"""Lexicographically-sorted skill names. Used by `/help` so output is
|
|
65
|
+
deterministic regardless of filesystem walk order."""
|
|
66
|
+
return sorted(self.entries.keys())
|
|
67
|
+
|
|
68
|
+
def __contains__(self, name: object) -> bool:
|
|
69
|
+
return isinstance(name, str) and name in self.entries
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def discover_skills(
|
|
73
|
+
*,
|
|
74
|
+
user_skills_dir: Path | None,
|
|
75
|
+
plugin_cache_dir: Path | None,
|
|
76
|
+
builtin_command_names: Iterable[str] = (),
|
|
77
|
+
) -> SkillsIndex:
|
|
78
|
+
"""Walk known locations, return a `SkillsIndex`.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
user_skills_dir : Path | None
|
|
83
|
+
Typically `~/.claude/skills`. If None or missing, skipped.
|
|
84
|
+
plugin_cache_dir : Path | None
|
|
85
|
+
Typically `~/.claude/plugins/cache`. If None or missing, skipped.
|
|
86
|
+
builtin_command_names : iterable of str
|
|
87
|
+
Names of orchestrator-internal slash commands (e.g. `cd`, `help`).
|
|
88
|
+
These do NOT cause the skill to be excluded — built-ins win at
|
|
89
|
+
dispatch time and the skill remains reachable via prose. We just
|
|
90
|
+
log a warning here so it's visible in production logs.
|
|
91
|
+
|
|
92
|
+
Notes
|
|
93
|
+
-----
|
|
94
|
+
- Each skill must live in its own directory containing a `SKILL.md` file.
|
|
95
|
+
We do not read the SKILL.md frontmatter — Claude Code's `Skill` tool
|
|
96
|
+
uses the directory name as the canonical identifier, and that's what
|
|
97
|
+
the orchestrator passes through.
|
|
98
|
+
- Walk order: user dir first (so user-installed copies win on collision),
|
|
99
|
+
then plugin cache. The plugin walk is bounded to depth 4 from the
|
|
100
|
+
cache root: `<plugin>/<version>/skills/<name>/SKILL.md`.
|
|
101
|
+
- Plugin-namespaced skills (e.g. `Notion:search`) are exposed under
|
|
102
|
+
their bare name (`search`). On collisions the first occurrence wins
|
|
103
|
+
— typically driven by directory iteration order, which is filesystem
|
|
104
|
+
dependent. A warning identifies the loser so a user can rename if
|
|
105
|
+
they care which copy is reachable via slash.
|
|
106
|
+
"""
|
|
107
|
+
builtin_set = set(builtin_command_names)
|
|
108
|
+
entries: dict[str, SkillEntry] = {}
|
|
109
|
+
|
|
110
|
+
def _try_add(name: str, path: Path, source: str) -> None:
|
|
111
|
+
if not _SKILL_NAME_RE.match(name):
|
|
112
|
+
log.debug("skipping skill with non-slash-safe name: %r at %s",
|
|
113
|
+
name, path)
|
|
114
|
+
return
|
|
115
|
+
if name in entries:
|
|
116
|
+
existing = entries[name]
|
|
117
|
+
log.warning(
|
|
118
|
+
"skill name collision: %r (kept %s copy at %s; "
|
|
119
|
+
"ignored %s copy at %s)",
|
|
120
|
+
name, existing.source, existing.path, source, path,
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
if name in builtin_set:
|
|
124
|
+
log.warning(
|
|
125
|
+
"skill name %r collides with a built-in slash command; the "
|
|
126
|
+
"built-in wins at dispatch but the skill remains reachable "
|
|
127
|
+
"via prose. (path=%s, source=%s)",
|
|
128
|
+
name, path, source,
|
|
129
|
+
)
|
|
130
|
+
entries[name] = SkillEntry(
|
|
131
|
+
name=name, path=str(path), source=source,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# --- user dir ---------------------------------------------------------
|
|
135
|
+
if user_skills_dir is not None and user_skills_dir.is_dir():
|
|
136
|
+
for child in sorted(user_skills_dir.iterdir()):
|
|
137
|
+
if not child.is_dir():
|
|
138
|
+
continue
|
|
139
|
+
skill_md = child / "SKILL.md"
|
|
140
|
+
if not skill_md.is_file():
|
|
141
|
+
continue
|
|
142
|
+
_try_add(child.name, skill_md, source="user")
|
|
143
|
+
|
|
144
|
+
# --- plugin cache -----------------------------------------------------
|
|
145
|
+
if plugin_cache_dir is not None and plugin_cache_dir.is_dir():
|
|
146
|
+
# Layout: <cache>/<vendor>/<plugin>/<version>/skills/<skill_name>/SKILL.md
|
|
147
|
+
# Example: ~/.claude/plugins/cache/claude-plugins-official/superpowers/
|
|
148
|
+
# 5.1.0/skills/writing-plans/SKILL.md
|
|
149
|
+
# Per-plugin we pick the *highest lexicographic version directory*
|
|
150
|
+
# — for typical semver this lines up with the latest release (5.1.0
|
|
151
|
+
# > 5.0.7 lexicographically). Edge cases (5.10.0 vs 5.2.0) sort
|
|
152
|
+
# incorrectly under this rule; that's a known v1 limitation, and
|
|
153
|
+
# the workaround is to delete stale cache versions.
|
|
154
|
+
for vendor_dir in sorted(plugin_cache_dir.iterdir()):
|
|
155
|
+
if not vendor_dir.is_dir():
|
|
156
|
+
continue
|
|
157
|
+
for plugin_dir in sorted(vendor_dir.iterdir()):
|
|
158
|
+
if not plugin_dir.is_dir():
|
|
159
|
+
continue
|
|
160
|
+
version_dirs = sorted(
|
|
161
|
+
[d for d in plugin_dir.iterdir() if d.is_dir()],
|
|
162
|
+
)
|
|
163
|
+
if not version_dirs:
|
|
164
|
+
continue
|
|
165
|
+
# Highest version wins.
|
|
166
|
+
version_dir = version_dirs[-1]
|
|
167
|
+
skills_root = version_dir / "skills"
|
|
168
|
+
if not skills_root.is_dir():
|
|
169
|
+
continue
|
|
170
|
+
for skill_dir in sorted(skills_root.iterdir()):
|
|
171
|
+
if not skill_dir.is_dir():
|
|
172
|
+
continue
|
|
173
|
+
skill_md = skill_dir / "SKILL.md"
|
|
174
|
+
if not skill_md.is_file():
|
|
175
|
+
continue
|
|
176
|
+
_try_add(skill_dir.name, skill_md, source="plugin")
|
|
177
|
+
|
|
178
|
+
log.info("discovered %d skill(s): %s",
|
|
179
|
+
len(entries), sorted(entries.keys()))
|
|
180
|
+
return SkillsIndex(entries=entries)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def default_skills_index(builtin_command_names: Iterable[str] = ()) -> SkillsIndex:
|
|
184
|
+
"""Convenience wrapper that scans the canonical user paths.
|
|
185
|
+
|
|
186
|
+
Used by production wiring; tests pass a hand-built `SkillsIndex` instead.
|
|
187
|
+
"""
|
|
188
|
+
home = Path.home()
|
|
189
|
+
return discover_skills(
|
|
190
|
+
user_skills_dir=home / ".claude" / "skills",
|
|
191
|
+
plugin_cache_dir=home / ".claude" / "plugins" / "cache",
|
|
192
|
+
builtin_command_names=builtin_command_names,
|
|
193
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Tab completion for `/`-prefixed slash commands in chat input boxes.
|
|
2
|
+
|
|
3
|
+
Both the top `CommandBar` and the `OrchestratorChat` input use this helper to
|
|
4
|
+
turn a Tab keypress into an in-place completion against the union of:
|
|
5
|
+
|
|
6
|
+
- the orchestrator's built-in slash commands (`/help`, `/cd`, ...) — i.e.
|
|
7
|
+
the same names that drive `_BUILTIN_COMMAND_NAMES` in
|
|
8
|
+
`patchfeld.orchestrator.session`, AND
|
|
9
|
+
- every locally-installed skill discovered by
|
|
10
|
+
`patchfeld.orchestrator.skills.discover_skills`.
|
|
11
|
+
|
|
12
|
+
The candidate set is a snapshot taken at orchestrator-session-start (matching
|
|
13
|
+
how the slash-dispatch path freezes the same set), so the same `SkillsIndex`
|
|
14
|
+
is reused — discovery is *not* duplicated.
|
|
15
|
+
|
|
16
|
+
Behaviour (v1):
|
|
17
|
+
|
|
18
|
+
- First Tab on `/<prefix>` completes to the first alphabetical match plus
|
|
19
|
+
a single trailing space; cursor goes to end-of-text.
|
|
20
|
+
- Repeated Tabs without intervening edits cycle through subsequent matches
|
|
21
|
+
(Shift+Tab cycles backward). After the last, wrap.
|
|
22
|
+
- Any text edit (typing or Backspace) breaks the cycle anchor — the next
|
|
23
|
+
Tab starts a fresh cycle from the new prefix.
|
|
24
|
+
- No completion is offered when the input does not start with `/`, is
|
|
25
|
+
empty, or contains a space outside an active cycle (so `/cd /Use<Tab>`
|
|
26
|
+
falls through to Textual's default Tab focus traversal — path
|
|
27
|
+
completion is out of scope).
|
|
28
|
+
- Cycle state is per-widget (keyed by an opaque string) so two inputs do
|
|
29
|
+
not interfere.
|
|
30
|
+
|
|
31
|
+
No dropdown UI in v1; the input value just changes in place.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Iterable
|
|
38
|
+
|
|
39
|
+
from textual.suggester import Suggester
|
|
40
|
+
|
|
41
|
+
from patchfeld.orchestrator.skills import SkillsIndex
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CompletionResult:
|
|
46
|
+
"""Outcome of a single Tab press the input widget should apply.
|
|
47
|
+
|
|
48
|
+
Attributes
|
|
49
|
+
----------
|
|
50
|
+
text:
|
|
51
|
+
The full new value of the input box (already includes leading slash
|
|
52
|
+
and trailing space).
|
|
53
|
+
cursor:
|
|
54
|
+
Position to move the input cursor to. Always end-of-text in v1.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
text: str
|
|
58
|
+
cursor: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SlashCompleter:
|
|
63
|
+
"""Per-session completion engine.
|
|
64
|
+
|
|
65
|
+
Construct via :meth:`build` so the candidate list is computed once,
|
|
66
|
+
sorted, and deduped. The instance is shared across input widgets — cycle
|
|
67
|
+
state is partitioned by `key` (typically the Input widget's id).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
_candidates: tuple[str, ...]
|
|
71
|
+
"""Bare command names (without leading slash), sorted alphabetically."""
|
|
72
|
+
|
|
73
|
+
_cycle_state: dict[str, dict] = field(default_factory=dict)
|
|
74
|
+
"""Per-key snapshot of the active cycle.
|
|
75
|
+
|
|
76
|
+
Shape: `{key: {"matches": [...], "index": int, "last_set": str}}`. The
|
|
77
|
+
`last_set` value is the full text we last wrote into the widget — when
|
|
78
|
+
the next Tab arrives we compare to detect "user did not edit between
|
|
79
|
+
presses", which is what licences cycling instead of restarting.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def build(
|
|
84
|
+
cls,
|
|
85
|
+
*,
|
|
86
|
+
builtin_commands: Iterable[str],
|
|
87
|
+
skills_index: SkillsIndex | None = None,
|
|
88
|
+
) -> "SlashCompleter":
|
|
89
|
+
"""Snapshot the candidate set.
|
|
90
|
+
|
|
91
|
+
Names from `builtin_commands` and `skills_index.names()` are merged
|
|
92
|
+
(set-union for dedupe) and sorted alphabetically. Pass an empty
|
|
93
|
+
index for tests/headless callers.
|
|
94
|
+
"""
|
|
95
|
+
names: set[str] = set(builtin_commands)
|
|
96
|
+
if skills_index is not None:
|
|
97
|
+
names.update(skills_index.names())
|
|
98
|
+
return cls(_candidates=tuple(sorted(names)))
|
|
99
|
+
|
|
100
|
+
# --- read-only inspection --------------------------------------------
|
|
101
|
+
|
|
102
|
+
def candidates(self) -> tuple[str, ...]:
|
|
103
|
+
"""Bare names, sorted. Useful for diagnostics; widgets call `match`
|
|
104
|
+
or `cycle` instead."""
|
|
105
|
+
return self._candidates
|
|
106
|
+
|
|
107
|
+
# --- pure prefix matching --------------------------------------------
|
|
108
|
+
|
|
109
|
+
def match(self, prefix: str) -> list[str]:
|
|
110
|
+
"""Return commands (with leading slash) whose name starts with
|
|
111
|
+
`prefix`, case-insensitively. `prefix` must include the leading
|
|
112
|
+
slash; pass `/` to retrieve every candidate.
|
|
113
|
+
|
|
114
|
+
Returns `[]` if `prefix` does not start with `/` so callers can
|
|
115
|
+
delegate the "is this even a slash" check to one place.
|
|
116
|
+
"""
|
|
117
|
+
if not prefix.startswith("/"):
|
|
118
|
+
return []
|
|
119
|
+
pfx_lower = prefix.lower()
|
|
120
|
+
return [
|
|
121
|
+
f"/{name}"
|
|
122
|
+
for name in self._candidates
|
|
123
|
+
if f"/{name}".lower().startswith(pfx_lower)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# --- cycle bookkeeping -----------------------------------------------
|
|
127
|
+
|
|
128
|
+
def reset(self, key: str) -> None:
|
|
129
|
+
"""Forget any cycle state for `key`. Inputs call this on edit
|
|
130
|
+
events (typing, Backspace, value programmatically cleared) so the
|
|
131
|
+
next Tab starts fresh."""
|
|
132
|
+
self._cycle_state.pop(key, None)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _is_fresh_trigger(text: str) -> bool:
|
|
136
|
+
"""A Tab press starts a fresh cycle only when the value looks like
|
|
137
|
+
a single command word: leading `/`, no whitespace anywhere. Empty
|
|
138
|
+
strings and free-form prose return False so the binding falls
|
|
139
|
+
through to Textual's default Tab focus traversal."""
|
|
140
|
+
if not text.startswith("/"):
|
|
141
|
+
return False
|
|
142
|
+
# Any whitespace (including the trailing-space we ourselves write
|
|
143
|
+
# after a completion) disqualifies a fresh trigger; the cycle path
|
|
144
|
+
# below recognises our own writes via `last_set` so the user can
|
|
145
|
+
# still continue an in-flight cycle.
|
|
146
|
+
return not any(ch.isspace() for ch in text)
|
|
147
|
+
|
|
148
|
+
def cycle(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
key: str,
|
|
152
|
+
current_text: str,
|
|
153
|
+
direction: int = 1,
|
|
154
|
+
) -> CompletionResult | None:
|
|
155
|
+
"""Compute the next completion for input identified by `key`.
|
|
156
|
+
|
|
157
|
+
Returns ``None`` when the keypress should fall through to Textual's
|
|
158
|
+
default Tab handling — the widget reads ``None`` as "I'm not
|
|
159
|
+
consuming this Tab".
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
key:
|
|
164
|
+
Stable identifier for the input widget (typically its DOM id).
|
|
165
|
+
current_text:
|
|
166
|
+
The input's current value.
|
|
167
|
+
direction:
|
|
168
|
+
``+1`` to advance through matches (Tab); ``-1`` to step back
|
|
169
|
+
(Shift+Tab).
|
|
170
|
+
"""
|
|
171
|
+
# --- continuing an in-flight cycle? ---
|
|
172
|
+
state = self._cycle_state.get(key)
|
|
173
|
+
if state is not None and state.get("last_set") == current_text:
|
|
174
|
+
matches: list[str] = state["matches"]
|
|
175
|
+
if not matches:
|
|
176
|
+
return None
|
|
177
|
+
new_idx = (state["index"] + direction) % len(matches)
|
|
178
|
+
return self._record_and_emit(key, matches, new_idx)
|
|
179
|
+
|
|
180
|
+
# --- otherwise, must look like a fresh `/<word>` trigger ---
|
|
181
|
+
if not self._is_fresh_trigger(current_text):
|
|
182
|
+
self._cycle_state.pop(key, None)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
matches = self.match(current_text)
|
|
186
|
+
if not matches:
|
|
187
|
+
self._cycle_state.pop(key, None)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Forward starts at index 0; backward starts at the last match so
|
|
191
|
+
# Shift+Tab on a fresh trigger jumps to the alphabetical tail (the
|
|
192
|
+
# natural inverse of forward-from-zero).
|
|
193
|
+
start_idx = 0 if direction >= 0 else len(matches) - 1
|
|
194
|
+
return self._record_and_emit(key, matches, start_idx)
|
|
195
|
+
|
|
196
|
+
def _record_and_emit(
|
|
197
|
+
self, key: str, matches: list[str], idx: int,
|
|
198
|
+
) -> CompletionResult:
|
|
199
|
+
"""Persist cycle state and return the rendered completion. Always
|
|
200
|
+
appends a single trailing space — every slash command can take args,
|
|
201
|
+
so the unconditional space is the cheapest right-default."""
|
|
202
|
+
chosen = matches[idx]
|
|
203
|
+
new_text = chosen + " "
|
|
204
|
+
self._cycle_state[key] = {
|
|
205
|
+
"matches": matches,
|
|
206
|
+
"index": idx,
|
|
207
|
+
"last_set": new_text,
|
|
208
|
+
}
|
|
209
|
+
return CompletionResult(text=new_text, cursor=len(new_text))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class SlashSuggester(Suggester):
|
|
213
|
+
"""Textual `Suggester` adapter exposing a `SlashCompleter`'s first
|
|
214
|
+
match as in-input ghost text.
|
|
215
|
+
|
|
216
|
+
The Textual `Input` widget polls a `Suggester` whenever its value
|
|
217
|
+
changes; if `get_suggestion(value)` returns a string that has `value`
|
|
218
|
+
as a (case-insensitive) prefix, Input renders the suffix in a faded
|
|
219
|
+
color after the cursor — the user sees what Tab is about to fill.
|
|
220
|
+
|
|
221
|
+
We share `SlashCompleter` rather than carry our own candidate set so
|
|
222
|
+
the preview cannot drift from the Tab-completion behaviour: identical
|
|
223
|
+
trigger predicate, identical match function, identical sort order →
|
|
224
|
+
the previewed command is always the very command Tab will fill.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, completer: SlashCompleter) -> None:
|
|
228
|
+
# use_cache=False because the underlying SkillsIndex is already a
|
|
229
|
+
# cheap dict lookup — caching adds nothing — and disabling it
|
|
230
|
+
# avoids surprising staleness if anyone ever swaps the completer
|
|
231
|
+
# at runtime. case_sensitive=False so the user sees a sensible
|
|
232
|
+
# preview when they type `/K` and the canonical command is
|
|
233
|
+
# lowercase (`/kb-query`).
|
|
234
|
+
super().__init__(use_cache=False, case_sensitive=False)
|
|
235
|
+
self._completer = completer
|
|
236
|
+
|
|
237
|
+
async def get_suggestion(self, value: str) -> str | None:
|
|
238
|
+
# Re-use the same trigger predicate that gates the Tab handler so
|
|
239
|
+
# the preview can never appear in positions where Tab is a no-op
|
|
240
|
+
# (mid-argument, no leading slash, trailing space after a fresh
|
|
241
|
+
# completion, …).
|
|
242
|
+
if not SlashCompleter._is_fresh_trigger(value):
|
|
243
|
+
return None
|
|
244
|
+
matches = self._completer.match(value)
|
|
245
|
+
if not matches:
|
|
246
|
+
return None
|
|
247
|
+
return matches[0]
|
|
@@ -6,6 +6,7 @@ from textual.containers import Horizontal
|
|
|
6
6
|
from textual.widgets import Input, Static
|
|
7
7
|
|
|
8
8
|
from patchfeld.events import EventBus, OrchestratorReply, UserMessageToOrchestrator
|
|
9
|
+
from patchfeld.orchestrator.slash_completion import SlashCompleter, SlashSuggester
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _format_cwd(path: Path, *, available_width: int) -> str:
|
|
@@ -54,9 +55,19 @@ class CommandBar(Horizontal):
|
|
|
54
55
|
}
|
|
55
56
|
"""
|
|
56
57
|
|
|
57
|
-
def __init__(
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
event_bus: EventBus | None = None,
|
|
62
|
+
slash_completer: SlashCompleter | None = None,
|
|
63
|
+
) -> None:
|
|
58
64
|
super().__init__()
|
|
59
65
|
self._bus = event_bus
|
|
66
|
+
# Optional injected completer. When unset we fall back to the host
|
|
67
|
+
# app's `slash_completer` attribute on first use — production wiring
|
|
68
|
+
# exposes one there at app construction so newly-mounted CommandBars
|
|
69
|
+
# share the same snapshot across `/cd` rebuilds.
|
|
70
|
+
self._completer = slash_completer
|
|
60
71
|
# True between a command-bar submit and the next OrchestratorReply.
|
|
61
72
|
# Gates the toast so replies from other input surfaces (the
|
|
62
73
|
# orchestrator chat panel) don't pop a toast as well.
|
|
@@ -74,6 +85,18 @@ class CommandBar(Horizontal):
|
|
|
74
85
|
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
75
86
|
if bus is not None:
|
|
76
87
|
self._unsub_reply = bus.subscribe(OrchestratorReply, self._on_reply)
|
|
88
|
+
# Attach the ghost-text suggester after mount so we can fall back to
|
|
89
|
+
# `app.slash_completer` when no completer was injected at construction.
|
|
90
|
+
# Set every time on_mount fires (theme/cwd swap rebuilds the widget)
|
|
91
|
+
# so the suggester always points at the live completer instance.
|
|
92
|
+
completer = self._resolve_completer()
|
|
93
|
+
if completer is not None:
|
|
94
|
+
try:
|
|
95
|
+
self.query_one("#cmd-input", Input).suggester = (
|
|
96
|
+
SlashSuggester(completer)
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
77
100
|
|
|
78
101
|
def on_unmount(self) -> None:
|
|
79
102
|
self._unsub_reply()
|
|
@@ -81,6 +104,66 @@ class CommandBar(Horizontal):
|
|
|
81
104
|
def focus_input(self) -> None:
|
|
82
105
|
self.query_one("#cmd-input", Input).focus()
|
|
83
106
|
|
|
107
|
+
def _resolve_completer(self) -> SlashCompleter | None:
|
|
108
|
+
"""Late-bound completer lookup: prefer the constructor-injected one,
|
|
109
|
+
fall back to whatever the host app exposes. Returning None disables
|
|
110
|
+
completion entirely (Tab falls through)."""
|
|
111
|
+
if self._completer is not None:
|
|
112
|
+
return self._completer
|
|
113
|
+
return getattr(self.app, "slash_completer", None)
|
|
114
|
+
|
|
115
|
+
def on_key(self, event) -> None:
|
|
116
|
+
"""Intercept Tab / Shift+Tab to apply slash-command completion in
|
|
117
|
+
place. When completion does not apply (no completer, empty input,
|
|
118
|
+
text without a leading slash, mid-argument), we leave the event
|
|
119
|
+
alone so Textual's default focus traversal still runs."""
|
|
120
|
+
if event.key not in ("tab", "shift+tab"):
|
|
121
|
+
return
|
|
122
|
+
completer = self._resolve_completer()
|
|
123
|
+
if completer is None:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
inp = self.query_one("#cmd-input", Input)
|
|
127
|
+
except Exception:
|
|
128
|
+
return
|
|
129
|
+
# Only intercept when the input owns focus — otherwise we'd shadow
|
|
130
|
+
# Tab traversal between widgets.
|
|
131
|
+
if not inp.has_focus:
|
|
132
|
+
return
|
|
133
|
+
direction = -1 if event.key == "shift+tab" else 1
|
|
134
|
+
result = completer.cycle(
|
|
135
|
+
key=inp.id or "cmd-input",
|
|
136
|
+
current_text=inp.value,
|
|
137
|
+
direction=direction,
|
|
138
|
+
)
|
|
139
|
+
if result is None:
|
|
140
|
+
return # let Tab fall through to focus_next/_previous
|
|
141
|
+
inp.value = result.text
|
|
142
|
+
try:
|
|
143
|
+
inp.cursor_position = result.cursor
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
event.stop()
|
|
147
|
+
event.prevent_default()
|
|
148
|
+
|
|
149
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
150
|
+
"""Reset the completer's cycle state on any user-driven edit. The
|
|
151
|
+
cycle path detects "user did not edit between Tabs" by comparing
|
|
152
|
+
the input value to its own last write — that comparison stays
|
|
153
|
+
correct without an explicit reset, but wiping state here also
|
|
154
|
+
frees memory once the user moves on to a different prefix."""
|
|
155
|
+
if event.input.id != "cmd-input":
|
|
156
|
+
return
|
|
157
|
+
completer = self._resolve_completer()
|
|
158
|
+
if completer is None:
|
|
159
|
+
return
|
|
160
|
+
# Skip resets that fired as a side-effect of our own write — those
|
|
161
|
+
# cases keep the cycle anchor intact so consecutive Tabs advance.
|
|
162
|
+
state = completer._cycle_state.get(event.input.id or "cmd-input")
|
|
163
|
+
if state is not None and state.get("last_set") == event.input.value:
|
|
164
|
+
return
|
|
165
|
+
completer.reset(event.input.id or "cmd-input")
|
|
166
|
+
|
|
84
167
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
85
168
|
if not event.value.strip():
|
|
86
169
|
return
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
from textual.widgets import Input
|
|
5
|
+
|
|
6
|
+
from patchfeld.events import EventBus, UserMessageToOrchestrator
|
|
7
|
+
from patchfeld.orchestrator.slash_completion import SlashCompleter, SlashSuggester
|
|
8
|
+
from patchfeld.widgets.rich_transcript import RichTranscript
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OrchestratorChat(Vertical):
|
|
12
|
+
"""Manager-Claude chat panel: RichTranscript + input box."""
|
|
13
|
+
|
|
14
|
+
AGENT_ID = "orchestrator"
|
|
15
|
+
DEFAULT_BORDER_TITLE = "Orchestrator"
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
OrchestratorChat {
|
|
19
|
+
border: round $primary;
|
|
20
|
+
padding: 0 1;
|
|
21
|
+
}
|
|
22
|
+
OrchestratorChat > RichTranscript {
|
|
23
|
+
height: 1fr;
|
|
24
|
+
}
|
|
25
|
+
OrchestratorChat #orch-input {
|
|
26
|
+
dock: bottom;
|
|
27
|
+
height: 3;
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# priority=True so the binding fires even when the Input child has
|
|
32
|
+
# focus — without it, ctrl+c is consumed by Textual's default driver
|
|
33
|
+
# handling (which would quit the app).
|
|
34
|
+
BINDINGS = [
|
|
35
|
+
Binding("ctrl+c", "interrupt", "interrupt orchestrator", priority=True),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, event_bus: EventBus | None = None) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._bus = event_bus
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
path = None
|
|
44
|
+
try:
|
|
45
|
+
orch = getattr(self.app, "orchestrator", None)
|
|
46
|
+
if orch is not None:
|
|
47
|
+
path = orch.active_transcript_path
|
|
48
|
+
except Exception:
|
|
49
|
+
path = None
|
|
50
|
+
yield RichTranscript(
|
|
51
|
+
agent_id=self.AGENT_ID, event_bus=self._bus, transcript_path=path,
|
|
52
|
+
)
|
|
53
|
+
yield Input(
|
|
54
|
+
placeholder=(
|
|
55
|
+
"Message orchestrator… "
|
|
56
|
+
"(/reset, /resume, /rename, /help, ctrl+c to interrupt)"
|
|
57
|
+
),
|
|
58
|
+
id="orch-input",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
"""Attach the ghost-text suggester to the chat input as soon as we
|
|
63
|
+
have access to the host app's `slash_completer`. Re-runs on every
|
|
64
|
+
remount (theme/cwd swap rebuilds this widget) so the suggester
|
|
65
|
+
always points at the live completer instance."""
|
|
66
|
+
completer = self._resolve_completer()
|
|
67
|
+
if completer is not None:
|
|
68
|
+
try:
|
|
69
|
+
self.query_one("#orch-input", Input).suggester = (
|
|
70
|
+
SlashSuggester(completer)
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
76
|
+
if not event.value.strip():
|
|
77
|
+
return
|
|
78
|
+
text = event.value
|
|
79
|
+
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
80
|
+
event.input.value = ""
|
|
81
|
+
if bus is not None:
|
|
82
|
+
bus.publish(UserMessageToOrchestrator(text))
|
|
83
|
+
|
|
84
|
+
def _resolve_completer(self) -> SlashCompleter | None:
|
|
85
|
+
"""Late-bound lookup of the host app's SlashCompleter. None disables
|
|
86
|
+
Tab completion (Tab falls through to default focus traversal)."""
|
|
87
|
+
return getattr(self.app, "slash_completer", None)
|
|
88
|
+
|
|
89
|
+
def on_key(self, event) -> None:
|
|
90
|
+
"""Apply slash-command completion in place when the chat input owns
|
|
91
|
+
focus, the value triggers completion (`/` prefix, no whitespace
|
|
92
|
+
outside an active cycle), and there is at least one match. Falls
|
|
93
|
+
through silently otherwise — Textual's default Tab traversal still
|
|
94
|
+
runs."""
|
|
95
|
+
if event.key not in ("tab", "shift+tab"):
|
|
96
|
+
return
|
|
97
|
+
completer = self._resolve_completer()
|
|
98
|
+
if completer is None:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
inp = self.query_one("#orch-input", Input)
|
|
102
|
+
except Exception:
|
|
103
|
+
return
|
|
104
|
+
if not inp.has_focus:
|
|
105
|
+
return
|
|
106
|
+
direction = -1 if event.key == "shift+tab" else 1
|
|
107
|
+
result = completer.cycle(
|
|
108
|
+
key=inp.id or "orch-input",
|
|
109
|
+
current_text=inp.value,
|
|
110
|
+
direction=direction,
|
|
111
|
+
)
|
|
112
|
+
if result is None:
|
|
113
|
+
return
|
|
114
|
+
inp.value = result.text
|
|
115
|
+
try:
|
|
116
|
+
inp.cursor_position = result.cursor
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
event.stop()
|
|
120
|
+
event.prevent_default()
|
|
121
|
+
|
|
122
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
123
|
+
"""Drop cycle state on edits the user (not us) made — keeps the
|
|
124
|
+
completer's per-widget state from leaking across unrelated prefixes.
|
|
125
|
+
Detection mirrors `CommandBar.on_input_changed`."""
|
|
126
|
+
if event.input.id != "orch-input":
|
|
127
|
+
return
|
|
128
|
+
completer = self._resolve_completer()
|
|
129
|
+
if completer is None:
|
|
130
|
+
return
|
|
131
|
+
state = completer._cycle_state.get(event.input.id or "orch-input")
|
|
132
|
+
if state is not None and state.get("last_set") == event.input.value:
|
|
133
|
+
return
|
|
134
|
+
completer.reset(event.input.id or "orch-input")
|
|
135
|
+
|
|
136
|
+
async def action_interrupt(self) -> None:
|
|
137
|
+
orch = getattr(self.app, "orchestrator", None)
|
|
138
|
+
if orch is None:
|
|
139
|
+
return
|
|
140
|
+
await orch.interrupt()
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.binding import Binding
|
|
3
|
-
from textual.containers import Vertical
|
|
4
|
-
from textual.widgets import Input
|
|
5
|
-
|
|
6
|
-
from patchfeld.events import EventBus, UserMessageToOrchestrator
|
|
7
|
-
from patchfeld.widgets.rich_transcript import RichTranscript
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class OrchestratorChat(Vertical):
|
|
11
|
-
"""Manager-Claude chat panel: RichTranscript + input box."""
|
|
12
|
-
|
|
13
|
-
AGENT_ID = "orchestrator"
|
|
14
|
-
DEFAULT_BORDER_TITLE = "Orchestrator"
|
|
15
|
-
|
|
16
|
-
DEFAULT_CSS = """
|
|
17
|
-
OrchestratorChat {
|
|
18
|
-
border: round $primary;
|
|
19
|
-
padding: 0 1;
|
|
20
|
-
}
|
|
21
|
-
OrchestratorChat > RichTranscript {
|
|
22
|
-
height: 1fr;
|
|
23
|
-
}
|
|
24
|
-
OrchestratorChat #orch-input {
|
|
25
|
-
dock: bottom;
|
|
26
|
-
height: 3;
|
|
27
|
-
}
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
|
-
# priority=True so the binding fires even when the Input child has
|
|
31
|
-
# focus — without it, ctrl+c is consumed by Textual's default driver
|
|
32
|
-
# handling (which would quit the app).
|
|
33
|
-
BINDINGS = [
|
|
34
|
-
Binding("ctrl+c", "interrupt", "interrupt orchestrator", priority=True),
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
def __init__(self, *, event_bus: EventBus | None = None) -> None:
|
|
38
|
-
super().__init__()
|
|
39
|
-
self._bus = event_bus
|
|
40
|
-
|
|
41
|
-
def compose(self) -> ComposeResult:
|
|
42
|
-
path = None
|
|
43
|
-
try:
|
|
44
|
-
orch = getattr(self.app, "orchestrator", None)
|
|
45
|
-
if orch is not None:
|
|
46
|
-
path = orch.active_transcript_path
|
|
47
|
-
except Exception:
|
|
48
|
-
path = None
|
|
49
|
-
yield RichTranscript(
|
|
50
|
-
agent_id=self.AGENT_ID, event_bus=self._bus, transcript_path=path,
|
|
51
|
-
)
|
|
52
|
-
yield Input(
|
|
53
|
-
placeholder=(
|
|
54
|
-
"Message orchestrator… "
|
|
55
|
-
"(/reset, /resume, /rename, /help, ctrl+c to interrupt)"
|
|
56
|
-
),
|
|
57
|
-
id="orch-input",
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
61
|
-
if not event.value.strip():
|
|
62
|
-
return
|
|
63
|
-
text = event.value
|
|
64
|
-
bus = self._bus or getattr(self.app, "event_bus", None)
|
|
65
|
-
event.input.value = ""
|
|
66
|
-
if bus is not None:
|
|
67
|
-
bus.publish(UserMessageToOrchestrator(text))
|
|
68
|
-
|
|
69
|
-
async def action_interrupt(self) -> None:
|
|
70
|
-
orch = getattr(self.app, "orchestrator", None)
|
|
71
|
-
if orch is None:
|
|
72
|
-
return
|
|
73
|
-
await orch.interrupt()
|
|
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
|
|
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
|