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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. 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)