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/app.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""The Textual application: one screen, six views, three responsive layouts.
|
|
2
|
+
|
|
3
|
+
The app reads through a :class:`DataSource` (SQLite projection + read-only git + artifact
|
|
4
|
+
scan) on a worker thread and renders pure ``Text`` produced by :mod:`cursorfleet.tui.views`.
|
|
5
|
+
It holds no prompts, thinking or file contents because none exist in its inputs, and it
|
|
6
|
+
makes no network calls.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual import events
|
|
19
|
+
from textual.app import App, ComposeResult
|
|
20
|
+
from textual.binding import Binding
|
|
21
|
+
from textual.containers import Container, VerticalScroll
|
|
22
|
+
from textual.widgets import Input, OptionList, Static
|
|
23
|
+
from textual.widgets.option_list import Option
|
|
24
|
+
|
|
25
|
+
from cursorfleet import __version__
|
|
26
|
+
from cursorfleet.adapters.cursor.kit_probe import HooksKitState
|
|
27
|
+
from cursorfleet.events.models import Event
|
|
28
|
+
from cursorfleet.state.event_store import DEFAULT_PAGE, MAX_PAGE, EventFilter
|
|
29
|
+
from cursorfleet.tui import views
|
|
30
|
+
from cursorfleet.tui.data import DataSource, FleetData
|
|
31
|
+
from cursorfleet.tui.filters import parse_timeline_filter
|
|
32
|
+
from cursorfleet.tui.help import HelpScreen
|
|
33
|
+
from cursorfleet.tui.layout import SINGLE, SPLIT, TILED, layout_for
|
|
34
|
+
from cursorfleet.tui.theme import Theme, fmt_ts, plural, safe
|
|
35
|
+
from cursorfleet.tui.watch import WatchFactory, default_watch_factory
|
|
36
|
+
|
|
37
|
+
DEFAULT_REFRESH_S = 2.0
|
|
38
|
+
PAGE_STEP = DEFAULT_PAGE
|
|
39
|
+
|
|
40
|
+
KEYS_WIDE = (
|
|
41
|
+
"q quit | ? help | / filter | j/k move | Enter detail | p pin | t tile | "
|
|
42
|
+
"a active run | u runs | o sessions | l timeline | w worktrees | g observations | "
|
|
43
|
+
"e evidence | r refresh"
|
|
44
|
+
)
|
|
45
|
+
KEYS_NARROW = "? help q quit / filter a u o l w g e p pin r refresh"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hooks_titlebar(data: FleetData, *, short: bool) -> str:
|
|
49
|
+
if data.telemetry == "hooks":
|
|
50
|
+
label = plural(len(data.sessions), "session")
|
|
51
|
+
return f"hooks {label}" if short else f"hooks: {label}"
|
|
52
|
+
if data.hooks_kit == HooksKitState.INSTALLED:
|
|
53
|
+
return "hooks ok (await Cursor)" if short else "hooks: installed, awaiting Cursor session"
|
|
54
|
+
if data.hooks_kit == HooksKitState.PARTIAL:
|
|
55
|
+
return "hooks partial" if short else "hooks: kit incomplete"
|
|
56
|
+
return "no telemetry" if short else "hooks: no telemetry"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Source(Protocol):
|
|
60
|
+
"""What the app needs from a data source (``DataSource`` or a test double)."""
|
|
61
|
+
|
|
62
|
+
def now(self) -> Any: ...
|
|
63
|
+
|
|
64
|
+
def load(
|
|
65
|
+
self, flt: EventFilter | None = None, *, limit: int = 200, force_git: bool = False
|
|
66
|
+
) -> FleetData: ...
|
|
67
|
+
|
|
68
|
+
def recent_tools(self, session_id: str, agent_key: str, limit: int = 6) -> list[Event]: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Tile(VerticalScroll, can_focus=False):
|
|
72
|
+
"""A read-only pane of the 2x2 grid."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FleetList(OptionList):
|
|
76
|
+
"""The primary list. Rows are ``Text``; headers are disabled options."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
CSS = """
|
|
80
|
+
Screen { layout: vertical; }
|
|
81
|
+
#titlebar { height: 1; padding: 0 1; background: $boost; }
|
|
82
|
+
#banner { height: auto; max-height: 3; padding: 0 1; display: none; }
|
|
83
|
+
#banner.has-problems { display: block; }
|
|
84
|
+
#filter { height: 1; border: none; padding: 0 1; display: none; }
|
|
85
|
+
#filter.open { display: block; }
|
|
86
|
+
#statusbar { height: 1; padding: 0 1; background: $boost; }
|
|
87
|
+
#body { height: 1fr; }
|
|
88
|
+
#list, #detail, .tile { border: round $primary; padding: 0 1; }
|
|
89
|
+
#list:focus, #detail:focus { border: heavy $accent; }
|
|
90
|
+
#body.single { layout: vertical; }
|
|
91
|
+
#body.single #detail, #body.single .tile { display: none; }
|
|
92
|
+
#body.single.show-detail #list { display: none; }
|
|
93
|
+
#body.single.show-detail #detail { display: block; height: 1fr; }
|
|
94
|
+
#body.split { layout: horizontal; }
|
|
95
|
+
#body.split #list { width: 45%; height: 1fr; }
|
|
96
|
+
#body.split #detail { width: 1fr; height: 1fr; }
|
|
97
|
+
#body.split .tile { display: none; }
|
|
98
|
+
#body.tiled { layout: grid; grid-size: 2 2; }
|
|
99
|
+
#body.tiled #list, #body.tiled #detail, #body.tiled .tile { width: 1fr; height: 1fr; }
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CursorFleetApp(App[None]):
|
|
104
|
+
"""CursorFleet v0.1 (Observe)."""
|
|
105
|
+
|
|
106
|
+
TITLE = "CursorFleet"
|
|
107
|
+
CSS = CSS
|
|
108
|
+
ENABLE_COMMAND_PALETTE = False
|
|
109
|
+
BINDINGS = [ # noqa: RUF012 - Textual convention
|
|
110
|
+
Binding("q", "quit", "Quit"),
|
|
111
|
+
Binding("question_mark", "help", "Help"),
|
|
112
|
+
Binding("slash", "filter", "Filter"),
|
|
113
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
114
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
115
|
+
Binding("p", "pin", "Pin"),
|
|
116
|
+
Binding("t", "tile", "Tile"),
|
|
117
|
+
Binding("a", "show('active_run')", "Active run"),
|
|
118
|
+
Binding("u", "show('active_runs')", "Runs"),
|
|
119
|
+
Binding("g", "show('gates')", "Observations"),
|
|
120
|
+
Binding("w", "show('worktrees')", "Worktrees"),
|
|
121
|
+
Binding("e", "show('evidence')", "Evidence"),
|
|
122
|
+
Binding("o", "show('overview')", "Sessions"),
|
|
123
|
+
Binding("l", "show('timeline')", "Timeline"),
|
|
124
|
+
Binding("r", "refresh", "Refresh"),
|
|
125
|
+
Binding("m", "more", "More"),
|
|
126
|
+
Binding("escape", "leave", "Back"),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def __init__( # noqa: PLR0913 - keyword-only wiring points, all optional
|
|
130
|
+
self,
|
|
131
|
+
source: Source,
|
|
132
|
+
*,
|
|
133
|
+
refresh_s: float = DEFAULT_REFRESH_S,
|
|
134
|
+
watch: bool = True,
|
|
135
|
+
watch_factory: WatchFactory | None = None,
|
|
136
|
+
spool_dir: str | None = None,
|
|
137
|
+
mono: bool | None = None,
|
|
138
|
+
initial_view: str | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
# Textual pops NO_COLOR from the environment in App.__init__: read it first.
|
|
141
|
+
no_color = os.environ.get("NO_COLOR") is not None if mono is None else mono
|
|
142
|
+
super().__init__()
|
|
143
|
+
self.source = source
|
|
144
|
+
self.refresh_s = max(0.25, refresh_s)
|
|
145
|
+
self.ui = views.UiState(theme=Theme(mono=no_color))
|
|
146
|
+
self.view = initial_view or views.default_view()
|
|
147
|
+
self.data = FleetData(now=source.now())
|
|
148
|
+
self.tile_enabled = False
|
|
149
|
+
self.detail_open = False
|
|
150
|
+
self.flash_message = ""
|
|
151
|
+
self._view_filters: dict[str, str] = {}
|
|
152
|
+
self._rows: list[views.Row] = []
|
|
153
|
+
self._signature: list[tuple[str, str, bool]] = []
|
|
154
|
+
self._loading = False
|
|
155
|
+
self._again = False
|
|
156
|
+
self._force_git = False
|
|
157
|
+
self._mode = SPLIT
|
|
158
|
+
self._width = 0
|
|
159
|
+
self._watch = watch
|
|
160
|
+
self._watch_factory = watch_factory
|
|
161
|
+
self._spool_dir = spool_dir
|
|
162
|
+
self.last_refresh_error: str | None = None
|
|
163
|
+
self._titlebar_plain = ""
|
|
164
|
+
self._fleet_ready = False
|
|
165
|
+
self._detail_plain = ""
|
|
166
|
+
self._tile_plain = ""
|
|
167
|
+
self.refresh_count = 0
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------ compose
|
|
170
|
+
|
|
171
|
+
def compose(self) -> ComposeResult:
|
|
172
|
+
yield Static(id="titlebar")
|
|
173
|
+
yield Static(id="banner")
|
|
174
|
+
yield Input(placeholder="filter (/ to open, Enter to apply, Esc to clear)", id="filter")
|
|
175
|
+
with Container(id="body", classes="split"):
|
|
176
|
+
yield FleetList(id="list")
|
|
177
|
+
with VerticalScroll(id="detail"):
|
|
178
|
+
yield Static(id="detail_text")
|
|
179
|
+
with Tile(id="tile_recent", classes="tile"):
|
|
180
|
+
yield Static(id="recent_text")
|
|
181
|
+
with Tile(id="tile_side", classes="tile"):
|
|
182
|
+
yield Static(id="side_text")
|
|
183
|
+
yield Static(id="statusbar")
|
|
184
|
+
|
|
185
|
+
async def on_mount(self) -> None:
|
|
186
|
+
self._apply_layout(self.size.width)
|
|
187
|
+
self.query_one("#list", FleetList).focus()
|
|
188
|
+
self._fleet_ready = True
|
|
189
|
+
await self.refresh_data()
|
|
190
|
+
self.set_interval(self.refresh_s, self._tick)
|
|
191
|
+
self._start_watcher()
|
|
192
|
+
|
|
193
|
+
async def on_resize(self, event: events.Resize) -> None:
|
|
194
|
+
self._apply_layout(event.size.width)
|
|
195
|
+
self._render_all()
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------ data
|
|
198
|
+
|
|
199
|
+
async def _tick(self) -> None:
|
|
200
|
+
await self.refresh_data()
|
|
201
|
+
|
|
202
|
+
async def refresh_data(self, *, force_git: bool = False) -> None:
|
|
203
|
+
"""Reload on a worker thread; overlapping requests coalesce into one more pass."""
|
|
204
|
+
self._force_git = self._force_git or force_git
|
|
205
|
+
if self._loading:
|
|
206
|
+
self._again = True
|
|
207
|
+
return
|
|
208
|
+
self._loading = True
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
self._again = False
|
|
212
|
+
force, self._force_git = self._force_git, False
|
|
213
|
+
flt, errors = self._timeline_filter()
|
|
214
|
+
try:
|
|
215
|
+
data = await asyncio.to_thread(
|
|
216
|
+
self.source.load, flt, limit=self.ui.timeline_limit, force_git=force
|
|
217
|
+
)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
self.last_refresh_error = type(exc).__name__
|
|
220
|
+
self.data.problems = [
|
|
221
|
+
f"refresh failed ({type(exc).__name__}); showing old data"
|
|
222
|
+
]
|
|
223
|
+
else:
|
|
224
|
+
data.timeline_errors = errors
|
|
225
|
+
self.last_refresh_error = None
|
|
226
|
+
self.data = data
|
|
227
|
+
self.refresh_count += 1
|
|
228
|
+
self._render_all()
|
|
229
|
+
if not self._again:
|
|
230
|
+
break
|
|
231
|
+
finally:
|
|
232
|
+
self._loading = False
|
|
233
|
+
|
|
234
|
+
def _timeline_filter(self) -> tuple[EventFilter | None, tuple[str, ...]]:
|
|
235
|
+
text = self._view_filters.get("timeline", "").strip()
|
|
236
|
+
if not text:
|
|
237
|
+
return None, ()
|
|
238
|
+
parsed = parse_timeline_filter(text, self.source.now())
|
|
239
|
+
return parsed.event, parsed.errors
|
|
240
|
+
|
|
241
|
+
def _start_watcher(self) -> None:
|
|
242
|
+
factory = self._watch_factory
|
|
243
|
+
if factory is None and self._watch:
|
|
244
|
+
factory = default_watch_factory()
|
|
245
|
+
directory = self._spool_dir or getattr(self.data, "runtime_dir", None)
|
|
246
|
+
if factory is None or directory is None:
|
|
247
|
+
return
|
|
248
|
+
spool = os.path.join(directory, "spool") if self._spool_dir is None else directory
|
|
249
|
+
if not os.path.isdir(spool):
|
|
250
|
+
return
|
|
251
|
+
self.run_worker(self._watch_loop(factory, spool), exclusive=False, name="spool-watch")
|
|
252
|
+
|
|
253
|
+
async def _watch_loop(self, factory: WatchFactory, spool: str) -> None:
|
|
254
|
+
try:
|
|
255
|
+
async for _batch in factory(spool):
|
|
256
|
+
await self.refresh_data()
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
self.flash(f"file watcher stopped ({type(exc).__name__}); polling continues")
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------ layout
|
|
261
|
+
|
|
262
|
+
def _apply_layout(self, width: int) -> None:
|
|
263
|
+
self._width = width
|
|
264
|
+
self._mode = layout_for(width, self.tile_enabled)
|
|
265
|
+
body = self.query_one("#body", Container)
|
|
266
|
+
body.remove_class(SINGLE, SPLIT, TILED)
|
|
267
|
+
body.add_class(self._mode)
|
|
268
|
+
body.set_class(self.detail_open and self._mode == SINGLE, "show-detail")
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def loading(self) -> bool:
|
|
272
|
+
return self._loading
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def layout_mode(self) -> str:
|
|
276
|
+
return self._mode
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------ rendering
|
|
279
|
+
|
|
280
|
+
def _render_all(self) -> None:
|
|
281
|
+
if not self._fleet_ready:
|
|
282
|
+
return
|
|
283
|
+
self._rows = views.rows_for(self.view, self.data, self.ui)
|
|
284
|
+
self._render_titlebar()
|
|
285
|
+
self._render_banner()
|
|
286
|
+
self._render_list()
|
|
287
|
+
self._render_detail()
|
|
288
|
+
self._render_tiles()
|
|
289
|
+
self._render_statusbar()
|
|
290
|
+
|
|
291
|
+
def _render_titlebar(self) -> None:
|
|
292
|
+
data = self.data
|
|
293
|
+
narrow = self._width < 100
|
|
294
|
+
blocked = data.blocked_count()
|
|
295
|
+
if narrow:
|
|
296
|
+
hooks = _hooks_titlebar(data, short=True)
|
|
297
|
+
git = (
|
|
298
|
+
"git off"
|
|
299
|
+
if not data.git_enabled
|
|
300
|
+
else ("git ok" if data.git and data.git.available else "no git")
|
|
301
|
+
)
|
|
302
|
+
parts = ["CursorFleet observe-only", views.VIEW_TITLE[self.view]]
|
|
303
|
+
if blocked:
|
|
304
|
+
parts.append(f"BLOCKED {blocked}")
|
|
305
|
+
parts.extend([hooks, git])
|
|
306
|
+
else:
|
|
307
|
+
hooks = _hooks_titlebar(data, short=False)
|
|
308
|
+
git = (
|
|
309
|
+
"git: off"
|
|
310
|
+
if not data.git_enabled
|
|
311
|
+
else ("git: ok" if data.git and data.git.available else "git: n/a")
|
|
312
|
+
)
|
|
313
|
+
parts = [f"CursorFleet {__version__} OBSERVE-ONLY", views.VIEW_TITLE[self.view]]
|
|
314
|
+
if blocked:
|
|
315
|
+
parts.append(f"BLOCKED: {blocked}")
|
|
316
|
+
parts.extend([hooks, git, f"UTC {fmt_ts(data.now)}"])
|
|
317
|
+
text = Text(" | ".join(parts))
|
|
318
|
+
self._titlebar_plain = text.plain
|
|
319
|
+
text.no_wrap = True
|
|
320
|
+
text.overflow = "ellipsis"
|
|
321
|
+
self.query_one("#titlebar", Static).update(text)
|
|
322
|
+
|
|
323
|
+
def _render_banner(self) -> None:
|
|
324
|
+
banner = self.query_one("#banner", Static)
|
|
325
|
+
problems = [safe(p, 200) for p in self.data.problems]
|
|
326
|
+
if problems:
|
|
327
|
+
banner.update(Text("! " + " | ".join(problems), style="bold"))
|
|
328
|
+
banner.add_class("has-problems")
|
|
329
|
+
else:
|
|
330
|
+
banner.update(Text(""))
|
|
331
|
+
banner.remove_class("has-problems")
|
|
332
|
+
|
|
333
|
+
def _render_list(self) -> None:
|
|
334
|
+
widget = self.query_one("#list", FleetList)
|
|
335
|
+
widget.border_title = views.VIEW_TITLE[self.view]
|
|
336
|
+
signature = [(r.key, r.label.plain, r.header) for r in self._rows]
|
|
337
|
+
if signature == self._signature:
|
|
338
|
+
return
|
|
339
|
+
self._signature = signature
|
|
340
|
+
wanted = self.ui.selected.get(self.view)
|
|
341
|
+
widget.clear_options()
|
|
342
|
+
widget.add_options(Option(r.label, id=r.key, disabled=r.header) for r in self._rows)
|
|
343
|
+
index = next(
|
|
344
|
+
(i for i, r in enumerate(self._rows) if r.key == wanted and not r.header), None
|
|
345
|
+
)
|
|
346
|
+
if index is None:
|
|
347
|
+
index = next((i for i, r in enumerate(self._rows) if not r.header), None)
|
|
348
|
+
if index is not None:
|
|
349
|
+
widget.highlighted = index
|
|
350
|
+
|
|
351
|
+
def _selected_key(self) -> str | None:
|
|
352
|
+
key = self.ui.selected.get(self.view)
|
|
353
|
+
if key is not None and any(r.key == key and not r.header for r in self._rows):
|
|
354
|
+
return key
|
|
355
|
+
return views.first_selectable(self._rows)
|
|
356
|
+
|
|
357
|
+
def _render_detail(self) -> None:
|
|
358
|
+
key = self._selected_key()
|
|
359
|
+
tools: list[Event] | None = None
|
|
360
|
+
if self.view == "overview" and key is not None:
|
|
361
|
+
bundle = self.data.bundles.get(key)
|
|
362
|
+
if bundle is not None and bundle.session is not None and bundle.view is not None:
|
|
363
|
+
tools = self.source.recent_tools(bundle.session.session_id, bundle.acc.key)
|
|
364
|
+
if self.view == "overview" and self.data.is_empty and key is None:
|
|
365
|
+
text = Text("\n\n".join(views.overview_guidance_lines(self.data)), style="")
|
|
366
|
+
else:
|
|
367
|
+
text = views.detail_for(self.view, self.data, self.ui, key, tools)
|
|
368
|
+
widget = self.query_one("#detail", VerticalScroll)
|
|
369
|
+
widget.border_title = "Detail"
|
|
370
|
+
self.query_one("#detail_text", Static).update(text)
|
|
371
|
+
self._detail_plain = text.plain
|
|
372
|
+
|
|
373
|
+
def _render_tiles(self) -> None:
|
|
374
|
+
recent = views.recent_events_text(self.data, self.ui)
|
|
375
|
+
side = views.side_summary_text(self.data, self.ui)
|
|
376
|
+
self.query_one("#recent_text", Static).update(recent)
|
|
377
|
+
self.query_one("#side_text", Static).update(side)
|
|
378
|
+
self.query_one("#tile_recent").border_title = "Recent events"
|
|
379
|
+
self.query_one("#tile_side").border_title = "Gates / worktrees"
|
|
380
|
+
self._tile_plain = recent.plain + "\n" + side.plain
|
|
381
|
+
|
|
382
|
+
def _render_statusbar(self) -> None:
|
|
383
|
+
keys = KEYS_WIDE if self._width >= 130 else KEYS_NARROW
|
|
384
|
+
text = Text()
|
|
385
|
+
flt = self._view_filters.get(self.view, "").strip()
|
|
386
|
+
if self.flash_message:
|
|
387
|
+
text.append(self.flash_message + " | ", style="bold")
|
|
388
|
+
if flt:
|
|
389
|
+
text.append(f"filter: {safe(flt, 40)} | ", style="bold")
|
|
390
|
+
text.append(keys)
|
|
391
|
+
text.no_wrap = True
|
|
392
|
+
text.overflow = "ellipsis"
|
|
393
|
+
self.query_one("#statusbar", Static).update(text)
|
|
394
|
+
|
|
395
|
+
def flash(self, message: str) -> None:
|
|
396
|
+
self.flash_message = message
|
|
397
|
+
if self._fleet_ready:
|
|
398
|
+
self._render_statusbar()
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------ introspection
|
|
401
|
+
|
|
402
|
+
def rendered_text(self) -> str:
|
|
403
|
+
"""All text currently drawn or drawable (titlebar, rows, detail, visible tiles).
|
|
404
|
+
|
|
405
|
+
Used by tests (privacy, snapshot-like assertions). Rows include off-screen ones.
|
|
406
|
+
"""
|
|
407
|
+
chunks = [self._titlebar_plain]
|
|
408
|
+
chunks.extend(p for p in self.data.problems)
|
|
409
|
+
chunks.extend(r.label.plain for r in self._rows)
|
|
410
|
+
chunks.append(self._detail_plain)
|
|
411
|
+
if self._mode == TILED:
|
|
412
|
+
chunks.append(self._tile_plain)
|
|
413
|
+
chunks.append(self.flash_message)
|
|
414
|
+
return "\n".join(chunks)
|
|
415
|
+
|
|
416
|
+
def row_keys(self) -> list[str]:
|
|
417
|
+
return [r.key for r in self._rows if not r.header]
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def selected_key(self) -> str | None:
|
|
421
|
+
return self._selected_key()
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------ events
|
|
424
|
+
|
|
425
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
426
|
+
if event.option_list.id != "list" or event.option.id is None:
|
|
427
|
+
return
|
|
428
|
+
self.ui.selected[self.view] = event.option.id
|
|
429
|
+
self._render_detail()
|
|
430
|
+
|
|
431
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
432
|
+
if event.option_list.id == "list":
|
|
433
|
+
self.action_open_detail()
|
|
434
|
+
|
|
435
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
436
|
+
if event.input.id != "filter":
|
|
437
|
+
return
|
|
438
|
+
self._view_filters[self.view] = event.value
|
|
439
|
+
self.ui.filter_text = event.value
|
|
440
|
+
if self.view == "timeline":
|
|
441
|
+
self.run_worker(self.refresh_data(), exclusive=False)
|
|
442
|
+
else:
|
|
443
|
+
self._render_all()
|
|
444
|
+
|
|
445
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
446
|
+
if event.input.id == "filter":
|
|
447
|
+
self.query_one("#list", FleetList).focus()
|
|
448
|
+
|
|
449
|
+
# ------------------------------------------------------------------ actions
|
|
450
|
+
|
|
451
|
+
def action_help(self) -> None:
|
|
452
|
+
if not isinstance(self.screen, HelpScreen):
|
|
453
|
+
self.push_screen(HelpScreen())
|
|
454
|
+
|
|
455
|
+
def action_filter(self) -> None:
|
|
456
|
+
box = self.query_one("#filter", Input)
|
|
457
|
+
box.add_class("open")
|
|
458
|
+
box.value = self._view_filters.get(self.view, "")
|
|
459
|
+
box.focus()
|
|
460
|
+
box.cursor_position = len(box.value)
|
|
461
|
+
|
|
462
|
+
def action_leave(self) -> None:
|
|
463
|
+
box = self.query_one("#filter", Input)
|
|
464
|
+
if self.focused is box:
|
|
465
|
+
self._close_filter(clear=True)
|
|
466
|
+
return
|
|
467
|
+
if self.focused is self.query_one("#detail"):
|
|
468
|
+
self.query_one("#list", FleetList).focus()
|
|
469
|
+
if self.detail_open:
|
|
470
|
+
self.detail_open = False
|
|
471
|
+
self._apply_layout(self._width)
|
|
472
|
+
self.query_one("#list", FleetList).focus()
|
|
473
|
+
return
|
|
474
|
+
if self._view_filters.get(self.view):
|
|
475
|
+
self._close_filter(clear=True)
|
|
476
|
+
|
|
477
|
+
def _close_filter(self, *, clear: bool) -> None:
|
|
478
|
+
box = self.query_one("#filter", Input)
|
|
479
|
+
if clear:
|
|
480
|
+
box.value = ""
|
|
481
|
+
self._view_filters[self.view] = ""
|
|
482
|
+
self.ui.filter_text = ""
|
|
483
|
+
box.remove_class("open")
|
|
484
|
+
self.query_one("#list", FleetList).focus()
|
|
485
|
+
if self.view == "timeline":
|
|
486
|
+
self.run_worker(self.refresh_data(), exclusive=False)
|
|
487
|
+
else:
|
|
488
|
+
self._render_all()
|
|
489
|
+
|
|
490
|
+
def action_show(self, view: str) -> None:
|
|
491
|
+
if view not in views.VIEWS:
|
|
492
|
+
return
|
|
493
|
+
self.view = view
|
|
494
|
+
self.detail_open = False
|
|
495
|
+
self.ui.filter_text = self._view_filters.get(view, "")
|
|
496
|
+
box = self.query_one("#filter", Input)
|
|
497
|
+
box.value = self.ui.filter_text
|
|
498
|
+
box.set_class(bool(self.ui.filter_text), "open")
|
|
499
|
+
self._apply_layout(self._width)
|
|
500
|
+
self.query_one("#list", FleetList).focus()
|
|
501
|
+
self._signature = []
|
|
502
|
+
self.flash("")
|
|
503
|
+
if view == "timeline":
|
|
504
|
+
self.run_worker(self.refresh_data(), exclusive=False)
|
|
505
|
+
else:
|
|
506
|
+
self._render_all()
|
|
507
|
+
|
|
508
|
+
def action_cursor_down(self) -> None:
|
|
509
|
+
self._move(1)
|
|
510
|
+
|
|
511
|
+
def action_cursor_up(self) -> None:
|
|
512
|
+
self._move(-1)
|
|
513
|
+
|
|
514
|
+
def _move(self, step: int) -> None:
|
|
515
|
+
focused = self.focused
|
|
516
|
+
if isinstance(focused, VerticalScroll):
|
|
517
|
+
focused.scroll_down() if step > 0 else focused.scroll_up()
|
|
518
|
+
return
|
|
519
|
+
if isinstance(focused, Input):
|
|
520
|
+
return
|
|
521
|
+
widget = self.query_one("#list", FleetList)
|
|
522
|
+
widget.action_cursor_down() if step > 0 else widget.action_cursor_up()
|
|
523
|
+
|
|
524
|
+
def action_open_detail(self) -> None:
|
|
525
|
+
if self._mode == SINGLE:
|
|
526
|
+
self.detail_open = True
|
|
527
|
+
self._apply_layout(self._width)
|
|
528
|
+
self.query_one("#detail", VerticalScroll).focus()
|
|
529
|
+
|
|
530
|
+
def action_pin(self) -> None:
|
|
531
|
+
key = self._selected_key()
|
|
532
|
+
if self.view != "overview" or key is None or key not in self.data.bundles:
|
|
533
|
+
self.flash("pin works on agents in the overview (o)")
|
|
534
|
+
return
|
|
535
|
+
if key in self.ui.pinned:
|
|
536
|
+
self.ui.pinned.discard(key)
|
|
537
|
+
self.flash("unpinned")
|
|
538
|
+
else:
|
|
539
|
+
self.ui.pinned.add(key)
|
|
540
|
+
self.flash("pinned (kept in memory for this session only)")
|
|
541
|
+
self._signature = []
|
|
542
|
+
self._render_all()
|
|
543
|
+
|
|
544
|
+
def action_tile(self) -> None:
|
|
545
|
+
self.tile_enabled = not self.tile_enabled
|
|
546
|
+
self._apply_layout(self._width)
|
|
547
|
+
if self.tile_enabled and self._mode != TILED:
|
|
548
|
+
self.flash("tiling applies at 160+ columns; widen the terminal")
|
|
549
|
+
else:
|
|
550
|
+
self.flash("tiled panes on" if self.tile_enabled else "tiled panes off")
|
|
551
|
+
self._render_all()
|
|
552
|
+
|
|
553
|
+
async def action_refresh(self) -> None:
|
|
554
|
+
self.flash("re-read (no fetch)")
|
|
555
|
+
await self.refresh_data(force_git=True)
|
|
556
|
+
|
|
557
|
+
def action_more(self) -> None:
|
|
558
|
+
if self.view != "timeline":
|
|
559
|
+
self.flash("m loads more events in the timeline (l)")
|
|
560
|
+
return
|
|
561
|
+
if self.ui.timeline_limit >= MAX_PAGE:
|
|
562
|
+
self.flash(f"timeline is capped at {MAX_PAGE} events; narrow the filter with /")
|
|
563
|
+
return
|
|
564
|
+
self.ui.timeline_limit = min(MAX_PAGE, self.ui.timeline_limit + PAGE_STEP)
|
|
565
|
+
self.run_worker(self.refresh_data(), exclusive=False)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def build_app(
|
|
569
|
+
repo: str | Path,
|
|
570
|
+
*,
|
|
571
|
+
refresh_s: float = DEFAULT_REFRESH_S,
|
|
572
|
+
with_git: bool = True,
|
|
573
|
+
watch: bool = True,
|
|
574
|
+
clock: Callable[[], Any] | None = None,
|
|
575
|
+
) -> CursorFleetApp:
|
|
576
|
+
"""The production wiring used by ``cursorfleet tui``."""
|
|
577
|
+
refresh = max(0.25, refresh_s)
|
|
578
|
+
# Git is slow relative to the spool: re-collect about every 10 s however fast we poll.
|
|
579
|
+
git_every = max(1, round(10 / refresh))
|
|
580
|
+
source = DataSource(repo, with_git=with_git, clock=clock, git_every=git_every)
|
|
581
|
+
return CursorFleetApp(source, refresh_s=refresh, watch=watch)
|