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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|