cursorfleet 0.1.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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
cursorfleet/tui/gates.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Display-only verification observations (v0.1 does NOT enforce gates).
|
|
2
|
+
|
|
3
|
+
Heuristic command classification may be listed here; it never satisfies a gate
|
|
4
|
+
(ADR 0011). PASS/FAIL gate tiles are intentionally not derived from tier 1-3 data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from cursorfleet.events.kinds import VERIFICATION_KINDS, EventKind, Outcome
|
|
15
|
+
from cursorfleet.events.models import Event
|
|
16
|
+
from cursorfleet.state.models import utc
|
|
17
|
+
|
|
18
|
+
GATES: tuple[tuple[str, str], ...] = (
|
|
19
|
+
("unit_tests", "Unit tests"),
|
|
20
|
+
("integration_tests", "Integration tests"),
|
|
21
|
+
("type_check", "Type check"),
|
|
22
|
+
("lint", "Lint"),
|
|
23
|
+
("security_checks", "Security checks"),
|
|
24
|
+
("independent_review", "Independent review"),
|
|
25
|
+
("re_review", "Re-review"),
|
|
26
|
+
("documentation", "Documentation"),
|
|
27
|
+
("ci_status", "CI status"),
|
|
28
|
+
("merge_readiness", "Merge readiness"),
|
|
29
|
+
)
|
|
30
|
+
GATE_LABEL = dict(GATES)
|
|
31
|
+
OBSERVABLE = frozenset({"unit_tests", "integration_tests", "type_check", "lint", "security_checks"})
|
|
32
|
+
|
|
33
|
+
DISPLAY_ONLY = "display-only: v0.1 does not enforce gates"
|
|
34
|
+
|
|
35
|
+
_SPLIT = re.compile(r"\s*(&&|\|\||;|\|)\s*")
|
|
36
|
+
_WORDS = re.compile(r"[a-z0-9_.\-]+")
|
|
37
|
+
_LINT = {"ruff", "eslint", "flake8", "pylint", "golangci-lint", "shellcheck", "hadolint", "clippy"}
|
|
38
|
+
_TYPE = {"mypy", "pyright", "tsc", "typecheck", "type-check"}
|
|
39
|
+
_SECURITY = {"bandit", "semgrep", "trivy", "audit", "pip-audit", "gitleaks", "security"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class GateEvidence:
|
|
44
|
+
gate: str
|
|
45
|
+
state: str # "observed" | "unknown" — never pass/fail for heuristics
|
|
46
|
+
ts: datetime
|
|
47
|
+
commit: str | None
|
|
48
|
+
worktree_id: str | None
|
|
49
|
+
session_id: str
|
|
50
|
+
agent: str | None
|
|
51
|
+
origin: str
|
|
52
|
+
command: str | None = None
|
|
53
|
+
note: str | None = None
|
|
54
|
+
exit_outcome: str | None = None # ok | failed | unknown from hook
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class GateStatus:
|
|
59
|
+
gate: str
|
|
60
|
+
label: str
|
|
61
|
+
state: str # not_evaluated | observed | unknown | stale
|
|
62
|
+
detail: str
|
|
63
|
+
evidence: GateEvidence | None = None
|
|
64
|
+
head: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_UNIT = frozenset(
|
|
68
|
+
{"pytest", "py.test", "jest", "vitest", "mocha", "rspec", "phpunit", "unittest", "ctest"}
|
|
69
|
+
| {"tox", "nox", "test"}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _classify_segment(words: list[str]) -> str | None: # noqa: PLR0911
|
|
74
|
+
names = words
|
|
75
|
+
joined = " ".join(names)
|
|
76
|
+
if any(n in _LINT for n in names) and "format" not in names:
|
|
77
|
+
return "lint"
|
|
78
|
+
if any(n in _TYPE for n in names):
|
|
79
|
+
return "type_check"
|
|
80
|
+
if any(n in _SECURITY for n in names):
|
|
81
|
+
return "security_checks"
|
|
82
|
+
if "integration" in joined or "e2e" in joined:
|
|
83
|
+
return "integration_tests"
|
|
84
|
+
if any(n in _UNIT or n.startswith(("test:", "test-")) for n in names):
|
|
85
|
+
return "unit_tests"
|
|
86
|
+
if "lint" in joined:
|
|
87
|
+
return "lint"
|
|
88
|
+
if "check" in joined and "typ" in joined:
|
|
89
|
+
return "type_check"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def classify_command(
|
|
94
|
+
argv0: str | None, subcommand: str | None, display: str | None
|
|
95
|
+
) -> tuple[list[str], bool]:
|
|
96
|
+
text = (display or " ".join(p for p in (argv0, subcommand) if p)).lower()
|
|
97
|
+
parts = _SPLIT.split(text)
|
|
98
|
+
segments = parts[0::2]
|
|
99
|
+
separators = parts[1::2]
|
|
100
|
+
gates: list[str] = []
|
|
101
|
+
for segment in segments:
|
|
102
|
+
gate = _classify_segment(_WORDS.findall(segment))
|
|
103
|
+
if gate is not None and gate not in gates:
|
|
104
|
+
gates.append(gate)
|
|
105
|
+
return gates, all(s == "&&" for s in separators)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _outcome_label(outcome: Outcome | None) -> str:
|
|
109
|
+
if outcome is Outcome.OK:
|
|
110
|
+
return "ok"
|
|
111
|
+
if outcome is Outcome.FAILED:
|
|
112
|
+
return "failed"
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def evidence_from_events(events: Iterable[Event]) -> list[GateEvidence]:
|
|
117
|
+
"""Heuristic verification observations; never mapped to PASS/FAIL gates."""
|
|
118
|
+
found: list[GateEvidence] = []
|
|
119
|
+
for event in events:
|
|
120
|
+
if event.kind is EventKind.GATE_CHANGED and event.gate is not None:
|
|
121
|
+
if event.gate.name in GATE_LABEL:
|
|
122
|
+
found.append(
|
|
123
|
+
_evidence(
|
|
124
|
+
event,
|
|
125
|
+
event.gate.name,
|
|
126
|
+
"unknown",
|
|
127
|
+
"gate.changed",
|
|
128
|
+
note="gate.changed is not tier-4 evidence in v0.1",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
elif event.kind in VERIFICATION_KINDS and event.command is not None:
|
|
132
|
+
cmd = event.command
|
|
133
|
+
gates, all_and = classify_command(cmd.argv0, cmd.subcommand, cmd.display)
|
|
134
|
+
exit_label = _outcome_label(event.outcome)
|
|
135
|
+
note = "heuristic observation; not proof of pass or fail"
|
|
136
|
+
if len(gates) > 1 and not (exit_label == "ok" and all_and):
|
|
137
|
+
note = "chained command: exit code does not identify which step ran; " + note
|
|
138
|
+
elif len(gates) > 1:
|
|
139
|
+
note = "chained && command; " + note
|
|
140
|
+
shown = cmd.display or " ".join(p for p in (cmd.argv0, cmd.subcommand) if p)
|
|
141
|
+
origin = (
|
|
142
|
+
"verification.observed"
|
|
143
|
+
if event.kind is EventKind.VERIFICATION_OBSERVED
|
|
144
|
+
else "test.completed"
|
|
145
|
+
)
|
|
146
|
+
found.extend(
|
|
147
|
+
_evidence(
|
|
148
|
+
event,
|
|
149
|
+
gate,
|
|
150
|
+
"observed",
|
|
151
|
+
origin,
|
|
152
|
+
command=shown,
|
|
153
|
+
note=note,
|
|
154
|
+
exit_outcome=exit_label,
|
|
155
|
+
)
|
|
156
|
+
for gate in gates
|
|
157
|
+
)
|
|
158
|
+
return found
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _evidence( # noqa: PLR0913
|
|
162
|
+
event: Event,
|
|
163
|
+
gate: str,
|
|
164
|
+
state: str,
|
|
165
|
+
origin: str,
|
|
166
|
+
*,
|
|
167
|
+
command: str | None = None,
|
|
168
|
+
note: str | None = None,
|
|
169
|
+
exit_outcome: str | None = None,
|
|
170
|
+
) -> GateEvidence:
|
|
171
|
+
return GateEvidence(
|
|
172
|
+
gate=gate,
|
|
173
|
+
state=state,
|
|
174
|
+
ts=utc(event.ts),
|
|
175
|
+
commit=event.commit,
|
|
176
|
+
worktree_id=event.worktree_id,
|
|
177
|
+
session_id=event.session_id,
|
|
178
|
+
agent=event.agent_id,
|
|
179
|
+
origin=origin,
|
|
180
|
+
command=command,
|
|
181
|
+
note=note,
|
|
182
|
+
exit_outcome=exit_outcome,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _same_commit(evidence_commit: str, head: str) -> bool:
|
|
187
|
+
shorter, longer = sorted((evidence_commit.lower(), head.lower()), key=len)
|
|
188
|
+
return longer.startswith(shorter)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def evaluate(
|
|
192
|
+
evidence: Iterable[GateEvidence], *, worktree_id: str | None, head: str | None
|
|
193
|
+
) -> list[GateStatus]:
|
|
194
|
+
latest: dict[str, GateEvidence] = {}
|
|
195
|
+
for item in evidence:
|
|
196
|
+
if item.worktree_id != worktree_id:
|
|
197
|
+
continue
|
|
198
|
+
current = latest.get(item.gate)
|
|
199
|
+
if current is None or (item.ts, item.session_id) > (current.ts, current.session_id):
|
|
200
|
+
latest[item.gate] = item
|
|
201
|
+
rows: list[GateStatus] = []
|
|
202
|
+
for gate, label in GATES:
|
|
203
|
+
found = latest.get(gate)
|
|
204
|
+
if found is None:
|
|
205
|
+
why = "not evaluated" if gate in OBSERVABLE else "not evaluated (not observable)"
|
|
206
|
+
rows.append(GateStatus(gate, label, "not_evaluated", why, None, head))
|
|
207
|
+
continue
|
|
208
|
+
rows.append(_judge(gate, label, found, head))
|
|
209
|
+
return rows
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _judge(gate: str, label: str, item: GateEvidence, head: str | None) -> GateStatus:
|
|
213
|
+
sha = item.commit[:8] if item.commit else None
|
|
214
|
+
exit_bit = f", exit {item.exit_outcome}" if item.exit_outcome else ""
|
|
215
|
+
if item.commit is None:
|
|
216
|
+
return GateStatus(
|
|
217
|
+
gate,
|
|
218
|
+
label,
|
|
219
|
+
"observed",
|
|
220
|
+
f"heuristic observation{exit_bit}; not bound to a commit",
|
|
221
|
+
item,
|
|
222
|
+
head,
|
|
223
|
+
)
|
|
224
|
+
if head is None:
|
|
225
|
+
return GateStatus(
|
|
226
|
+
gate,
|
|
227
|
+
label,
|
|
228
|
+
"observed",
|
|
229
|
+
f"heuristic observation{exit_bit} @{sha}; HEAD unknown",
|
|
230
|
+
item,
|
|
231
|
+
head,
|
|
232
|
+
)
|
|
233
|
+
if not _same_commit(item.commit, head):
|
|
234
|
+
return GateStatus(
|
|
235
|
+
gate,
|
|
236
|
+
label,
|
|
237
|
+
"stale",
|
|
238
|
+
f"STALE: observation{exit_bit} was @{sha}, HEAD now @{head[:8]}",
|
|
239
|
+
item,
|
|
240
|
+
head,
|
|
241
|
+
)
|
|
242
|
+
return GateStatus(
|
|
243
|
+
gate,
|
|
244
|
+
label,
|
|
245
|
+
"observed",
|
|
246
|
+
f"heuristic observation{exit_bit} @{sha} (HEAD)",
|
|
247
|
+
item,
|
|
248
|
+
head,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def evaluate_all(
|
|
253
|
+
evidence: list[GateEvidence], heads: Mapping[str | None, str | None]
|
|
254
|
+
) -> dict[str | None, list[GateStatus]]:
|
|
255
|
+
return {wt: evaluate(evidence, worktree_id=wt, head=head) for wt, head in heads.items()}
|
cursorfleet/tui/help.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Help screen text (kept as data so tests and docs can assert on it)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from cursorfleet.tui.gates import DISPLAY_ONLY
|
|
13
|
+
from cursorfleet.tui.layout import SINGLE_BELOW, TILE_FROM
|
|
14
|
+
|
|
15
|
+
KEYS: tuple[tuple[str, str], ...] = (
|
|
16
|
+
("a", "Active run: task-centric roles and instances (default)"),
|
|
17
|
+
("u", "Active runs: persisted runs for this repo"),
|
|
18
|
+
("o", "Sessions (archive): full session-centric overview"),
|
|
19
|
+
("l", "Timeline: filterable event stream (newest first)"),
|
|
20
|
+
("w", "Worktrees: branch, HEAD, dirty, ahead/behind, owner"),
|
|
21
|
+
("g", "Observations: verification signals, display-only (no pass/fail score)"),
|
|
22
|
+
("e", "Evidence: observed runs vs SELF-REPORTED declarations"),
|
|
23
|
+
("j / k, arrows", "Move down / up (scrolls the detail when it has focus)"),
|
|
24
|
+
("Enter", "Open the detail for the selected item"),
|
|
25
|
+
("Esc", "Back: leave detail, close or clear the filter"),
|
|
26
|
+
("/", "Filter (timeline: agent: session: kind: risk: source: file: since: until: words)"),
|
|
27
|
+
("m", "Timeline: load more events"),
|
|
28
|
+
("p", "Pin the selected agent to the top of its lane (this session only)"),
|
|
29
|
+
("t", f"Toggle 2x2 tiled panes ({TILE_FROM}+ columns only)"),
|
|
30
|
+
("r", "Re-read now (index, git, artifacts); never fetches"),
|
|
31
|
+
("Tab", "Move focus between filter, list and detail"),
|
|
32
|
+
("?", "This help"),
|
|
33
|
+
("q", "Quit"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
HONESTY: tuple[str, ...] = (
|
|
37
|
+
"CursorFleet v0.1 OBSERVES. It does not enforce, approve or block anything.",
|
|
38
|
+
"Primary labels: Observed, Declared, Inferred, Unavailable, Conflict (not obs/drv/SELF).",
|
|
39
|
+
"Detail panes may still show technical source/attribution for power users.",
|
|
40
|
+
"A silent agent is shown STALE / OFFLINE, never idle. No telemetry means UNKNOWN.",
|
|
41
|
+
"session_id groups one Cursor chat; it is correlation only, not agent identity.",
|
|
42
|
+
"Q1 and Q2 (subagent identity in hooks) are OPEN; incomplete lifecycles stay visible.",
|
|
43
|
+
"Token and cost budgets are unknown: Cursor hooks do not expose them.",
|
|
44
|
+
f"Observations: {DISPLAY_ONLY}. Each signal stands alone; there is no overall score.",
|
|
45
|
+
"Cloud agents are not visible. Local Cursor sessions only.",
|
|
46
|
+
"Status is always written as text; colour is only a hint and NO_COLOR is honoured.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
LAYOUTS: tuple[str, ...] = (
|
|
50
|
+
f"Under {SINGLE_BELOW} columns: one pane (Enter opens the detail, Esc returns).",
|
|
51
|
+
f"{SINGLE_BELOW}-{TILE_FROM - 1} columns: list on the left, detail on the right.",
|
|
52
|
+
f"{TILE_FROM}+ columns: same, or press t for a 2x2 grid of list, detail, recent events "
|
|
53
|
+
"and gates/worktrees.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
SETUP: tuple[str, ...] = (
|
|
57
|
+
"No data? Run `cursorfleet init --cursor` to install the hooks, then use Cursor.",
|
|
58
|
+
"`cursorfleet doctor` explains what is missing.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def help_text() -> Text:
|
|
63
|
+
text = Text("CursorFleet help\n\n", style="bold")
|
|
64
|
+
text.append("KEYS\n", style="bold")
|
|
65
|
+
for key, meaning in KEYS:
|
|
66
|
+
text.append(f" {key:<14}", style="bold")
|
|
67
|
+
text.append(f"{meaning}\n")
|
|
68
|
+
text.append("\nLAYOUT\n", style="bold")
|
|
69
|
+
for line in LAYOUTS:
|
|
70
|
+
text.append(f" {line}\n")
|
|
71
|
+
text.append("\nHONESTY\n", style="bold")
|
|
72
|
+
for line in HONESTY:
|
|
73
|
+
text.append(f" {line}\n")
|
|
74
|
+
text.append("\nSETUP\n", style="bold")
|
|
75
|
+
for line in SETUP:
|
|
76
|
+
text.append(f" {line}\n")
|
|
77
|
+
text.append("\nPress Esc, ? or q to close.\n", style="dim")
|
|
78
|
+
return text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HelpScreen(ModalScreen[None]):
|
|
82
|
+
"""Modal help. Closing it never quits the app."""
|
|
83
|
+
|
|
84
|
+
BINDINGS = [ # noqa: RUF012 - Textual convention
|
|
85
|
+
Binding("escape", "close", "Close", show=False),
|
|
86
|
+
Binding("question_mark", "close", "Close", show=False),
|
|
87
|
+
Binding("q", "close", "Close", show=False),
|
|
88
|
+
]
|
|
89
|
+
DEFAULT_CSS = """
|
|
90
|
+
HelpScreen { align: center middle; }
|
|
91
|
+
HelpScreen > VerticalScroll {
|
|
92
|
+
width: 90%; max-width: 100; height: 90%; border: round $primary; padding: 0 1;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def compose(self) -> ComposeResult:
|
|
97
|
+
with VerticalScroll():
|
|
98
|
+
yield Static(help_text(), id="help_text")
|
|
99
|
+
|
|
100
|
+
def action_close(self) -> None:
|
|
101
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Responsive layout rules (docs/tui.md).
|
|
2
|
+
|
|
3
|
+
- below 100 columns: ONE pane (the list, or the detail after Enter);
|
|
4
|
+
- 100-159 columns: list + detail side by side;
|
|
5
|
+
- 160+ columns: list + detail, or an optional 2x2 tile grid (``t``).
|
|
6
|
+
|
|
7
|
+
Four panes are never placed side by side: tiles form a 2x2 grid, so each pane keeps at
|
|
8
|
+
least ~80 columns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
SINGLE_BELOW = 100
|
|
14
|
+
TILE_FROM = 160
|
|
15
|
+
|
|
16
|
+
SINGLE = "single"
|
|
17
|
+
SPLIT = "split"
|
|
18
|
+
TILED = "tiled"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def layout_for(width: int, tile_enabled: bool) -> str:
|
|
22
|
+
if width < SINGLE_BELOW:
|
|
23
|
+
return SINGLE
|
|
24
|
+
if width >= TILE_FROM and tile_enabled:
|
|
25
|
+
return TILED
|
|
26
|
+
return SPLIT
|
cursorfleet/tui/theme.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Text helpers and styling for the TUI.
|
|
2
|
+
|
|
3
|
+
Rules enforced here for every screen:
|
|
4
|
+
|
|
5
|
+
- Untrusted strings (roles, branches, paths, refs) go through :func:`safe` and are placed in
|
|
6
|
+
``rich.text.Text`` objects, never in markup strings, so ``[bold]``-style content from a
|
|
7
|
+
forged artifact cannot restyle or hide the UI.
|
|
8
|
+
- Status is never conveyed by colour alone: every state has a text label and an ASCII
|
|
9
|
+
marker (:data:`LANE_TAG`, :data:`LANE_MARK`). Colour is only an enhancement and is
|
|
10
|
+
replaced by bold/dim/reverse when ``NO_COLOR`` is set (:class:`Theme`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from cursorfleet.state.models import Lane, utc
|
|
19
|
+
|
|
20
|
+
LANE_ORDER: tuple[str, ...] = (
|
|
21
|
+
Lane.QUEUED.value,
|
|
22
|
+
Lane.LOADING_CONTEXT.value,
|
|
23
|
+
Lane.PLANNING.value,
|
|
24
|
+
Lane.WORKING.value,
|
|
25
|
+
Lane.VERIFYING.value,
|
|
26
|
+
Lane.AWAITING_REVIEW.value,
|
|
27
|
+
Lane.PATCHING.value,
|
|
28
|
+
Lane.BLOCKED.value,
|
|
29
|
+
Lane.DONE.value,
|
|
30
|
+
Lane.STALE_OFFLINE.value,
|
|
31
|
+
Lane.UNKNOWN.value,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
LANE_TAG: dict[str, str] = {
|
|
35
|
+
"queued": "QUEUED",
|
|
36
|
+
"loading_context": "LOADING CONTEXT",
|
|
37
|
+
"planning": "PLANNING",
|
|
38
|
+
"working": "WORKING",
|
|
39
|
+
"verifying": "VERIFYING",
|
|
40
|
+
"awaiting_review": "AWAITING REVIEW",
|
|
41
|
+
"patching": "PATCHING",
|
|
42
|
+
"blocked": "BLOCKED",
|
|
43
|
+
"done": "DONE",
|
|
44
|
+
"stale_offline": "STALE / OFFLINE",
|
|
45
|
+
"unknown": "UNKNOWN / NO TELEMETRY",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
LANE_MARK: dict[str, str] = {
|
|
49
|
+
"queued": "[Q]",
|
|
50
|
+
"loading_context": "[C]",
|
|
51
|
+
"planning": "[P]",
|
|
52
|
+
"working": "[W]",
|
|
53
|
+
"verifying": "[V]",
|
|
54
|
+
"awaiting_review": "[R]",
|
|
55
|
+
"patching": "[X]",
|
|
56
|
+
"blocked": "[!]",
|
|
57
|
+
"done": "[D]",
|
|
58
|
+
"stale_offline": "[~]",
|
|
59
|
+
"unknown": "[?]",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
BASIS_TAG: dict[str, str] = {
|
|
63
|
+
"observed_activity": "observed",
|
|
64
|
+
"lifecycle_only": "lifecycle-only",
|
|
65
|
+
"self_reported": "SELF-REPORTED",
|
|
66
|
+
"derived": "derived",
|
|
67
|
+
"none": "no telemetry",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_COLOR: dict[str, str] = {
|
|
71
|
+
"blocked": "bold red",
|
|
72
|
+
"working": "cyan",
|
|
73
|
+
"verifying": "magenta",
|
|
74
|
+
"awaiting_review": "yellow",
|
|
75
|
+
"patching": "blue",
|
|
76
|
+
"done": "green",
|
|
77
|
+
"stale_offline": "dim yellow",
|
|
78
|
+
"unknown": "dim",
|
|
79
|
+
}
|
|
80
|
+
_MONO: dict[str, str] = {
|
|
81
|
+
"blocked": "bold reverse",
|
|
82
|
+
"working": "bold",
|
|
83
|
+
"verifying": "bold",
|
|
84
|
+
"awaiting_review": "underline",
|
|
85
|
+
"patching": "underline",
|
|
86
|
+
"done": "dim",
|
|
87
|
+
"stale_offline": "dim italic",
|
|
88
|
+
"unknown": "dim",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
GATE_COLOR = {
|
|
92
|
+
"pass": "green",
|
|
93
|
+
"fail": "bold red",
|
|
94
|
+
"stale": "yellow",
|
|
95
|
+
"unknown": "dim",
|
|
96
|
+
"not_evaluated": "dim",
|
|
97
|
+
"observed": "cyan",
|
|
98
|
+
}
|
|
99
|
+
GATE_MONO = {
|
|
100
|
+
"pass": "bold",
|
|
101
|
+
"fail": "bold reverse",
|
|
102
|
+
"stale": "underline",
|
|
103
|
+
"unknown": "dim",
|
|
104
|
+
"not_evaluated": "dim",
|
|
105
|
+
"observed": "bold",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
UNKNOWN_BUDGET = "unknown (not exposed by Cursor hooks)"
|
|
109
|
+
SELF_REPORTED_NOTE = "SELF-REPORTED (an agent's claim, not evidence)"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Theme:
|
|
114
|
+
"""Style lookup; ``mono`` drops every colour (``NO_COLOR`` or ``--no-color``)."""
|
|
115
|
+
|
|
116
|
+
mono: bool = False
|
|
117
|
+
|
|
118
|
+
def lane(self, lane: str) -> str:
|
|
119
|
+
table = _MONO if self.mono else _COLOR
|
|
120
|
+
return table.get(lane, "")
|
|
121
|
+
|
|
122
|
+
def gate(self, state: str) -> str:
|
|
123
|
+
return (GATE_MONO if self.mono else GATE_COLOR).get(state, "")
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def accent(self) -> str:
|
|
127
|
+
return "bold" if self.mono else "bold cyan"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def dim(self) -> str:
|
|
131
|
+
return "dim"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def safe(value: object, limit: int = 160) -> str:
|
|
135
|
+
"""Printable, single-line, bounded text for untrusted values."""
|
|
136
|
+
text = "".join(ch if ch.isprintable() else "?" for ch in str(value))
|
|
137
|
+
return text if len(text) <= limit else text[: max(limit - 3, 0)] + "..."
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def short_sha(value: str | None, width: int = 8) -> str:
|
|
141
|
+
return value[:width] if value else "-"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fmt_ts(value: datetime | None) -> str:
|
|
145
|
+
"""Compact absolute UTC time; the UI states that all times are UTC."""
|
|
146
|
+
return "-" if value is None else utc(value).strftime("%m-%d %H:%M:%S")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fmt_age(now: datetime, then: datetime | None) -> str:
|
|
150
|
+
if then is None:
|
|
151
|
+
return "never"
|
|
152
|
+
seconds = int((utc(now) - utc(then)).total_seconds())
|
|
153
|
+
if seconds < 0:
|
|
154
|
+
return "clock skew"
|
|
155
|
+
if seconds < 60:
|
|
156
|
+
return f"{seconds}s ago"
|
|
157
|
+
if seconds < 3600:
|
|
158
|
+
return f"{seconds // 60}m ago"
|
|
159
|
+
if seconds < 86400:
|
|
160
|
+
return f"{seconds // 3600}h ago"
|
|
161
|
+
return f"{seconds // 86400}d ago"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fmt_duration(seconds: float) -> str:
|
|
165
|
+
total = int(seconds)
|
|
166
|
+
hours, rest = divmod(total, 3600)
|
|
167
|
+
minutes, secs = divmod(rest, 60)
|
|
168
|
+
if hours:
|
|
169
|
+
return f"{hours}h{minutes:02d}m{secs:02d}s"
|
|
170
|
+
if minutes:
|
|
171
|
+
return f"{minutes}m{secs:02d}s"
|
|
172
|
+
return f"{secs}s"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def plural(count: int, noun: str) -> str:
|
|
176
|
+
return f"{count} {noun}" + ("" if count == 1 else "s")
|