prompt-command-center 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pcc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PCC — Prompt Command Center: a keyboard-first prompt template palette."""
2
+
3
+ __version__ = "1.0.0"
pcc/__main__.py ADDED
@@ -0,0 +1,168 @@
1
+ """Entry point: single-instance guard, tray, IPC wiring, event loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import sys
7
+
8
+ from PySide6.QtCore import QObject, Qt, Signal
9
+ from PySide6.QtGui import QAction, QColor, QFont, QIcon, QPainter, QPainterPath, QPixmap
10
+ from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
11
+
12
+ from . import store, winapi
13
+ from .ipc import CMD_QUIT, CMD_SHOW, CMD_TOGGLE, HostWindow, TriggerFilter, find_existing_host
14
+ from .ui.palette import PaletteWindow, load_stylesheet
15
+
16
+ MUTEX_NAME = "Global\\PCC_PromptCommandCenter_SingleInstance"
17
+ ACCENT = QColor("#22D3EE")
18
+ BACKDROP = QColor("#0B0F14")
19
+
20
+
21
+ class ReloadBridge(QObject):
22
+ """Marshals watchdog's worker-thread callback onto the Qt main thread.
23
+
24
+ Touching widgets from the watcher thread would be a crash waiting to happen;
25
+ a queued signal is the cheap, correct fix.
26
+ """
27
+
28
+ triggered = Signal()
29
+
30
+
31
+ def _acquire_single_instance() -> bool:
32
+ """Claim the named mutex. ``False`` means another PCC already owns it.
33
+
34
+ A named mutex rather than a window search: process creation and window
35
+ creation are not atomic, so two near-simultaneous launches could both find
36
+ no window and both start.
37
+ """
38
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
39
+ handle = kernel32.CreateMutexW(None, False, MUTEX_NAME)
40
+ if not handle:
41
+ return True # cannot tell; assume we are alone rather than refuse to run
42
+ ERROR_ALREADY_EXISTS = 183
43
+ if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
44
+ return False
45
+ # Leaked deliberately: the mutex must outlive this function for the whole
46
+ # process lifetime, and Windows releases it on exit.
47
+ _acquire_single_instance._handle = handle # type: ignore[attr-defined]
48
+ return True
49
+
50
+
51
+ def build_icon() -> QIcon:
52
+ """Draw the tray glyph rather than shipping a .ico file."""
53
+ pixmap = QPixmap(64, 64)
54
+ pixmap.fill(Qt.GlobalColor.transparent)
55
+
56
+ painter = QPainter(pixmap)
57
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
58
+
59
+ path = QPainterPath()
60
+ path.addRoundedRect(4, 4, 56, 56, 14, 14)
61
+ painter.fillPath(path, BACKDROP)
62
+ painter.setPen(ACCENT)
63
+ painter.drawPath(path)
64
+
65
+ font = QFont("Cascadia Code", 26, QFont.Weight.Bold)
66
+ painter.setFont(font)
67
+ painter.setPen(ACCENT)
68
+ painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "›_")
69
+ painter.end()
70
+
71
+ return QIcon(pixmap)
72
+
73
+
74
+ def main() -> int:
75
+ if not _acquire_single_instance():
76
+ # Second launch acts as a trigger for the instance already running --
77
+ # this is what makes the AHK hybrid fallback safe to fire twice.
78
+ existing = find_existing_host()
79
+ if existing:
80
+ ctypes.WinDLL("user32", use_last_error=True).PostMessageW(
81
+ ctypes.c_void_p(existing), 0x8000, CMD_SHOW, 0
82
+ )
83
+ return 0
84
+
85
+ app = QApplication(sys.argv)
86
+ app.setApplicationName("PCC")
87
+ app.setQuitOnLastWindowClosed(False) # the palette hiding must not end the app
88
+
89
+ winapi.relax_foreground_lock()
90
+
91
+ settings = store.load_settings()
92
+ app.setStyleSheet(load_stylesheet(settings))
93
+ library = store.load_library(store.library_path(settings))
94
+ palette = PaletteWindow(library, settings)
95
+ palette.quit_requested.connect(app.quit)
96
+ palette.prewarm()
97
+
98
+ host = HostWindow()
99
+ trigger_filter = TriggerFilter(
100
+ host.hwnd,
101
+ {
102
+ CMD_SHOW: palette.trigger,
103
+ CMD_TOGGLE: palette.toggle,
104
+ CMD_QUIT: app.quit,
105
+ },
106
+ )
107
+ app.installNativeEventFilter(trigger_filter)
108
+ # Only now is the host discoverable by AHK; see HostWindow.arm().
109
+ host.arm()
110
+
111
+ bridge = ReloadBridge()
112
+ bridge.triggered.connect(palette.reload_library)
113
+ watcher = store.LibraryWatcher(store.library_path(settings), bridge.triggered.emit)
114
+ watcher.start()
115
+
116
+ # Every internal save must silence the watcher, or we reload our own writes.
117
+ palette.before_save = watcher.mark_self_write
118
+
119
+ icon = build_icon()
120
+ app.setWindowIcon(icon)
121
+ tray = QSystemTrayIcon(icon)
122
+ tray.setToolTip("PCC — CapsLock+Space")
123
+
124
+ menu = QMenu()
125
+ show_action = QAction("Show palette", menu)
126
+ show_action.triggered.connect(palette.trigger)
127
+ edit_action = QAction("Edit templates.json", menu)
128
+ edit_action.triggered.connect(palette.open_library_file)
129
+ prefs_action = QAction("Settings…\tCtrl+,", menu)
130
+ prefs_action.triggered.connect(lambda: (palette.trigger(), palette.open_settings()))
131
+ settings_action = QAction("Edit settings.json", menu)
132
+ settings_action.triggered.connect(palette.open_settings_file)
133
+ reload_action = QAction("Reload templates", menu)
134
+ reload_action.triggered.connect(palette.reload_library)
135
+ restyle_action = QAction("Reload settings", menu)
136
+ restyle_action.triggered.connect(palette.reload_settings)
137
+ quit_action = QAction("Quit PCC\tCtrl+Q", menu)
138
+ quit_action.triggered.connect(app.quit)
139
+ menu.addAction(show_action)
140
+ menu.addAction(prefs_action)
141
+ menu.addSeparator()
142
+ menu.addAction(edit_action)
143
+ menu.addAction(settings_action)
144
+ menu.addAction(reload_action)
145
+ menu.addAction(restyle_action)
146
+ menu.addSeparator()
147
+ menu.addAction(quit_action)
148
+
149
+ tray.setContextMenu(menu)
150
+ tray.activated.connect(
151
+ lambda reason: palette.toggle()
152
+ if reason == QSystemTrayIcon.ActivationReason.Trigger
153
+ else None
154
+ )
155
+ tray.show()
156
+
157
+ app.aboutToQuit.connect(watcher.stop)
158
+ # Flush the debounced usage tail so the last few pastes are not lost on exit.
159
+ app.aboutToQuit.connect(palette.usage.flush)
160
+
161
+ if "--show" in sys.argv:
162
+ palette.trigger()
163
+
164
+ return app.exec()
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
pcc/compose.py ADDED
@@ -0,0 +1,51 @@
1
+ """Composition: stack templates and layer modifiers into one prompt.
2
+
3
+ The trick that keeps this tiny and robust is to compose at the *body* level, not
4
+ with a new runtime object. Joining a few template bodies with blank lines yields
5
+ an ordinary body string, which then flows through the very same
6
+ :func:`pcc.model.render`, :func:`pcc.model.parse_slots` and fill panel as any
7
+ single template -- including the existing rule that a repeated ``{{name}}``
8
+ collapses to one field. So two stacked templates that both use ``{{code}}`` share
9
+ one input for free, with no code here to make that happen.
10
+
11
+ A *modifier* (a template tagged ``modifier``) is a fragment like "Be concise." or
12
+ "Answer as a markdown table." Marking a handful and applying them to a base is
13
+ what turns N templates into N x M prompts from the same small library.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .model import Template
19
+
20
+ #: Blank line between parts: it reads as paragraph breaks in the destination
21
+ #: chat box and never accidentally glues the end of one prompt to the next.
22
+ JOIN = "\n\n"
23
+
24
+
25
+ def combined_body(bases: list[Template], modifiers: list[Template]) -> str:
26
+ """Join base bodies then modifier bodies into one prompt body.
27
+
28
+ Bases keep their given order (stacking), modifiers follow (so "be concise"
29
+ lands after the instruction it qualifies). Empty bodies are dropped so a
30
+ stray blank template cannot open a paste with whitespace.
31
+ """
32
+ parts = [t.body.strip() for t in bases if t.body.strip()]
33
+ parts += [m.body.strip() for m in modifiers if m.body.strip()]
34
+ return JOIN.join(parts)
35
+
36
+
37
+ def compose(bases: list[Template], modifiers: list[Template]) -> Template | None:
38
+ """A transient :class:`Template` for the joined prompt, or ``None`` if empty.
39
+
40
+ Transient by design: it carries a sentinel id and is never stored, so it
41
+ reuses the whole fill/paste path without touching the library.
42
+ """
43
+ body = combined_body(bases, modifiers)
44
+ if not body:
45
+ return None
46
+ if len(bases) == 1 and not modifiers:
47
+ # A single base with no modifiers is just that base -- keep its identity
48
+ # so usage recording and slot recall still key off the real template.
49
+ return bases[0]
50
+ title = bases[0].title if bases else "Composed"
51
+ return Template(title=title, body=body, id="__composed__")
pcc/context.py ADDED
@@ -0,0 +1,198 @@
1
+ """Context resolvers: the magic behind ``{{clipboard}}``, ``{{date}}`` and kin.
2
+
3
+ A *magic slot* is a placeholder whose name is a known context source rather than
4
+ a fill-in the user types. ``{{clipboard}}`` becomes whatever you last copied,
5
+ ``{{app}}`` the executable you summoned PCC over, ``{{date}}`` today.
6
+
7
+ The whole feature is one function injected into :func:`pcc.model.render`: a
8
+ ``resolve(name) -> str | None`` closure. ``model`` stays free of Qt and Win32 --
9
+ the promise that keeps it headlessly testable -- because the closure is built
10
+ *here* and passed in, and this module is the one place allowed to touch
11
+ :mod:`pcc.winapi`.
12
+
13
+ The slow-path contract is deliberately built in from day one, even though every
14
+ resolver today is instant. A :class:`Resolved` may report ``pending``; a
15
+ pending value renders to the literal token, exactly like an unavailable one, and
16
+ the paste path never blocks. An LLM-backed slot later is just a resolver that
17
+ stays pending for 900 ms -- no structural change needed then.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as _dt
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass
25
+
26
+ from . import winapi
27
+
28
+
29
+ @dataclass
30
+ class ResolveEnv:
31
+ """Everything a resolver may read, captured at trigger time.
32
+
33
+ Captured rather than read live so a resolver is a pure function of this
34
+ snapshot: the target window has moved on by the time the user picks a
35
+ template, and tests can supply the whole world without a real desktop.
36
+ """
37
+
38
+ target_hwnd: int = 0
39
+ #: Clipboard text at trigger time. ``None`` means "read it live"; a string
40
+ #: (including "") means "use exactly this", which is what lets tests and the
41
+ #: selection-capture path (wave 5) override it.
42
+ clipboard: str | None = None
43
+ #: Selection captured from the target app. Reserved for wave 5; until then it
44
+ #: stays ``None`` and ``{{selection}}`` resolves to the literal token.
45
+ selection: str | None = None
46
+ now: _dt.datetime | None = None
47
+
48
+ def _clipboard(self) -> str:
49
+ if self.clipboard is not None:
50
+ return self.clipboard
51
+ return winapi.clipboard_get_text() or ""
52
+
53
+ def _now(self) -> _dt.datetime:
54
+ return self.now or _dt.datetime.now()
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Resolved:
59
+ """A resolver's answer.
60
+
61
+ ``value`` is ``None`` when the source has nothing (empty clipboard, unknown
62
+ window); ``pending`` marks a slow source still working. Both render to the
63
+ literal token, so an unresolved magic slot degrades to precisely what an
64
+ unfilled ordinary slot does today.
65
+ """
66
+
67
+ value: str | None
68
+ pending: bool = False
69
+
70
+ @property
71
+ def available(self) -> bool:
72
+ return self.value is not None and not self.pending
73
+
74
+
75
+ # --- The registry -----------------------------------------------------------
76
+ #
77
+ # Each entry maps a magic name to a pure function of the env. Adding a source is
78
+ # one line here; nothing else in the app needs to know the name exists.
79
+
80
+ Resolver = Callable[[ResolveEnv], Resolved]
81
+
82
+
83
+ def _some(text: str | None) -> Resolved:
84
+ """Wrap a plain string: empty or ``None`` becomes an unavailable Resolved."""
85
+ return Resolved(text if text else None)
86
+
87
+
88
+ RESOLVERS: dict[str, Resolver] = {
89
+ "clipboard": lambda env: _some(env._clipboard()),
90
+ "selection": lambda env: _some(env.selection),
91
+ "date": lambda env: _some(env._now().strftime("%Y-%m-%d")),
92
+ "time": lambda env: _some(env._now().strftime("%H:%M")),
93
+ "datetime": lambda env: _some(env._now().strftime("%Y-%m-%d %H:%M")),
94
+ "app": lambda env: _some(winapi.process_name(env.target_hwnd)),
95
+ "window": lambda env: _some(winapi.window_title(env.target_hwnd)),
96
+ }
97
+
98
+ #: Human labels for the fill panel's CONTEXT strip. A magic slot injects text the
99
+ #: user did not type, so it must be *shown*; silent injection is how this feature
100
+ #: would lose trust the first time it guessed wrong.
101
+ LABELS: dict[str, str] = {
102
+ "clipboard": "clipboard",
103
+ "selection": "selection",
104
+ "date": "today",
105
+ "time": "now",
106
+ "datetime": "now",
107
+ "app": "app",
108
+ "window": "window",
109
+ }
110
+
111
+
112
+ #: Processes where synthesising Ctrl+C would do harm rather than copy: in a
113
+ #: console it is SIGINT and would kill the user's running command. Selection
114
+ #: capture always skips these, whatever the setting.
115
+ CONSOLE_DENYLIST = frozenset({
116
+ "windowsterminal.exe", "cmd.exe", "powershell.exe", "pwsh.exe",
117
+ "conhost.exe", "wt.exe", "mintty.exe", "bash.exe",
118
+ })
119
+
120
+
121
+ def is_console(app: str | None) -> bool:
122
+ return bool(app) and app.strip().lower() in CONSOLE_DENYLIST
123
+
124
+
125
+ def is_magic(name: str) -> bool:
126
+ return name.strip() in RESOLVERS
127
+
128
+
129
+ def resolve(name: str, env: ResolveEnv) -> Resolved:
130
+ """Resolve one magic name against ``env``; unknown names are unavailable."""
131
+ resolver = RESOLVERS.get(name.strip())
132
+ if resolver is None:
133
+ return Resolved(None)
134
+ try:
135
+ return resolver(env)
136
+ except Exception:
137
+ # A resolver must never take down a paste. Treat any failure as "nothing
138
+ # here" and fall through to the literal token.
139
+ return Resolved(None)
140
+
141
+
142
+ def make_resolver(env: ResolveEnv) -> Callable[[str], str | None]:
143
+ """Build the closure :func:`pcc.model.render` calls for each placeholder.
144
+
145
+ Returns the resolved text, or ``None`` to mean "not a magic slot / nothing
146
+ available", on which render keeps its literal-token fallback.
147
+ """
148
+
149
+ def _resolve(name: str) -> str | None:
150
+ result = resolve(name, env)
151
+ return result.value if result.available else None
152
+
153
+ return _resolve
154
+
155
+
156
+ @dataclass
157
+ class ContextItem:
158
+ """One resolved magic slot, ready to show in the CONTEXT strip."""
159
+
160
+ name: str
161
+ label: str
162
+ value: str | None
163
+ pending: bool = False
164
+
165
+ @property
166
+ def available(self) -> bool:
167
+ return self.value is not None and not self.pending
168
+
169
+ def summary(self) -> str:
170
+ """A compact, safe one-liner: ``clipboard · 1.2k chars`` / ``app · Code.exe``."""
171
+ if self.pending:
172
+ return f"{self.label} · …"
173
+ if self.value is None:
174
+ return f"{self.label} · —"
175
+ text = self.value
176
+ # Long values (a pasted file, a whole selection) are summarised by size
177
+ # rather than dumped into the strip.
178
+ if len(text) > 42 or "\n" in text:
179
+ chars = len(text)
180
+ size = f"{chars / 1000:.1f}k" if chars >= 1000 else str(chars)
181
+ return f"{self.label} · {size} chars"
182
+ return f"{self.label} · {text}"
183
+
184
+
185
+ def context_items(names: list[str], env: ResolveEnv) -> list[ContextItem]:
186
+ """Resolve ``names`` (already known magic) into display items, order kept."""
187
+ items: list[ContextItem] = []
188
+ for name in names:
189
+ result = resolve(name, env)
190
+ items.append(
191
+ ContextItem(
192
+ name=name,
193
+ label=LABELS.get(name.strip(), name.strip()),
194
+ value=result.value,
195
+ pending=result.pending,
196
+ )
197
+ )
198
+ return items
pcc/ipc.py ADDED
@@ -0,0 +1,109 @@
1
+ """Trigger channel: AutoHotkey -> PCC.
2
+
3
+ AHK posts ``WM_APP`` to a well-known, never-shown top-level window. That is
4
+ roughly a millisecond of work and needs no sockets, no ports, and no polling.
5
+
6
+ Two details matter and are easy to get wrong:
7
+
8
+ * The host must be a genuine **top-level** window, not a ``HWND_MESSAGE``-only
9
+ one -- message-only windows are invisible to AHK's ``WinExist``/``FindWindow``.
10
+ * ``winId()`` must be touched so Qt actually creates the HWND for a widget that
11
+ is never shown.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ctypes
17
+ from collections.abc import Callable
18
+ from ctypes import wintypes
19
+
20
+ from PySide6.QtCore import QAbstractNativeEventFilter, Qt
21
+ from PySide6.QtWidgets import QWidget
22
+
23
+ #: Window title AHK looks for. Must stay in sync with ``scripts/pcc.ahk``.
24
+ HOST_TITLE = "PCC_IPC_HOST"
25
+
26
+ WM_APP = 0x8000
27
+ #: wParam values, so one message type can carry several commands.
28
+ CMD_SHOW = 0
29
+ CMD_TOGGLE = 1
30
+ CMD_QUIT = 2
31
+
32
+
33
+ class _MSG(ctypes.Structure):
34
+ _fields_ = [
35
+ ("hwnd", wintypes.HWND),
36
+ ("message", wintypes.UINT),
37
+ ("wParam", wintypes.WPARAM),
38
+ ("lParam", wintypes.LPARAM),
39
+ ("time", wintypes.DWORD),
40
+ ("pt_x", wintypes.LONG),
41
+ ("pt_y", wintypes.LONG),
42
+ ]
43
+
44
+
45
+ class HostWindow(QWidget):
46
+ """Invisible window whose only job is to own a findable HWND.
47
+
48
+ Created *unarmed*: the window exists (so its HWND can be handed to the event
49
+ filter) but carries a placeholder title, so AHK's ``WinWait`` cannot find it
50
+ yet. Call :meth:`arm` once the filter is installed. Without this, a trigger
51
+ arriving in the gap between window creation and filter installation is
52
+ dispatched to a window that is not yet listening and silently vanishes.
53
+ """
54
+
55
+ UNARMED_TITLE = HOST_TITLE + "_STARTING"
56
+
57
+ def __init__(self) -> None:
58
+ super().__init__(None, Qt.WindowType.Tool)
59
+ self.setWindowTitle(self.UNARMED_TITLE)
60
+ self.setObjectName(HOST_TITLE)
61
+ self.setFixedSize(1, 1)
62
+ # Force native window creation without ever showing it.
63
+ self.hwnd = int(self.winId())
64
+
65
+ def arm(self) -> None:
66
+ """Publish the well-known title; from here on AHK can reach us."""
67
+ self.setWindowTitle(HOST_TITLE)
68
+
69
+
70
+ class TriggerFilter(QAbstractNativeEventFilter):
71
+ """Routes ``WM_APP`` posted at the host window to Python callbacks.
72
+
73
+ Runs on Qt's main thread inside the event loop, so handlers may touch the
74
+ UI directly. Keep them fast -- this sits on the message pump.
75
+ """
76
+
77
+ def __init__(self, hwnd: int, handlers: dict[int, Callable[[], None]]) -> None:
78
+ super().__init__()
79
+ self._hwnd = hwnd
80
+ self._handlers = handlers
81
+
82
+ def nativeEventFilter(self, event_type, message): # noqa: N802 (Qt naming)
83
+ if event_type != b"windows_generic_MSG":
84
+ return False, 0
85
+ try:
86
+ msg = ctypes.cast(int(message), ctypes.POINTER(_MSG)).contents
87
+ except (TypeError, ValueError):
88
+ return False, 0
89
+
90
+ if msg.message != WM_APP or int(msg.hwnd or 0) != self._hwnd:
91
+ return False, 0
92
+
93
+ handler = self._handlers.get(int(msg.wParam))
94
+ if handler is not None:
95
+ handler()
96
+ # Swallow it: nothing else has any business seeing this message.
97
+ return True, 0
98
+
99
+
100
+ def find_existing_host() -> int:
101
+ """HWND of an already-running PCC, or 0.
102
+
103
+ Used only for diagnostics; the real single-instance guard is a named mutex,
104
+ which is race-free where a window search is not.
105
+ """
106
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
107
+ user32.FindWindowW.argtypes = (wintypes.LPCWSTR, wintypes.LPCWSTR)
108
+ user32.FindWindowW.restype = wintypes.HWND
109
+ return int(user32.FindWindowW(None, HOST_TITLE) or 0)
pcc/journal.py ADDED
@@ -0,0 +1,49 @@
1
+ """An in-session undo ring for destructive library edits.
2
+
3
+ Confirmation dialogs are a poor guard: they interrupt every delete to catch the
4
+ rare mistake, and people learn to dismiss them without reading. Undo is both
5
+ faster (no prompt on the common, correct case) and safer (it also recovers the
6
+ delete you *meant*, then regretted). So the palette deletes immediately and
7
+ banks a snapshot here.
8
+
9
+ Snapshots are whole-library ``to_dict`` dicts. That is coarse, but a library is
10
+ small and correctness beats cleverness: restoring a snapshot cannot leave a
11
+ half-applied edit the way replaying an inverse operation might.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ from dataclasses import dataclass, field
18
+
19
+
20
+ @dataclass
21
+ class UndoJournal:
22
+ """A bounded stack of ``(library_dict, label)`` snapshots, newest last."""
23
+
24
+ limit: int = 20
25
+ _stack: list[tuple[dict, str]] = field(default_factory=list)
26
+
27
+ def record(self, library_dict: dict, label: str) -> None:
28
+ """Snapshot the library *before* a destructive change, tagged ``label``."""
29
+ self._stack.append((copy.deepcopy(library_dict), label))
30
+ if len(self._stack) > self.limit:
31
+ # Drop the oldest; the deep past is not worth unbounded memory.
32
+ del self._stack[0 : len(self._stack) - self.limit]
33
+
34
+ @property
35
+ def can_undo(self) -> bool:
36
+ return bool(self._stack)
37
+
38
+ @property
39
+ def next_label(self) -> str | None:
40
+ return self._stack[-1][1] if self._stack else None
41
+
42
+ def undo(self) -> tuple[dict, str] | None:
43
+ """Pop and return the most recent ``(library_dict, label)``, or ``None``."""
44
+ if not self._stack:
45
+ return None
46
+ return self._stack.pop()
47
+
48
+ def __len__(self) -> int:
49
+ return len(self._stack)
pcc/lint.py ADDED
@@ -0,0 +1,102 @@
1
+ """Library health checks: the only feature that makes a prompt library shrink.
2
+
3
+ Prompt collections rot silently -- they only ever grow, and nobody schedules a
4
+ cleanup. Surfacing the dead weight (never-used, duplicated, broken, empty) turns
5
+ "organise your library someday" into a concrete, finishable list. Loss aversion,
6
+ used honestly: "you have 14 templates you have never used" prunes what a tidy
7
+ "Manage" button never would.
8
+
9
+ Pure: a function of the library (and, optionally, usage), returning findings.
10
+ The UI just renders them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+
18
+ from .model import INCLUDE_PREFIX, PLACEHOLDER_RE, Library
19
+
20
+ #: Ordering/severity: lower sorts first in the health view.
21
+ SEVERITY = {"broken-include": 0, "empty": 1, "duplicate-title": 2, "untitled": 3, "unused": 4}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Finding:
26
+ kind: str
27
+ message: str
28
+ template_id: str
29
+ tab_name: str
30
+
31
+ @property
32
+ def severity(self) -> int:
33
+ return SEVERITY.get(self.kind, 99)
34
+
35
+
36
+ def _include_refs(body: str) -> list[str]:
37
+ refs = []
38
+ for match in PLACEHOLDER_RE.finditer(body):
39
+ name = match.group(1).strip()
40
+ if name.startswith(INCLUDE_PREFIX):
41
+ ref = name[len(INCLUDE_PREFIX):].strip()
42
+ if ref:
43
+ refs.append(ref)
44
+ return refs
45
+
46
+
47
+ def lint(library: Library, frecency: Callable[[str], float] | None = None) -> list[Finding]:
48
+ """Return health findings, most severe first.
49
+
50
+ ``frecency`` (from :class:`pcc.usage.UsageStore`) enables the "never used"
51
+ check; without it that check is skipped, so a fresh install is not scolded
52
+ for templates it has not had a chance to use yet.
53
+ """
54
+ findings: list[Finding] = []
55
+
56
+ # Duplicate titles, compared case-insensitively across the whole library.
57
+ seen: dict[str, int] = {}
58
+ for _tab, template in library.iter_all():
59
+ seen[template.title.strip().lower()] = seen.get(template.title.strip().lower(), 0) + 1
60
+
61
+ # Valid include targets: any template id or title.
62
+ valid_refs = set()
63
+ for _tab, template in library.iter_all():
64
+ valid_refs.add(template.id)
65
+ valid_refs.add(template.title.strip().lower())
66
+
67
+ for tab, template in library.iter_all():
68
+ title = template.title.strip()
69
+
70
+ if not template.body.strip():
71
+ findings.append(Finding("empty", "empty body", template.id, tab.name))
72
+
73
+ if title.lower() == "untitled" or not title:
74
+ findings.append(Finding("untitled", "no title", template.id, tab.name))
75
+
76
+ if seen.get(title.lower(), 0) > 1:
77
+ findings.append(
78
+ Finding("duplicate-title", f"duplicate title “{title}”", template.id, tab.name)
79
+ )
80
+
81
+ for ref in _include_refs(template.body):
82
+ if ref not in valid_refs and ref.lower() not in valid_refs:
83
+ findings.append(
84
+ Finding("broken-include", f"include “{ref}” not found", template.id, tab.name)
85
+ )
86
+
87
+ if frecency is not None and frecency(template.id) <= 0.0:
88
+ findings.append(Finding("unused", "never used", template.id, tab.name))
89
+
90
+ findings.sort(key=lambda f: (f.severity, f.tab_name))
91
+ return findings
92
+
93
+
94
+ def summary(findings: list[Finding]) -> str:
95
+ """One-line rollup for a toast, e.g. ``2 broken · 3 unused``."""
96
+ if not findings:
97
+ return "library looks healthy"
98
+ counts: dict[str, int] = {}
99
+ for finding in findings:
100
+ counts[finding.kind] = counts.get(finding.kind, 0) + 1
101
+ order = sorted(counts, key=lambda k: SEVERITY.get(k, 99))
102
+ return " · ".join(f"{counts[k]} {k.replace('-', ' ')}" for k in order)