cortexshift 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 (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
cortexshift/tui/app.py ADDED
@@ -0,0 +1,1051 @@
1
+ """The CortexShift interactive terminal control center.
2
+
3
+ This is an adapter, not a backend. Every fact it renders and every action it performs is
4
+ produced by `TuiFacade` on top of the existing application services; the dashboard itself
5
+ contains no SQL, opens no database, runs no Git command, and builds no provider argv.
6
+
7
+ Two rules shape its runtime behaviour:
8
+
9
+ 1. **The dashboard never holds the workspace lease.** Observing and editing task state
10
+ must never block a coding agent, so the lease is only taken inside the services that
11
+ genuinely require exclusivity (run, resume, switch, recover).
12
+ 2. **The terminal is released before a provider starts.** Choosing run, resume, or switch
13
+ exits the Textual application with a `TuiExitRequest`. Only after `App.run()` has
14
+ returned does the coordinator launch the native provider, which then owns the terminal
15
+ directly. No provider TUI is ever embedded, scraped, or multiplexed.
16
+ """
17
+
18
+ import traceback
19
+ from collections.abc import Callable, Iterable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import ClassVar, cast
23
+
24
+ from rich.text import Text
25
+ from textual import events, work
26
+ from textual.app import App, ComposeResult
27
+ from textual.binding import Binding
28
+ from textual.command import DiscoveryHit, Hit, Hits, Provider
29
+ from textual.containers import Horizontal, Vertical
30
+ from textual.widgets import ContentSwitcher, DataTable, Footer, Header, OptionList, Static
31
+ from textual.widgets.option_list import Option
32
+ from textual.worker import WorkerState
33
+
34
+ from cortexshift.domain.checkpoint import CheckpointRecord
35
+ from cortexshift.domain.errors import CortexShiftError
36
+ from cortexshift.domain.task import Task
37
+ from cortexshift.tui.actions import TuiExitAction, TuiExitRequest
38
+ from cortexshift.tui.facade import TuiFacade
39
+ from cortexshift.tui.modals import (
40
+ AddRemainingModal,
41
+ CheckpointInput,
42
+ CheckpointModal,
43
+ ConfirmModal,
44
+ CurrentWorkInput,
45
+ InfoModal,
46
+ MarkCompletedModal,
47
+ ProviderActionModal,
48
+ ProviderActionOption,
49
+ RecordIssueModal,
50
+ SetCurrentWorkModal,
51
+ render_handoff_preview,
52
+ render_recovery_preview,
53
+ render_switch_preview,
54
+ )
55
+ from cortexshift.tui.models import (
56
+ TuiHandoffPreview,
57
+ TuiMcpStatus,
58
+ TuiProviderStatus,
59
+ TuiRecoveryPreview,
60
+ TuiRepositoryModel,
61
+ TuiStateSnapshot,
62
+ TuiSwitchPreview,
63
+ WorkspaceActivity,
64
+ format_relative,
65
+ )
66
+ from cortexshift.tui.screens import SECTION_ORDER, SectionView, TuiSection
67
+ from cortexshift.tui.screens.checkpoints import CheckpointsSection
68
+ from cortexshift.tui.screens.handoffs import HandoffsSection
69
+ from cortexshift.tui.screens.help import HelpScreen
70
+ from cortexshift.tui.screens.overview import OverviewSection
71
+ from cortexshift.tui.screens.providers import ProvidersSection
72
+ from cortexshift.tui.screens.repository import RepositorySection
73
+ from cortexshift.tui.screens.sessions import SessionsSection
74
+ from cortexshift.tui.screens.task import TaskSection
75
+
76
+ STATE_REFRESH_SECONDS = 2.0
77
+ # The worker group the lightweight state reload runs in. Named so the timer can ask
78
+ # whether a reload is already in flight rather than starting one on top of it.
79
+ STATE_WORKER_GROUP = "cortexshift-state"
80
+ COMPACT_WIDTH = 90
81
+ MINIMUM_WIDTH = 60
82
+ MINIMUM_HEIGHT = 12
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ServiceCall:
87
+ """A service invocation dispatched to a worker thread.
88
+
89
+ Application services perform file, subprocess, and database I/O; running them on the
90
+ UI thread would stall rendering, so every one of them is routed through here.
91
+ """
92
+
93
+ run: Callable[[], object]
94
+ complete: Callable[[object], None]
95
+ failure_title: str
96
+
97
+
98
+ class CortexShiftCommands(Provider):
99
+ """Semantic actions exposed through Textual's built-in command palette."""
100
+
101
+ @property
102
+ def _app(self) -> "CortexShiftApp":
103
+ return cast("CortexShiftApp", self.app)
104
+
105
+ def _commands(self) -> Iterable[tuple[str, str, Callable[[], None]]]:
106
+ app = self._app
107
+ for section in SECTION_ORDER:
108
+ yield (
109
+ f"Go to {section.label}",
110
+ f"Show the {section.label} section ({section.shortcut})",
111
+ _section_callback(app, section),
112
+ )
113
+ yield ("Refresh all", "Reload state, repository, and provider status", app.action_refresh)
114
+ yield (
115
+ "Refresh repository",
116
+ "Run a live read-only Git inspection",
117
+ app.refresh_repository,
118
+ )
119
+ yield ("Create checkpoint", "Capture a MANUAL checkpoint", app.action_checkpoint)
120
+ yield ("Set current work", "Update the active task's in-flight work", app.action_set_work)
121
+ yield ("Mark item completed", "Complete one remaining item", app.action_mark_completed)
122
+ yield ("Add remaining item", "Record newly discovered work", app.action_add_remaining)
123
+ yield ("Record issue", "Record a blocker on the active task", app.action_record_issue)
124
+ yield (
125
+ "Provider action",
126
+ "Run, resume, or switch a native provider",
127
+ app.action_provider_actions,
128
+ )
129
+ yield (
130
+ "Preview handoff",
131
+ "Render canonical handoff context without launching anything",
132
+ app.action_preview_handoff,
133
+ )
134
+ yield (
135
+ "Recover workspace",
136
+ "Reconcile unfinalized sessions through RecoveryService",
137
+ app.action_recover,
138
+ )
139
+ yield ("Help", "Show the keyboard contract", app.action_help)
140
+
141
+ async def search(self, query: str) -> Hits:
142
+ """Yield palette hits matching the operator's query."""
143
+ matcher = self.matcher(query)
144
+ for name, help_text, callback in self._commands():
145
+ score = matcher.match(name)
146
+ if score > 0:
147
+ yield Hit(score, matcher.highlight(name), callback, help=help_text)
148
+
149
+ async def discover(self) -> Hits:
150
+ """Yield the full command list before the operator types anything."""
151
+ for name, help_text, callback in self._commands():
152
+ yield DiscoveryHit(name, callback, help=help_text)
153
+
154
+
155
+ def _section_callback(app: "CortexShiftApp", section: TuiSection) -> Callable[[], None]:
156
+ """Bind a section switch for the command palette."""
157
+
158
+ def callback() -> None:
159
+ app.show_section(section)
160
+
161
+ return callback
162
+
163
+
164
+ class CortexShiftApp(App[TuiExitRequest | None]):
165
+ """The CortexShift dashboard."""
166
+
167
+ CSS_PATH = "cortexshift.tcss"
168
+ TITLE = "CortexShift"
169
+ COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = App.COMMANDS | {
170
+ CortexShiftCommands
171
+ }
172
+
173
+ BINDINGS = [
174
+ Binding("1", "section('overview')", "Overview", show=False),
175
+ Binding("2", "section('task')", "Task", show=False),
176
+ Binding("3", "section('repository')", "Repository", show=False),
177
+ Binding("4", "section('sessions')", "Sessions", show=False),
178
+ Binding("5", "section('checkpoints')", "Checkpoints", show=False),
179
+ Binding("6", "section('handoffs')", "Handoffs", show=False),
180
+ Binding("7", "section('providers')", "Providers", show=False),
181
+ Binding("r", "refresh", "Refresh", show=True),
182
+ Binding("c", "checkpoint", "Checkpoint", show=True),
183
+ Binding("x", "provider_actions", "Provider", show=True),
184
+ Binding("w", "set_work", "Set work", show=False),
185
+ Binding("m", "mark_completed", "Complete", show=False),
186
+ Binding("n", "add_remaining", "Add item", show=False),
187
+ Binding("i", "record_issue", "Issue", show=False),
188
+ Binding("a", "activate_task", "Activate", show=False),
189
+ Binding("p", "preview_handoff", "Preview", show=False),
190
+ Binding("g", "setup_antigravity_mcp", "MCP setup", show=False),
191
+ Binding("R", "recover", "Recover", show=False),
192
+ Binding("question_mark", "help", "Help", show=True),
193
+ Binding("q", "quit", "Quit", show=True),
194
+ ]
195
+
196
+ def __init__(
197
+ self,
198
+ facade: TuiFacade,
199
+ *,
200
+ state_refresh_seconds: float = STATE_REFRESH_SECONDS,
201
+ initial_section: TuiSection = TuiSection.OVERVIEW,
202
+ ) -> None:
203
+ super().__init__()
204
+ self.facade = facade
205
+ self._state_refresh_seconds = state_refresh_seconds
206
+ self._initial_section = initial_section
207
+
208
+ self._snapshot: TuiStateSnapshot | None = None
209
+ self._repository: TuiRepositoryModel | None = None
210
+ self._providers: tuple[TuiProviderStatus, ...] = ()
211
+ self._mcp: TuiMcpStatus | None = None
212
+ self._workspace_activity = WorkspaceActivity.UNKNOWN
213
+
214
+ self._state_generation = 0
215
+ self._last_state_error: str | None = None
216
+ self._repository_generation = 0
217
+ self._provider_generation = 0
218
+ self._syncing_nav = False
219
+ self._active_section = initial_section
220
+
221
+ # ------------------------------------------------------------------
222
+ # Composition
223
+ # ------------------------------------------------------------------
224
+
225
+ def compose(self) -> ComposeResult:
226
+ """Build the dashboard shell.
227
+
228
+ The shell renders before any state loads, so startup never blocks on Git or
229
+ provider probes.
230
+ """
231
+ yield Header(show_clock=False)
232
+ with Horizontal(id="shell"):
233
+ with Vertical(id="sidebar"):
234
+ yield Static(Text("CortexShift", style="bold"), id="sidebar-title")
235
+ yield OptionList(id="nav")
236
+ with ContentSwitcher(id="content", initial=self._initial_section.value):
237
+ yield OverviewSection(id=TuiSection.OVERVIEW.value)
238
+ yield TaskSection(id=TuiSection.TASK.value)
239
+ yield RepositorySection(id=TuiSection.REPOSITORY.value)
240
+ yield SessionsSection(id=TuiSection.SESSIONS.value)
241
+ yield CheckpointsSection(id=TuiSection.CHECKPOINTS.value)
242
+ yield HandoffsSection(id=TuiSection.HANDOFFS.value)
243
+ yield ProvidersSection(id=TuiSection.PROVIDERS.value)
244
+ yield Static(
245
+ Text(
246
+ "This terminal is too small for the CortexShift dashboard.\n\n"
247
+ "Resize to at least 60 x 12 (80 x 24 recommended) and it will return.",
248
+ style="bold",
249
+ ),
250
+ id="too-small",
251
+ )
252
+ yield Static("", id="status-line")
253
+ yield Footer()
254
+
255
+ def on_mount(self) -> None:
256
+ """Populate navigation, then load state in the background."""
257
+ nav = self.query_one("#nav", OptionList)
258
+ nav.add_options(
259
+ [
260
+ Option(f"{section.shortcut} {section.label}", id=section.value)
261
+ for section in SECTION_ORDER
262
+ ]
263
+ )
264
+ self._sync_nav_highlight()
265
+ self._render_status_line()
266
+
267
+ self.refresh_state()
268
+ self.refresh_repository()
269
+ self.refresh_providers()
270
+ self.set_interval(self._state_refresh_seconds, self._on_state_tick)
271
+
272
+ # ------------------------------------------------------------------
273
+ # Navigation
274
+ # ------------------------------------------------------------------
275
+
276
+ @property
277
+ def active_section(self) -> TuiSection:
278
+ """The section currently visible in the content area."""
279
+ return self._active_section
280
+
281
+ def show_section(self, section: TuiSection) -> None:
282
+ """Switch to a primary section and let it react to becoming visible."""
283
+ self._active_section = section
284
+ self.query_one("#content", ContentSwitcher).current = section.value
285
+ self._sync_nav_highlight()
286
+ self._render_status_line()
287
+
288
+ view = self.section_view(section)
289
+ view.on_section_shown()
290
+
291
+ # Live Git runs on screen entry and explicit refresh only — never on the
292
+ # lightweight state timer, so large repositories are not hammered.
293
+ if section is TuiSection.REPOSITORY:
294
+ self.refresh_repository()
295
+ elif section is TuiSection.PROVIDERS and not self._providers:
296
+ self.refresh_providers()
297
+
298
+ def section_view(self, section: TuiSection) -> SectionView:
299
+ """Return the view object for a section."""
300
+ return self.query_one(f"#{section.value}", SectionView)
301
+
302
+ def action_section(self, section: str) -> None:
303
+ """Jump to a primary section by identifier."""
304
+ self.show_section(TuiSection(section))
305
+
306
+ def _sync_nav_highlight(self) -> None:
307
+ nav = self.query_one("#nav", OptionList)
308
+ self._syncing_nav = True
309
+ try:
310
+ nav.highlighted = SECTION_ORDER.index(self._active_section)
311
+ finally:
312
+ self._syncing_nav = False
313
+
314
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
315
+ """Follow sidebar navigation with the keyboard."""
316
+ if event.option_list.id != "nav" or self._syncing_nav:
317
+ return
318
+ event.stop()
319
+ section = SECTION_ORDER[event.option_index]
320
+ if section is not self._active_section:
321
+ self.show_section(section)
322
+
323
+ # ------------------------------------------------------------------
324
+ # Refresh: lightweight persisted state
325
+ # ------------------------------------------------------------------
326
+
327
+ def _on_state_tick(self) -> None:
328
+ """Lightweight timer tick.
329
+
330
+ Reloads persisted state only. Another agent may be updating the canonical Task
331
+ through MCP while this dashboard is open, and those updates must surface here.
332
+ Git is never inspected on this path.
333
+
334
+ A tick that arrives while the previous reload is still running is skipped rather
335
+ than replacing it. The reload is exclusive, so starting another would cancel the
336
+ one in flight and the generation guard would drop its result: on a machine where
337
+ reading state takes longer than the interval, every tick would cancel the reload
338
+ that was about to finish and the dashboard would silently stop updating. Skipping
339
+ keeps the timer from starving the very refresh it exists to perform. An operator
340
+ asking for a refresh still supersedes -- see `action_refresh`.
341
+ """
342
+ if self._state_refresh_running():
343
+ return
344
+ self.refresh_state()
345
+
346
+ def _state_refresh_running(self) -> bool:
347
+ """Whether a state reload is in flight right now."""
348
+ return any(
349
+ worker.group == STATE_WORKER_GROUP and worker.state is WorkerState.RUNNING
350
+ for worker in self.workers
351
+ )
352
+
353
+ def refresh_state(self) -> None:
354
+ """Reload persisted read models in an exclusive worker."""
355
+ self._state_generation += 1
356
+ self._load_state(self._state_generation)
357
+
358
+ @work(exclusive=True, group=STATE_WORKER_GROUP, thread=True, exit_on_error=False)
359
+ def _load_state(self, generation: int) -> None:
360
+ try:
361
+ snapshot = self.facade.load_state()
362
+ except Exception as exc:
363
+ self.call_from_thread(self._on_state_error, exc)
364
+ return
365
+ self.call_from_thread(self._apply_state, generation, snapshot)
366
+
367
+ def _on_state_error(self, error: Exception) -> None:
368
+ """Report a state refresh failure once, not on every tick.
369
+
370
+ This path runs on a timer. If the database becomes unreadable the operator needs
371
+ to be told, but repeating the same notification every couple of seconds would
372
+ bury the dashboard rather than inform them.
373
+ """
374
+ signature = f"{type(error).__name__}: {error}"
375
+ if signature == self._last_state_error:
376
+ return
377
+ self._last_state_error = signature
378
+ self._report_error("State refresh failed", error)
379
+
380
+ def _apply_state(self, generation: int, snapshot: TuiStateSnapshot) -> None:
381
+ """Apply a state result, discarding any that a newer refresh has superseded."""
382
+ self._last_state_error = None
383
+ if generation != self._state_generation:
384
+ return
385
+ self._snapshot = snapshot
386
+ for section in SECTION_ORDER:
387
+ self.section_view(section).update_state(snapshot)
388
+ self._render_status_line()
389
+
390
+ # ------------------------------------------------------------------
391
+ # Refresh: live repository (native Git, worker only)
392
+ # ------------------------------------------------------------------
393
+
394
+ def refresh_repository(self) -> None:
395
+ """Run a live Git inspection in an exclusive worker.
396
+
397
+ `exclusive=True` cancels an in-flight inspection, and the generation guard drops
398
+ any late result, so rapid refreshes can never leave older data on screen.
399
+ """
400
+ self._repository_generation += 1
401
+ self._inspect_repository(self._repository_generation)
402
+
403
+ @work(exclusive=True, group="cortexshift-repository", thread=True, exit_on_error=False)
404
+ def _inspect_repository(self, generation: int) -> None:
405
+ try:
406
+ repository = self.facade.inspect_repository()
407
+ except Exception as exc:
408
+ self.call_from_thread(self._report_error, "Repository inspection failed", exc)
409
+ return
410
+ self.call_from_thread(self._apply_repository, generation, repository)
411
+
412
+ def _apply_repository(self, generation: int, repository: TuiRepositoryModel) -> None:
413
+ """Apply a repository result unless a newer inspection has already started."""
414
+ if generation != self._repository_generation:
415
+ return
416
+ self._repository = repository
417
+ for section in SECTION_ORDER:
418
+ self.section_view(section).update_repository(repository)
419
+ self._render_status_line()
420
+
421
+ # ------------------------------------------------------------------
422
+ # Refresh: provider discovery and MCP status (worker only)
423
+ # ------------------------------------------------------------------
424
+
425
+ def refresh_providers(self, *, force: bool = False) -> None:
426
+ """Probe provider CLIs and MCP integration in an exclusive worker."""
427
+ self._provider_generation += 1
428
+ self._discover_providers(self._provider_generation, force)
429
+
430
+ @work(exclusive=True, group="cortexshift-providers", thread=True, exit_on_error=False)
431
+ def _discover_providers(self, generation: int, force: bool) -> None:
432
+ try:
433
+ providers = self.facade.provider_status(refresh=force)
434
+ mcp = self.facade.mcp_status()
435
+ activity = self.facade.workspace_activity()
436
+ except Exception as exc:
437
+ self.call_from_thread(self._report_error, "Provider discovery failed", exc)
438
+ return
439
+ self.call_from_thread(self._apply_providers, generation, providers, mcp, activity)
440
+
441
+ def _apply_providers(
442
+ self,
443
+ generation: int,
444
+ providers: tuple[TuiProviderStatus, ...],
445
+ mcp: TuiMcpStatus,
446
+ activity: WorkspaceActivity,
447
+ ) -> None:
448
+ """Apply provider results unless a newer probe has already started."""
449
+ if generation != self._provider_generation:
450
+ return
451
+ self._providers = providers
452
+ self._mcp = mcp
453
+ self._workspace_activity = activity
454
+ for section in SECTION_ORDER:
455
+ view = self.section_view(section)
456
+ view.update_providers(providers, mcp)
457
+ view.update_activity(activity)
458
+ self._render_status_line()
459
+
460
+ def action_refresh(self) -> None:
461
+ """Refresh everything, including live Git and provider discovery."""
462
+ self.refresh_state()
463
+ self.refresh_repository()
464
+ self.refresh_providers(force=True)
465
+ self.notify("Refreshing state, repository, and providers…", timeout=2)
466
+
467
+ # ------------------------------------------------------------------
468
+ # Service dispatch
469
+ # ------------------------------------------------------------------
470
+
471
+ def dispatch(self, call: ServiceCall) -> None:
472
+ """Run an application service call off the UI thread."""
473
+ self._service_worker(call)
474
+
475
+ @work(group="cortexshift-service", thread=True, exit_on_error=False)
476
+ def _service_worker(self, call: ServiceCall) -> None:
477
+ try:
478
+ result = call.run()
479
+ except Exception as exc:
480
+ self.call_from_thread(self._report_error, call.failure_title, exc)
481
+ return
482
+ self.call_from_thread(call.complete, result)
483
+
484
+ def _report_error(self, title: str, error: Exception) -> None:
485
+ """Surface an error without taking the dashboard down.
486
+
487
+ Expected CortexShift errors are reported in their own words. Anything else is
488
+ reported as unexpected and logged in full — never silently swallowed.
489
+ """
490
+ if isinstance(error, CortexShiftError):
491
+ self.notify(str(error), title=title, severity="error", timeout=8)
492
+ return
493
+ self.log.error(f"{title}: {error!r}\n{traceback.format_exc()}")
494
+ self.notify(
495
+ f"{type(error).__name__}: {error}",
496
+ title=f"{title} (unexpected)",
497
+ severity="error",
498
+ timeout=10,
499
+ )
500
+
501
+ # ------------------------------------------------------------------
502
+ # Task actions
503
+ # ------------------------------------------------------------------
504
+
505
+ def _require_active_task(self) -> bool:
506
+ if self._snapshot is None or self._snapshot.active_task is None:
507
+ self.notify(
508
+ "No active CortexShift task. Activate one on the Task screen first.",
509
+ title="No active task",
510
+ severity="warning",
511
+ )
512
+ return False
513
+ return True
514
+
515
+ def action_set_work(self) -> None:
516
+ """Open the set-current-work dialog for the active task."""
517
+ if not self._require_active_task():
518
+ return
519
+ assert self._snapshot is not None and self._snapshot.active_task is not None
520
+ initial = self._snapshot.active_task.current_work or ""
521
+
522
+ def completed(value: object) -> None:
523
+ # A cancel returns nothing; only a confirmed entry reaches the service, so
524
+ # dismissing the dialog can never clear the operator's current work.
525
+ if not isinstance(value, CurrentWorkInput):
526
+ return
527
+ entry = value.value
528
+ message = "Current work updated." if entry else "Current work cleared."
529
+ self.dispatch(
530
+ ServiceCall(
531
+ run=lambda: self.facade.set_current_work(entry),
532
+ complete=self._on_task_mutated(message),
533
+ failure_title="Could not set current work",
534
+ )
535
+ )
536
+
537
+ self.push_screen(SetCurrentWorkModal(initial), completed)
538
+
539
+ def action_mark_completed(self) -> None:
540
+ """Open the mark-completed dialog for the active task."""
541
+ if not self._require_active_task():
542
+ return
543
+ assert self._snapshot is not None and self._snapshot.active_task is not None
544
+ remaining = self._snapshot.active_task.remaining
545
+
546
+ def completed(value: object) -> None:
547
+ if value is None:
548
+ return
549
+ item = cast(str, value)
550
+ self.dispatch(
551
+ ServiceCall(
552
+ run=lambda: self.facade.mark_completed([item]),
553
+ complete=self._on_task_mutated(f"Marked completed: {item}"),
554
+ failure_title="Could not mark item completed",
555
+ )
556
+ )
557
+
558
+ self.push_screen(MarkCompletedModal(remaining), completed)
559
+
560
+ def action_add_remaining(self) -> None:
561
+ """Open the add-remaining-item dialog for the active task."""
562
+ if not self._require_active_task():
563
+ return
564
+
565
+ def completed(value: object) -> None:
566
+ if value is None:
567
+ return
568
+ item = cast(str, value)
569
+ self.dispatch(
570
+ ServiceCall(
571
+ run=lambda: self.facade.add_remaining([item]),
572
+ complete=self._on_task_mutated(f"Added remaining item: {item}"),
573
+ failure_title="Could not add remaining item",
574
+ )
575
+ )
576
+
577
+ self.push_screen(AddRemainingModal(), completed)
578
+
579
+ def action_record_issue(self) -> None:
580
+ """Open the record-issue dialog for the active task."""
581
+ if not self._require_active_task():
582
+ return
583
+
584
+ def completed(value: object) -> None:
585
+ if value is None:
586
+ return
587
+ item = cast(str, value)
588
+ self.dispatch(
589
+ ServiceCall(
590
+ run=lambda: self.facade.record_issues([item]),
591
+ complete=self._on_task_mutated(f"Recorded issue: {item}"),
592
+ failure_title="Could not record issue",
593
+ )
594
+ )
595
+
596
+ self.push_screen(RecordIssueModal(), completed)
597
+
598
+ def action_activate_task(self) -> None:
599
+ """Activate the task selected on the Task screen."""
600
+ if self._active_section is not TuiSection.TASK:
601
+ self.show_section(TuiSection.TASK)
602
+ return
603
+
604
+ view = cast(TaskSection, self.section_view(TuiSection.TASK))
605
+ row = view.selected_task
606
+ if row is None:
607
+ self.notify("No task is selected.", severity="warning")
608
+ return
609
+ if row.is_active:
610
+ self.notify(f"{row.title} is already the active task.", timeout=3)
611
+ return
612
+
613
+ task_id = row.id
614
+ self.dispatch(
615
+ ServiceCall(
616
+ run=lambda: self.facade.activate_task(task_id),
617
+ complete=self._on_task_mutated(f"Activated: {row.title}"),
618
+ failure_title="Could not activate task",
619
+ )
620
+ )
621
+
622
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
623
+ """Enter on the Task table activates the selected task."""
624
+ if event.data_table.id != "task-table":
625
+ return
626
+ event.stop()
627
+ self.action_activate_task()
628
+
629
+ def _on_task_mutated(self, message: str) -> Callable[[object], None]:
630
+ """Build a completion callback that refreshes state after a task mutation."""
631
+
632
+ def complete(result: object) -> None:
633
+ task = cast(Task, result)
634
+ self.notify(message, title=f"Task {task.id}", timeout=4)
635
+ self.refresh_state()
636
+
637
+ return complete
638
+
639
+ # ------------------------------------------------------------------
640
+ # Checkpoint
641
+ # ------------------------------------------------------------------
642
+
643
+ def action_checkpoint(self) -> None:
644
+ """Open the checkpoint dialog and create a MANUAL checkpoint on confirmation."""
645
+ if not self._require_active_task():
646
+ return
647
+
648
+ def completed(value: object) -> None:
649
+ if value is None:
650
+ return
651
+ entry = cast(CheckpointInput, value)
652
+ self.dispatch(
653
+ ServiceCall(
654
+ run=lambda: self.facade.create_checkpoint(
655
+ decisions=[entry.decision] if entry.decision else None,
656
+ test_summary=entry.test_summary,
657
+ note=entry.note,
658
+ ),
659
+ complete=self._on_checkpoint_created,
660
+ failure_title="Could not create checkpoint",
661
+ )
662
+ )
663
+
664
+ self.push_screen(CheckpointModal(), completed)
665
+
666
+ def _on_checkpoint_created(self, result: object) -> None:
667
+ record = cast(CheckpointRecord, result)
668
+ suffix = (
669
+ " Test summary recorded as reported / unverified."
670
+ if record.payload.test_status.known
671
+ else ""
672
+ )
673
+ self.notify(
674
+ f"Created {record.kind.value} checkpoint {record.id}.{suffix}",
675
+ title="Checkpoint",
676
+ timeout=6,
677
+ )
678
+ self.refresh_state()
679
+
680
+ # ------------------------------------------------------------------
681
+ # Recovery
682
+ # ------------------------------------------------------------------
683
+
684
+ def action_recover(self) -> None:
685
+ """Preview recovery, then reconcile through RecoveryService on confirmation."""
686
+ self.dispatch(
687
+ ServiceCall(
688
+ run=self.facade.preview_recovery,
689
+ complete=self._on_recovery_preview,
690
+ failure_title="Could not preview recovery",
691
+ )
692
+ )
693
+
694
+ def _on_recovery_preview(self, result: object) -> None:
695
+ preview = cast(TuiRecoveryPreview, result)
696
+ if preview.stale_count == 0:
697
+ self.notify(
698
+ "No unfinalized sessions were found. Nothing to recover.",
699
+ title="Recovery",
700
+ timeout=5,
701
+ )
702
+ return
703
+
704
+ def confirmed(value: object) -> None:
705
+ if value is not True:
706
+ return
707
+ self.dispatch(
708
+ ServiceCall(
709
+ run=self.facade.recover,
710
+ complete=self._on_recovered,
711
+ failure_title="Recovery failed",
712
+ )
713
+ )
714
+
715
+ self.push_screen(
716
+ ConfirmModal(
717
+ "Recover workspace",
718
+ render_recovery_preview(preview),
719
+ confirm_label="Recover",
720
+ confirm_variant="warning",
721
+ ),
722
+ confirmed,
723
+ )
724
+
725
+ def _on_recovered(self, result: object) -> None:
726
+ report = getattr(result, "reconciled_session_ids", [])
727
+ checkpoint_id = getattr(result, "checkpoint_id", None)
728
+ detail = f" Recovery checkpoint {checkpoint_id}." if checkpoint_id else ""
729
+ self.notify(
730
+ f"Reconciled {len(report)} session(s).{detail}",
731
+ title="Recovery complete",
732
+ timeout=8,
733
+ )
734
+ self.refresh_state()
735
+
736
+ # ------------------------------------------------------------------
737
+ # Handoff preview
738
+ # ------------------------------------------------------------------
739
+
740
+ def action_preview_handoff(self) -> None:
741
+ """Preview the canonical handoff for a target provider, launching nothing."""
742
+ options = tuple(
743
+ ProviderActionOption(
744
+ action=TuiExitAction.SWITCH,
745
+ provider=provider,
746
+ label=f"Preview handoff to {provider}",
747
+ detail="Renders canonical context. Persists nothing, uses no model quota.",
748
+ )
749
+ for provider in self.facade.supported_providers()
750
+ )
751
+ if not options:
752
+ self.notify("No providers are registered.", severity="warning")
753
+ return
754
+
755
+ def chosen(value: object) -> None:
756
+ if value is None:
757
+ return
758
+ option = cast(ProviderActionOption, value)
759
+ provider = option.provider
760
+ self.dispatch(
761
+ ServiceCall(
762
+ run=lambda: self.facade.preview_handoff(provider),
763
+ complete=self._on_handoff_preview,
764
+ failure_title="Could not preview handoff",
765
+ )
766
+ )
767
+
768
+ self.push_screen(ProviderActionModal(options), chosen)
769
+
770
+ def _on_handoff_preview(self, result: object) -> None:
771
+ preview = cast(TuiHandoffPreview, result)
772
+ self.push_screen(
773
+ InfoModal(
774
+ f"Handoff preview → {preview.target_provider_name}",
775
+ render_handoff_preview(preview),
776
+ )
777
+ )
778
+
779
+ # ------------------------------------------------------------------
780
+ # Antigravity workspace MCP setup
781
+ # ------------------------------------------------------------------
782
+
783
+ def action_setup_antigravity_mcp(self) -> None:
784
+ """Configure workspace MCP for Antigravity after explicit confirmation."""
785
+ body = Text()
786
+ path = self._mcp.antigravity_config_path if self._mcp else None
787
+ body.append("CortexShift will add its MCP server entry to:\n\n", style="bold")
788
+ body.append(f" {path or '.agents/mcp_config.json'}\n\n")
789
+ body.append(
790
+ "Unrelated MCP servers and top-level keys are preserved. If a conflicting "
791
+ "'cortexshift' entry already exists, the setup is refused rather than "
792
+ "overwritten — CortexShift never forces a configuration silently.",
793
+ style="dim",
794
+ )
795
+
796
+ def confirmed(value: object) -> None:
797
+ if value is not True:
798
+ return
799
+ self.dispatch(
800
+ ServiceCall(
801
+ run=lambda: self.facade.configure_antigravity_mcp(),
802
+ complete=self._on_antigravity_configured,
803
+ failure_title="Antigravity MCP setup failed",
804
+ )
805
+ )
806
+
807
+ self.push_screen(
808
+ ConfirmModal(
809
+ "Configure CortexShift MCP for Antigravity",
810
+ body,
811
+ confirm_label="Configure",
812
+ ),
813
+ confirmed,
814
+ )
815
+
816
+ def _on_antigravity_configured(self, result: object) -> None:
817
+ payload = cast(dict[str, object], result)
818
+ self.notify(
819
+ f"Antigravity workspace MCP {payload.get('action', 'updated')}.",
820
+ title="MCP setup",
821
+ timeout=6,
822
+ )
823
+ self.refresh_providers(force=True)
824
+
825
+ # ------------------------------------------------------------------
826
+ # Provider actions — the terminal handoff boundary
827
+ # ------------------------------------------------------------------
828
+
829
+ def action_provider_actions(self) -> None:
830
+ """Open the provider action palette."""
831
+ self.dispatch(
832
+ ServiceCall(
833
+ run=self._build_provider_options,
834
+ complete=self._on_provider_options,
835
+ failure_title="Could not list provider actions",
836
+ )
837
+ )
838
+
839
+ def _build_provider_options(self) -> tuple[ProviderActionOption, ...]:
840
+ """Compute currently valid provider actions.
841
+
842
+ Runs on a worker thread: it resolves executables on PATH and reads session
843
+ history through the facade.
844
+ """
845
+ snapshot = self._snapshot
846
+ source_provider = (
847
+ snapshot.activity.latest_session.provider_id
848
+ if snapshot and snapshot.activity.latest_session
849
+ else None
850
+ )
851
+ has_active_task = bool(snapshot and snapshot.active_task)
852
+
853
+ options: list[ProviderActionOption] = []
854
+ for provider in self.facade.supported_providers():
855
+ installed = self.facade.provider_available(provider)
856
+ resumable = self.facade.resumable_sessions(provider) if installed else ()
857
+
858
+ options.append(
859
+ ProviderActionOption(
860
+ action=TuiExitAction.RUN,
861
+ provider=provider,
862
+ label=f"Run {provider}",
863
+ detail="Start a new native session on the active task.",
864
+ enabled=installed and has_active_task,
865
+ disabled_reason=(
866
+ f"{provider} was not found in PATH."
867
+ if not installed
868
+ else "No active CortexShift task."
869
+ ),
870
+ )
871
+ )
872
+
873
+ newest = resumable[0] if resumable else None
874
+ options.append(
875
+ ProviderActionOption(
876
+ action=TuiExitAction.RESUME,
877
+ provider=provider,
878
+ label=f"Resume {provider}",
879
+ detail=(
880
+ f"Resume native conversation from session {newest.short_id}."
881
+ if newest
882
+ else ""
883
+ ),
884
+ selected_session_id=newest.id if newest else None,
885
+ enabled=newest is not None,
886
+ disabled_reason=(
887
+ f"{provider} was not found in PATH."
888
+ if not installed
889
+ else "No exactly resumable native session is recorded for this task."
890
+ ),
891
+ )
892
+ )
893
+
894
+ same_provider = source_provider == provider
895
+ options.append(
896
+ ProviderActionOption(
897
+ action=TuiExitAction.SWITCH,
898
+ provider=provider,
899
+ label=f"Switch to {provider}",
900
+ detail=(
901
+ "Hand the task over with a fresh canonical handoff, reusing a "
902
+ "known native conversation when one exists."
903
+ ),
904
+ enabled=installed and has_active_task and not same_provider,
905
+ disabled_reason=(
906
+ f"{provider} was not found in PATH."
907
+ if not installed
908
+ else (
909
+ "The latest session already belongs to this provider — "
910
+ "use Resume instead."
911
+ if same_provider
912
+ else "No active CortexShift task."
913
+ )
914
+ ),
915
+ )
916
+ )
917
+ return tuple(options)
918
+
919
+ def _on_provider_options(self, result: object) -> None:
920
+ options = cast(tuple[ProviderActionOption, ...], result)
921
+ if not options:
922
+ self.notify("No provider actions are available.", severity="warning")
923
+ return
924
+ self.push_screen(ProviderActionModal(options), self._on_provider_action_chosen)
925
+
926
+ def _on_provider_action_chosen(self, value: object) -> None:
927
+ if value is None:
928
+ return
929
+ option = cast(ProviderActionOption, value)
930
+
931
+ if option.action is TuiExitAction.SWITCH:
932
+ provider = option.provider
933
+ new_session = option.force_new_session
934
+ self.dispatch(
935
+ ServiceCall(
936
+ run=lambda: self.facade.preview_switch(provider, new_session=new_session),
937
+ complete=self._switch_confirmation(option),
938
+ failure_title="Could not prepare the switch",
939
+ )
940
+ )
941
+ return
942
+
943
+ self.request_provider_launch(
944
+ TuiExitRequest(
945
+ action=option.action,
946
+ provider=option.provider,
947
+ selected_session_id=option.selected_session_id,
948
+ force_new_session=option.force_new_session,
949
+ )
950
+ )
951
+
952
+ def _switch_confirmation(self, option: ProviderActionOption) -> Callable[[object], None]:
953
+ """Confirm a switch against its dry run before releasing the terminal."""
954
+
955
+ def complete(result: object) -> None:
956
+ preview = cast(TuiSwitchPreview, result)
957
+
958
+ def confirmed(value: object) -> None:
959
+ if value is not True:
960
+ return
961
+ self.request_provider_launch(
962
+ TuiExitRequest(
963
+ action=TuiExitAction.SWITCH,
964
+ provider=option.provider,
965
+ selected_session_id=option.selected_session_id,
966
+ force_new_session=option.force_new_session,
967
+ )
968
+ )
969
+
970
+ self.push_screen(
971
+ ConfirmModal(
972
+ f"Switch to {preview.target_provider_name}",
973
+ render_switch_preview(preview),
974
+ confirm_label="Switch",
975
+ ),
976
+ confirmed,
977
+ )
978
+
979
+ return complete
980
+
981
+ def request_provider_launch(self, request: TuiExitRequest) -> None:
982
+ """Exit the dashboard so the coordinator can launch the native provider.
983
+
984
+ Nothing is launched here. Textual tears down, restores the terminal, and returns
985
+ this request from `App.run()`; only then does the coordinator call the run,
986
+ resume, or switch service.
987
+ """
988
+ self.exit(request)
989
+
990
+ # ------------------------------------------------------------------
991
+ # Help, status line, responsive layout
992
+ # ------------------------------------------------------------------
993
+
994
+ def action_help(self) -> None:
995
+ """Show the keyboard contract."""
996
+ self.push_screen(HelpScreen())
997
+
998
+ def _render_status_line(self) -> None:
999
+ """Render the status line, shedding lower-priority facts on narrow terminals."""
1000
+ status = self.query_one("#status-line", Static)
1001
+ compact = self.size.width < COMPACT_WIDTH
1002
+ parts: list[str] = []
1003
+
1004
+ if self._snapshot is not None:
1005
+ parts.append(self._snapshot.project.name)
1006
+ else:
1007
+ parts.append("Loading…")
1008
+
1009
+ # The sidebar folds away in compact mode, so the section name moves here.
1010
+ if compact:
1011
+ parts.append(self._active_section.label)
1012
+
1013
+ if self._repository is not None and self._repository.ready:
1014
+ branch = self._repository.branch or "(detached)"
1015
+ parts.append(f"{branch} · {'dirty' if self._repository.dirty else 'clean'}")
1016
+ elif self._repository is not None:
1017
+ parts.append(f"git {self._repository.status.value}")
1018
+
1019
+ parts.append(f"workspace {self._workspace_activity.value}")
1020
+
1021
+ if not compact and self._snapshot is not None:
1022
+ parts.append(f"updated {format_relative(self._snapshot.loaded_at)}")
1023
+
1024
+ status.update(Text(" │ ".join(parts), style="dim"))
1025
+
1026
+ def on_resize(self, event: events.Resize) -> None:
1027
+ """Adapt the layout to the terminal size.
1028
+
1029
+ The dashboard stays usable at 80 x 24. Below 90 columns the sidebar folds away
1030
+ and navigation continues through the number keys; below the minimum size a clear
1031
+ message replaces the layout instead of a broken render.
1032
+ """
1033
+ width, height = event.size.width, event.size.height
1034
+ self.set_class(width < COMPACT_WIDTH, "compact")
1035
+ self.set_class(width < MINIMUM_WIDTH or height < MINIMUM_HEIGHT, "too-small")
1036
+ self._render_status_line()
1037
+
1038
+
1039
+ def build_app(
1040
+ project_root: Path | str | None = None,
1041
+ *,
1042
+ facade: TuiFacade | None = None,
1043
+ state_refresh_seconds: float = STATE_REFRESH_SECONDS,
1044
+ ) -> CortexShiftApp:
1045
+ """Construct the dashboard bound to one initialized CortexShift project.
1046
+
1047
+ Raises:
1048
+ ProjectNotInitializedError: If no initialized project root is found.
1049
+ """
1050
+ resolved = facade or TuiFacade.resolve(project_root)
1051
+ return CortexShiftApp(resolved, state_refresh_seconds=state_refresh_seconds)