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
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Immutable presentation models for the CortexShift terminal control center.
|
|
2
|
+
|
|
3
|
+
These are read models: bounded, flattened projections assembled by `TuiFacade` from
|
|
4
|
+
canonical domain entities so screen code never has to understand persistence adapters,
|
|
5
|
+
Git inspection results, or provider probe internals. They are never persisted, never
|
|
6
|
+
serialized to a wire protocol, and never substitute for canonical domain objects.
|
|
7
|
+
|
|
8
|
+
Every model carries an explicit authority label (`DataAuthority`) so the dashboard can
|
|
9
|
+
state honestly whether the reader is looking at live truth, an immutable historical
|
|
10
|
+
observation, an unverified agent report, or a last-known value that may have gone stale.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from cortexshift.domain.checkpoint import CheckpointRecord
|
|
20
|
+
from cortexshift.domain.git import RepositoryInspectionStatus
|
|
21
|
+
from cortexshift.domain.handoff import HandoffRecord
|
|
22
|
+
from cortexshift.domain.task import Task
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataAuthority(StrEnum):
|
|
26
|
+
"""How much authority a rendered value carries in the truth hierarchy.
|
|
27
|
+
|
|
28
|
+
CortexShift never presents a historical observation as current reality, and never
|
|
29
|
+
presents an agent's report as a verified result.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
LIVE = "live"
|
|
33
|
+
HISTORICAL = "historical"
|
|
34
|
+
REPORTED = "reported"
|
|
35
|
+
LAST_KNOWN = "last_known"
|
|
36
|
+
UNKNOWN = "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
AUTHORITY_LABELS: dict[DataAuthority, str] = {
|
|
40
|
+
DataAuthority.LIVE: "Live",
|
|
41
|
+
DataAuthority.HISTORICAL: "Historical observation",
|
|
42
|
+
DataAuthority.REPORTED: "Reported / unverified",
|
|
43
|
+
DataAuthority.LAST_KNOWN: "Last-known",
|
|
44
|
+
DataAuthority.UNKNOWN: "Unknown",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WorkspaceActivity(StrEnum):
|
|
49
|
+
"""Observed state of the exclusive project workspace lease.
|
|
50
|
+
|
|
51
|
+
Derived strictly from a non-blocking OS advisory lock probe. The presence of the
|
|
52
|
+
lock file on disk is never treated as evidence of an active lease.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
FREE = "free"
|
|
56
|
+
BUSY = "busy"
|
|
57
|
+
UNKNOWN = "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class TuiProjectModel:
|
|
62
|
+
"""Identity of the CortexShift project the dashboard is bound to."""
|
|
63
|
+
|
|
64
|
+
project_id: str
|
|
65
|
+
name: str
|
|
66
|
+
root: Path
|
|
67
|
+
state_file: str
|
|
68
|
+
schema_version: int
|
|
69
|
+
cortexshift_version: str
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def short_id(self) -> str:
|
|
73
|
+
"""Abbreviated project identifier for dense table and header rendering."""
|
|
74
|
+
return abbreviate_id(self.project_id)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class TuiTaskProgress:
|
|
79
|
+
"""Structured completion counts for the active Task.
|
|
80
|
+
|
|
81
|
+
A percentage is only meaningful when structured items exist. CortexShift never
|
|
82
|
+
infers progress from Git state or from a provider process exit status.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
completed_count: int = 0
|
|
86
|
+
remaining_count: int = 0
|
|
87
|
+
issue_count: int = 0
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def total(self) -> int:
|
|
91
|
+
"""Number of structured progress items forming the denominator."""
|
|
92
|
+
return self.completed_count + self.remaining_count
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def has_denominator(self) -> bool:
|
|
96
|
+
"""Whether a meaningful completion ratio can be computed at all."""
|
|
97
|
+
return self.total > 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def fraction(self) -> float | None:
|
|
101
|
+
"""Completion ratio in [0, 1], or None when no structured progress exists."""
|
|
102
|
+
if not self.has_denominator:
|
|
103
|
+
return None
|
|
104
|
+
return self.completed_count / self.total
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def percent(self) -> int | None:
|
|
108
|
+
"""Completion percentage, or None when no structured progress exists."""
|
|
109
|
+
fraction = self.fraction
|
|
110
|
+
if fraction is None:
|
|
111
|
+
return None
|
|
112
|
+
return int(round(fraction * 100))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class TuiTaskModel:
|
|
117
|
+
"""Complete canonical Task state for the Task screen."""
|
|
118
|
+
|
|
119
|
+
id: str
|
|
120
|
+
title: str
|
|
121
|
+
status: str
|
|
122
|
+
objective: str
|
|
123
|
+
requirements: tuple[str, ...] = ()
|
|
124
|
+
constraints: tuple[str, ...] = ()
|
|
125
|
+
completed: tuple[str, ...] = ()
|
|
126
|
+
current_work: str | None = None
|
|
127
|
+
remaining: tuple[str, ...] = ()
|
|
128
|
+
known_issues: tuple[str, ...] = ()
|
|
129
|
+
created_at: datetime | None = None
|
|
130
|
+
updated_at: datetime | None = None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def short_id(self) -> str:
|
|
134
|
+
"""Abbreviated task identifier for dense rendering."""
|
|
135
|
+
return abbreviate_id(self.id)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def progress(self) -> TuiTaskProgress:
|
|
139
|
+
"""Structured progress counts derived from canonical task lists."""
|
|
140
|
+
return TuiTaskProgress(
|
|
141
|
+
completed_count=len(self.completed),
|
|
142
|
+
remaining_count=len(self.remaining),
|
|
143
|
+
issue_count=len(self.known_issues),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_task(cls, task: Task) -> "TuiTaskModel":
|
|
148
|
+
"""Project a canonical Task into its read model."""
|
|
149
|
+
return cls(
|
|
150
|
+
id=task.id,
|
|
151
|
+
title=task.title,
|
|
152
|
+
status=task.status.value,
|
|
153
|
+
objective=task.objective,
|
|
154
|
+
requirements=tuple(task.requirements),
|
|
155
|
+
constraints=tuple(task.constraints),
|
|
156
|
+
completed=tuple(task.completed),
|
|
157
|
+
current_work=task.current_work,
|
|
158
|
+
remaining=tuple(task.remaining),
|
|
159
|
+
known_issues=tuple(task.known_issues),
|
|
160
|
+
created_at=task.created_at,
|
|
161
|
+
updated_at=task.updated_at,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class TuiTaskRow:
|
|
167
|
+
"""One row of the Task table."""
|
|
168
|
+
|
|
169
|
+
id: str
|
|
170
|
+
title: str
|
|
171
|
+
status: str
|
|
172
|
+
is_active: bool
|
|
173
|
+
is_terminal: bool
|
|
174
|
+
completed_count: int
|
|
175
|
+
remaining_count: int
|
|
176
|
+
updated_at: datetime | None = None
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def short_id(self) -> str:
|
|
180
|
+
"""Abbreviated task identifier for dense rendering."""
|
|
181
|
+
return abbreviate_id(self.id)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class TuiRepositoryModel:
|
|
186
|
+
"""Result of a live, strictly read-only Git working tree inspection."""
|
|
187
|
+
|
|
188
|
+
status: RepositoryInspectionStatus
|
|
189
|
+
project_root: Path
|
|
190
|
+
git_available: bool
|
|
191
|
+
git_version: str | None = None
|
|
192
|
+
git_root: str | None = None
|
|
193
|
+
branch: str | None = None
|
|
194
|
+
head_sha: str | None = None
|
|
195
|
+
detached_head: bool = False
|
|
196
|
+
dirty: bool = False
|
|
197
|
+
staged_files: tuple[str, ...] = ()
|
|
198
|
+
modified_files: tuple[str, ...] = ()
|
|
199
|
+
untracked_files: tuple[str, ...] = ()
|
|
200
|
+
conflicted_files: tuple[str, ...] = ()
|
|
201
|
+
working_tree_diff_summary: str | None = None
|
|
202
|
+
staged_diff_summary: str | None = None
|
|
203
|
+
diagnostic: str | None = None
|
|
204
|
+
observed_at: datetime | None = None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def ready(self) -> bool:
|
|
208
|
+
"""Whether the repository was inspected successfully."""
|
|
209
|
+
return self.status == RepositoryInspectionStatus.READY
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def changed_file_count(self) -> int:
|
|
213
|
+
"""Total number of distinct changed paths reported by the inspection."""
|
|
214
|
+
return len(
|
|
215
|
+
{
|
|
216
|
+
*self.staged_files,
|
|
217
|
+
*self.modified_files,
|
|
218
|
+
*self.untracked_files,
|
|
219
|
+
*self.conflicted_files,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def short_head(self) -> str:
|
|
225
|
+
"""Abbreviated HEAD commit, or an em dash when no commit exists."""
|
|
226
|
+
if not self.head_sha:
|
|
227
|
+
return "—"
|
|
228
|
+
return self.head_sha[:8]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True, slots=True)
|
|
232
|
+
class TuiSessionRow:
|
|
233
|
+
"""One CortexShift orchestration Session.
|
|
234
|
+
|
|
235
|
+
Holds orchestration metadata only. Provider transcripts, prompts, reasoning, and
|
|
236
|
+
terminal history are never read and never displayed.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
id: str
|
|
240
|
+
task_id: str
|
|
241
|
+
provider_id: str
|
|
242
|
+
status: str
|
|
243
|
+
native_resumable: bool
|
|
244
|
+
native_session_id: str | None = None
|
|
245
|
+
resumed_from_session_id: str | None = None
|
|
246
|
+
exit_reason: str | None = None
|
|
247
|
+
exit_code: int | None = None
|
|
248
|
+
started_at: datetime | None = None
|
|
249
|
+
ended_at: datetime | None = None
|
|
250
|
+
reconciled_at: datetime | None = None
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def short_id(self) -> str:
|
|
254
|
+
"""Abbreviated session identifier for dense rendering."""
|
|
255
|
+
return abbreviate_id(self.id)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def unfinalized(self) -> bool:
|
|
259
|
+
"""Whether this record was never finalized and may warrant recovery."""
|
|
260
|
+
return self.status in ("initializing", "running")
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def authority(self) -> DataAuthority:
|
|
264
|
+
"""An unfinalized row is last-known state, not a live running process."""
|
|
265
|
+
return DataAuthority.LAST_KNOWN if self.unfinalized else DataAuthority.HISTORICAL
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True, slots=True)
|
|
269
|
+
class TuiCheckpointRow:
|
|
270
|
+
"""One immutable Checkpoint observation."""
|
|
271
|
+
|
|
272
|
+
id: str
|
|
273
|
+
kind: str
|
|
274
|
+
session_id: str | None
|
|
275
|
+
created_at: datetime
|
|
276
|
+
git_summary: str
|
|
277
|
+
test_reported: bool
|
|
278
|
+
record: CheckpointRecord
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def short_id(self) -> str:
|
|
282
|
+
"""Abbreviated checkpoint identifier for dense rendering."""
|
|
283
|
+
return abbreviate_id(self.id)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True, slots=True)
|
|
287
|
+
class TuiHandoffRow:
|
|
288
|
+
"""One canonical cross-provider Handoff record."""
|
|
289
|
+
|
|
290
|
+
id: str
|
|
291
|
+
source_provider_id: str
|
|
292
|
+
target_provider_id: str
|
|
293
|
+
status: str
|
|
294
|
+
checkpoint_id: str | None
|
|
295
|
+
created_at: datetime
|
|
296
|
+
record: HandoffRecord
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def short_id(self) -> str:
|
|
300
|
+
"""Abbreviated handoff identifier for dense rendering."""
|
|
301
|
+
return abbreviate_id(self.id)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True, slots=True)
|
|
305
|
+
class TuiProviderStatus:
|
|
306
|
+
"""Passive discovery result for one provider CLI.
|
|
307
|
+
|
|
308
|
+
Account identifiers, credential paths, and token material are never collected.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
provider_id: str
|
|
312
|
+
display_name: str
|
|
313
|
+
executable: str
|
|
314
|
+
installed: bool
|
|
315
|
+
version: str | None = None
|
|
316
|
+
authentication: str = "unknown"
|
|
317
|
+
supports_native_resume: bool = False
|
|
318
|
+
supports_exact_resume: bool = False
|
|
319
|
+
mcp_integration: str = "unknown"
|
|
320
|
+
diagnostics: tuple[str, ...] = ()
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def available(self) -> bool:
|
|
324
|
+
"""Whether the provider CLI resolved on PATH."""
|
|
325
|
+
return self.installed
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True, slots=True)
|
|
329
|
+
class TuiMcpStatus:
|
|
330
|
+
"""CortexShift MCP server and provider integration status.
|
|
331
|
+
|
|
332
|
+
Rendering this never starts an MCP server.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
sdk_available: bool
|
|
336
|
+
sdk_version: str
|
|
337
|
+
transport: str = "stdio (local only)"
|
|
338
|
+
claude_integration: str = "automatic per launch (--mcp-config)"
|
|
339
|
+
codex_integration: str = "automatic per launch (-c overrides)"
|
|
340
|
+
antigravity_configured: bool = False
|
|
341
|
+
antigravity_config_path: str | None = None
|
|
342
|
+
read_tools: tuple[str, ...] = ()
|
|
343
|
+
write_tools: tuple[str, ...] = ()
|
|
344
|
+
resources: tuple[str, ...] = ()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True, slots=True)
|
|
348
|
+
class TuiActivityModel:
|
|
349
|
+
"""Most recent orchestration activity recorded for the project."""
|
|
350
|
+
|
|
351
|
+
latest_session: TuiSessionRow | None = None
|
|
352
|
+
latest_checkpoint: TuiCheckpointRow | None = None
|
|
353
|
+
latest_handoff: TuiHandoffRow | None = None
|
|
354
|
+
unfinalized_session_count: int = 0
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def recovery_may_be_required(self) -> bool:
|
|
358
|
+
"""Whether unfinalized session records exist that recovery could reconcile."""
|
|
359
|
+
return self.unfinalized_session_count > 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class TuiStateSnapshot:
|
|
364
|
+
"""All SQLite-backed read models assembled in one pass.
|
|
365
|
+
|
|
366
|
+
Deliberately excludes live Git inspection and provider discovery so the lightweight
|
|
367
|
+
refresh timer never spawns a subprocess.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
project: TuiProjectModel
|
|
371
|
+
active_task: TuiTaskModel | None = None
|
|
372
|
+
tasks: tuple[TuiTaskRow, ...] = ()
|
|
373
|
+
sessions: tuple[TuiSessionRow, ...] = ()
|
|
374
|
+
checkpoints: tuple[TuiCheckpointRow, ...] = ()
|
|
375
|
+
handoffs: tuple[TuiHandoffRow, ...] = ()
|
|
376
|
+
activity: TuiActivityModel = field(default_factory=TuiActivityModel)
|
|
377
|
+
loaded_at: datetime | None = None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(frozen=True, slots=True)
|
|
381
|
+
class TuiRecoveryPreview:
|
|
382
|
+
"""Non-mutating preview of what a recovery run would reconcile."""
|
|
383
|
+
|
|
384
|
+
task_id: str
|
|
385
|
+
task_title: str
|
|
386
|
+
stale_session_ids: tuple[str, ...] = ()
|
|
387
|
+
dirty: bool = False
|
|
388
|
+
files_touched: tuple[str, ...] = ()
|
|
389
|
+
repository_status: str = "unknown"
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def stale_count(self) -> int:
|
|
393
|
+
"""Number of unfinalized sessions that would be reconciled."""
|
|
394
|
+
return len(self.stale_session_ids)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True, slots=True)
|
|
398
|
+
class TuiSwitchPreview:
|
|
399
|
+
"""Bounded preview of a provider switch, produced without any model call."""
|
|
400
|
+
|
|
401
|
+
target_provider_id: str
|
|
402
|
+
target_provider_name: str
|
|
403
|
+
source_provider_id: str
|
|
404
|
+
source_session_id: str
|
|
405
|
+
task_id: str
|
|
406
|
+
task_title: str
|
|
407
|
+
target_native_mode: str
|
|
408
|
+
selected_prior_target_session_id: str | None
|
|
409
|
+
git_status: str
|
|
410
|
+
git_branch: str | None
|
|
411
|
+
git_dirty: bool
|
|
412
|
+
delivery_strategy: str
|
|
413
|
+
bootstrap_model_turn_required: bool
|
|
414
|
+
checkpoint_enrichment: str
|
|
415
|
+
context_characters: int
|
|
416
|
+
context_max_characters: int
|
|
417
|
+
context_truncated: bool
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True, slots=True)
|
|
421
|
+
class TuiHandoffPreview:
|
|
422
|
+
"""Bounded canonical handoff context rendered without persisting anything."""
|
|
423
|
+
|
|
424
|
+
target_provider_id: str
|
|
425
|
+
target_provider_name: str
|
|
426
|
+
delivery_strategy: str
|
|
427
|
+
bootstrap_model_turn_required: bool
|
|
428
|
+
rendered_context: str
|
|
429
|
+
context_characters: int
|
|
430
|
+
context_max_characters: int
|
|
431
|
+
context_truncated: bool
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def abbreviate_id(value: str, keep: int = 8) -> str:
|
|
435
|
+
"""Abbreviate a canonical identifier for dense table rendering.
|
|
436
|
+
|
|
437
|
+
Only ever used for display. Service calls always receive the full canonical ID, and
|
|
438
|
+
detail panels always expose it in full.
|
|
439
|
+
"""
|
|
440
|
+
if not value:
|
|
441
|
+
return "—"
|
|
442
|
+
prefix, separator, suffix = value.partition("_")
|
|
443
|
+
if not separator:
|
|
444
|
+
return value if len(value) <= keep else f"{value[:keep]}…"
|
|
445
|
+
if len(suffix) <= keep:
|
|
446
|
+
return value
|
|
447
|
+
return f"{prefix}_{suffix[:keep]}…"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def format_relative(moment: datetime | None, *, now: datetime | None = None) -> str:
|
|
451
|
+
"""Render a UTC timestamp as a compact relative age.
|
|
452
|
+
|
|
453
|
+
Canonical UTC datetimes are never mutated; this is a display projection only. Exact
|
|
454
|
+
timestamps remain available in detail views.
|
|
455
|
+
"""
|
|
456
|
+
if moment is None:
|
|
457
|
+
return "—"
|
|
458
|
+
|
|
459
|
+
from cortexshift.domain.identifiers import utc_now
|
|
460
|
+
|
|
461
|
+
reference = now or utc_now()
|
|
462
|
+
anchored = moment.replace(tzinfo=reference.tzinfo) if moment.tzinfo is None else moment
|
|
463
|
+
seconds = (reference - anchored).total_seconds()
|
|
464
|
+
if seconds < 0:
|
|
465
|
+
return "just now"
|
|
466
|
+
if seconds < 60:
|
|
467
|
+
return "just now"
|
|
468
|
+
minutes = int(seconds // 60)
|
|
469
|
+
if minutes < 60:
|
|
470
|
+
return f"{minutes}m ago"
|
|
471
|
+
hours = minutes // 60
|
|
472
|
+
if hours < 24:
|
|
473
|
+
return f"{hours}h ago"
|
|
474
|
+
days = hours // 24
|
|
475
|
+
if days < 30:
|
|
476
|
+
return f"{days}d ago"
|
|
477
|
+
months = days // 30
|
|
478
|
+
if months < 12:
|
|
479
|
+
return f"{months}mo ago"
|
|
480
|
+
return f"{days // 365}y ago"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def format_timestamp(moment: datetime | None) -> str:
|
|
484
|
+
"""Render an exact UTC timestamp for copy-friendly detail panels."""
|
|
485
|
+
if moment is None:
|
|
486
|
+
return "—"
|
|
487
|
+
return moment.isoformat()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def truncate(text: str | None, limit: int) -> str:
|
|
491
|
+
"""Bound a free-text value for dense rendering without altering canonical state."""
|
|
492
|
+
if not text:
|
|
493
|
+
return "—"
|
|
494
|
+
collapsed = " ".join(text.split())
|
|
495
|
+
if len(collapsed) <= limit:
|
|
496
|
+
return collapsed
|
|
497
|
+
return f"{collapsed[: max(1, limit - 1)]}…"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def bounded(items: Sequence[str], limit: int) -> tuple[list[str], int]:
|
|
501
|
+
"""Split a sequence into a displayable head and a count of omitted entries."""
|
|
502
|
+
head = list(items[:limit])
|
|
503
|
+
return head, max(0, len(items) - limit)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Primary dashboard sections.
|
|
2
|
+
|
|
3
|
+
Each section is a self-contained view over presentation models supplied by the app. A
|
|
4
|
+
section never reaches into persistence, never runs Git, and never constructs provider
|
|
5
|
+
arguments; it renders what `TuiFacade` produced and raises intent back to the app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
|
|
12
|
+
from cortexshift.tui.models import (
|
|
13
|
+
TuiMcpStatus,
|
|
14
|
+
TuiProviderStatus,
|
|
15
|
+
TuiRepositoryModel,
|
|
16
|
+
TuiStateSnapshot,
|
|
17
|
+
WorkspaceActivity,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TuiSection(StrEnum):
|
|
22
|
+
"""The primary navigable sections of the control center."""
|
|
23
|
+
|
|
24
|
+
OVERVIEW = "overview"
|
|
25
|
+
TASK = "task"
|
|
26
|
+
REPOSITORY = "repository"
|
|
27
|
+
SESSIONS = "sessions"
|
|
28
|
+
CHECKPOINTS = "checkpoints"
|
|
29
|
+
HANDOFFS = "handoffs"
|
|
30
|
+
PROVIDERS = "providers"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def label(self) -> str:
|
|
34
|
+
"""Display name shown in the sidebar and status line."""
|
|
35
|
+
return self.value.capitalize()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def shortcut(self) -> str:
|
|
39
|
+
"""The number key that jumps directly to this section."""
|
|
40
|
+
return str(SECTION_ORDER.index(self) + 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SECTION_ORDER: tuple[TuiSection, ...] = (
|
|
44
|
+
TuiSection.OVERVIEW,
|
|
45
|
+
TuiSection.TASK,
|
|
46
|
+
TuiSection.REPOSITORY,
|
|
47
|
+
TuiSection.SESSIONS,
|
|
48
|
+
TuiSection.CHECKPOINTS,
|
|
49
|
+
TuiSection.HANDOFFS,
|
|
50
|
+
TuiSection.PROVIDERS,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SectionView(Vertical):
|
|
55
|
+
"""Base class for a primary section of the dashboard."""
|
|
56
|
+
|
|
57
|
+
DEFAULT_CLASSES = "section"
|
|
58
|
+
|
|
59
|
+
section: TuiSection
|
|
60
|
+
|
|
61
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
62
|
+
"""Render newly loaded persisted state."""
|
|
63
|
+
|
|
64
|
+
def update_repository(self, repository: TuiRepositoryModel | None) -> None:
|
|
65
|
+
"""Render the result of a live repository inspection."""
|
|
66
|
+
|
|
67
|
+
def update_providers(
|
|
68
|
+
self,
|
|
69
|
+
providers: tuple[TuiProviderStatus, ...],
|
|
70
|
+
mcp: TuiMcpStatus | None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Render provider discovery and MCP integration status."""
|
|
73
|
+
|
|
74
|
+
def update_activity(self, activity: WorkspaceActivity) -> None:
|
|
75
|
+
"""Render the observed workspace lease state."""
|
|
76
|
+
|
|
77
|
+
def on_section_shown(self) -> None:
|
|
78
|
+
"""Called when this section becomes the visible section."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["SECTION_ORDER", "SectionView", "TuiSection"]
|