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,188 @@
|
|
|
1
|
+
"""The Checkpoints section: immutable historical observations.
|
|
2
|
+
|
|
3
|
+
A checkpoint records what was true when it was captured. It is never presented as
|
|
4
|
+
current truth, and a reported test summary is never presented as a verified result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.widgets import DataTable, Static
|
|
10
|
+
|
|
11
|
+
from cortexshift.domain.checkpoint import CheckpointTestProvenance
|
|
12
|
+
from cortexshift.tui.models import (
|
|
13
|
+
AUTHORITY_LABELS,
|
|
14
|
+
DataAuthority,
|
|
15
|
+
TuiCheckpointRow,
|
|
16
|
+
TuiStateSnapshot,
|
|
17
|
+
format_relative,
|
|
18
|
+
format_timestamp,
|
|
19
|
+
)
|
|
20
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
21
|
+
from cortexshift.tui.widgets import (
|
|
22
|
+
BulletList,
|
|
23
|
+
EmptyState,
|
|
24
|
+
FieldList,
|
|
25
|
+
Panel,
|
|
26
|
+
current_row_key,
|
|
27
|
+
restore_cursor,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CheckpointsSection(SectionView):
|
|
32
|
+
"""Lists checkpoints newest first and renders the selected one in full."""
|
|
33
|
+
|
|
34
|
+
section = TuiSection.CHECKPOINTS
|
|
35
|
+
|
|
36
|
+
def __init__(self, id: str | None = None) -> None:
|
|
37
|
+
super().__init__(id=id)
|
|
38
|
+
self._rows: dict[str, TuiCheckpointRow] = {}
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
"""Build the checkpoints layout."""
|
|
42
|
+
yield Static(Text("Checkpoints", style="bold"), classes="section-title")
|
|
43
|
+
yield EmptyState(
|
|
44
|
+
"No checkpoints yet.",
|
|
45
|
+
"Create one with:\n c\n\nCheckpoints capture task and repository state "
|
|
46
|
+
"without holding the workspace lease.",
|
|
47
|
+
id="checkpoints-empty",
|
|
48
|
+
)
|
|
49
|
+
yield DataTable(id="checkpoints-table", cursor_type="row")
|
|
50
|
+
|
|
51
|
+
with Panel(
|
|
52
|
+
"Checkpoint detail",
|
|
53
|
+
authority=DataAuthority.HISTORICAL,
|
|
54
|
+
id="checkpoints-detail-panel",
|
|
55
|
+
):
|
|
56
|
+
yield FieldList(id="checkpoints-detail")
|
|
57
|
+
yield Static(Text("Task snapshot", style="bold"), classes="panel-title")
|
|
58
|
+
yield FieldList(id="checkpoints-task")
|
|
59
|
+
yield Static(Text("Decisions", style="bold"), classes="panel-title")
|
|
60
|
+
yield BulletList(id="checkpoints-decisions")
|
|
61
|
+
yield Static(Text("Files touched", style="bold"), classes="panel-title")
|
|
62
|
+
yield BulletList(id="checkpoints-files")
|
|
63
|
+
|
|
64
|
+
def on_mount(self) -> None:
|
|
65
|
+
"""Configure checkpoint table columns."""
|
|
66
|
+
table = self.query_one("#checkpoints-table", DataTable)
|
|
67
|
+
table.add_columns("Checkpoint", "Kind", "Session", "Created", "Git state")
|
|
68
|
+
|
|
69
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
70
|
+
"""Refresh the checkpoints table, preserving the operator's selection."""
|
|
71
|
+
table = self.query_one("#checkpoints-table", DataTable)
|
|
72
|
+
empty = self.query_one("#checkpoints-empty", EmptyState)
|
|
73
|
+
detail_panel = self.query_one("#checkpoints-detail-panel", Panel)
|
|
74
|
+
|
|
75
|
+
rows = snapshot.checkpoints
|
|
76
|
+
self._rows = {row.id: row for row in rows}
|
|
77
|
+
|
|
78
|
+
empty.display = not rows
|
|
79
|
+
table.display = bool(rows)
|
|
80
|
+
detail_panel.display = bool(rows)
|
|
81
|
+
|
|
82
|
+
selected = current_row_key(table)
|
|
83
|
+
table.clear()
|
|
84
|
+
for row in rows:
|
|
85
|
+
session_label = row.session_id[:12] if row.session_id else "—"
|
|
86
|
+
table.add_row(
|
|
87
|
+
Text(row.short_id),
|
|
88
|
+
Text(row.kind),
|
|
89
|
+
Text(session_label),
|
|
90
|
+
Text(format_relative(row.created_at)),
|
|
91
|
+
Text(row.git_summary),
|
|
92
|
+
key=row.id,
|
|
93
|
+
)
|
|
94
|
+
restore_cursor(table, selected)
|
|
95
|
+
self._render_detail()
|
|
96
|
+
|
|
97
|
+
def on_data_table_row_highlighted(self, _event: DataTable.RowHighlighted) -> None:
|
|
98
|
+
"""Render the highlighted checkpoint in full."""
|
|
99
|
+
self._render_detail()
|
|
100
|
+
|
|
101
|
+
def _render_detail(self) -> None:
|
|
102
|
+
key = current_row_key(self.query_one("#checkpoints-table", DataTable))
|
|
103
|
+
row = self._rows.get(key) if key else None
|
|
104
|
+
|
|
105
|
+
detail = self.query_one("#checkpoints-detail", FieldList)
|
|
106
|
+
task_fields = self.query_one("#checkpoints-task", FieldList)
|
|
107
|
+
decisions = self.query_one("#checkpoints-decisions", BulletList)
|
|
108
|
+
files = self.query_one("#checkpoints-files", BulletList)
|
|
109
|
+
|
|
110
|
+
if row is None:
|
|
111
|
+
detail.set_fields([])
|
|
112
|
+
task_fields.set_fields([])
|
|
113
|
+
decisions.set_items([])
|
|
114
|
+
files.set_items([])
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
payload = row.record.payload
|
|
118
|
+
test_status = payload.test_status
|
|
119
|
+
if not test_status.known:
|
|
120
|
+
test_line: str | Text = "No test result recorded."
|
|
121
|
+
elif test_status.provenance == CheckpointTestProvenance.VERIFIED:
|
|
122
|
+
test_line = Text(f"{test_status.summary} (verified)", style="green")
|
|
123
|
+
else:
|
|
124
|
+
test_line = Text(
|
|
125
|
+
f"{test_status.summary} (Reported / unverified)",
|
|
126
|
+
style="yellow",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
git = payload.git_state
|
|
130
|
+
detail.set_fields(
|
|
131
|
+
[
|
|
132
|
+
("Checkpoint ID", row.record.id),
|
|
133
|
+
("Kind", row.record.kind.value),
|
|
134
|
+
("Protocol", f"v{row.record.protocol_version}"),
|
|
135
|
+
("Created at", format_timestamp(row.record.created_at)),
|
|
136
|
+
("Session ID", row.record.session_id or "—"),
|
|
137
|
+
("Git snapshot ID", row.record.git_snapshot_id or "—"),
|
|
138
|
+
("Git state", f"{git.status.value} · {git.note}"),
|
|
139
|
+
("Branch", git.branch or "—"),
|
|
140
|
+
("HEAD", git.head_sha or "—"),
|
|
141
|
+
("Dirty", "yes" if git.dirty else "no"),
|
|
142
|
+
(
|
|
143
|
+
"Changed counts",
|
|
144
|
+
f"staged {git.staged_count} · modified {git.modified_count} · "
|
|
145
|
+
f"untracked {git.untracked_count} · conflicted {git.conflicted_count}",
|
|
146
|
+
),
|
|
147
|
+
("Test status", test_line),
|
|
148
|
+
("Operator note", payload.operator_note or "—"),
|
|
149
|
+
("Authority", AUTHORITY_LABELS[DataAuthority.HISTORICAL]),
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
task = payload.task
|
|
154
|
+
task_fields.set_fields(
|
|
155
|
+
[
|
|
156
|
+
("Task ID", task.task_id),
|
|
157
|
+
("Title", task.task_title),
|
|
158
|
+
("Status", task.task_status),
|
|
159
|
+
("Current work", task.current_work or "—"),
|
|
160
|
+
(
|
|
161
|
+
"Progress",
|
|
162
|
+
f"{len(task.completed)} completed · {len(task.remaining)} remaining · "
|
|
163
|
+
f"{len(task.known_issues)} issue(s)",
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
decisions.set_items(
|
|
169
|
+
list(payload.decisions),
|
|
170
|
+
empty="No decisions recorded in this checkpoint.",
|
|
171
|
+
)
|
|
172
|
+
files.set_items(
|
|
173
|
+
list(payload.files_touched),
|
|
174
|
+
limit=15,
|
|
175
|
+
empty="No files recorded as touched.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def selected_checkpoint(self) -> TuiCheckpointRow | None:
|
|
180
|
+
"""The checkpoint row under the cursor, or None."""
|
|
181
|
+
key = current_row_key(self.query_one("#checkpoints-table", DataTable))
|
|
182
|
+
return self._rows.get(key) if key else None
|
|
183
|
+
|
|
184
|
+
def on_section_shown(self) -> None:
|
|
185
|
+
"""Focus the table when there is anything to select."""
|
|
186
|
+
table = self.query_one("#checkpoints-table", DataTable)
|
|
187
|
+
if table.display:
|
|
188
|
+
table.focus()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The Handoffs section: canonical cross-provider handoff history.
|
|
2
|
+
|
|
3
|
+
Rendered provider prompts are never persisted by CortexShift, so none are shown here.
|
|
4
|
+
What is displayed is the canonical payload the handoff was derived from.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.widgets import DataTable, Static
|
|
10
|
+
|
|
11
|
+
from cortexshift.tui.models import (
|
|
12
|
+
AUTHORITY_LABELS,
|
|
13
|
+
DataAuthority,
|
|
14
|
+
TuiHandoffRow,
|
|
15
|
+
TuiStateSnapshot,
|
|
16
|
+
format_relative,
|
|
17
|
+
format_timestamp,
|
|
18
|
+
)
|
|
19
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
20
|
+
from cortexshift.tui.widgets import (
|
|
21
|
+
BulletList,
|
|
22
|
+
EmptyState,
|
|
23
|
+
FieldList,
|
|
24
|
+
Panel,
|
|
25
|
+
current_row_key,
|
|
26
|
+
marked,
|
|
27
|
+
restore_cursor,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HandoffsSection(SectionView):
|
|
32
|
+
"""Lists handoffs newest first and renders the selected canonical payload."""
|
|
33
|
+
|
|
34
|
+
section = TuiSection.HANDOFFS
|
|
35
|
+
|
|
36
|
+
def __init__(self, id: str | None = None) -> None:
|
|
37
|
+
super().__init__(id=id)
|
|
38
|
+
self._rows: dict[str, TuiHandoffRow] = {}
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
"""Build the handoffs layout."""
|
|
42
|
+
yield Static(Text("Handoffs", style="bold"), classes="section-title")
|
|
43
|
+
yield EmptyState(
|
|
44
|
+
"No handoffs yet.",
|
|
45
|
+
"Start with:\n cortexshift run PROVIDER\n\n"
|
|
46
|
+
"Then press X to switch the task to another agent, or preview a handoff "
|
|
47
|
+
"without launching anything.",
|
|
48
|
+
id="handoffs-empty",
|
|
49
|
+
)
|
|
50
|
+
yield DataTable(id="handoffs-table", cursor_type="row")
|
|
51
|
+
|
|
52
|
+
with Panel(
|
|
53
|
+
"Handoff detail",
|
|
54
|
+
authority=DataAuthority.HISTORICAL,
|
|
55
|
+
id="handoffs-detail-panel",
|
|
56
|
+
):
|
|
57
|
+
yield FieldList(id="handoffs-detail")
|
|
58
|
+
yield Static(Text("Completed", style="bold"), classes="panel-title")
|
|
59
|
+
yield BulletList(id="handoffs-completed")
|
|
60
|
+
yield Static(Text("Remaining", style="bold"), classes="panel-title")
|
|
61
|
+
yield BulletList(id="handoffs-remaining")
|
|
62
|
+
yield Static(Text("Files touched", style="bold"), classes="panel-title")
|
|
63
|
+
yield BulletList(id="handoffs-files")
|
|
64
|
+
|
|
65
|
+
yield Static(
|
|
66
|
+
Text(
|
|
67
|
+
"Press P to preview a fresh canonical handoff for a target provider. "
|
|
68
|
+
"Previews persist nothing and consume no model quota.",
|
|
69
|
+
style="dim italic",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
"""Configure handoff table columns."""
|
|
75
|
+
table = self.query_one("#handoffs-table", DataTable)
|
|
76
|
+
table.add_columns("Handoff", "From", "To", "Status", "Checkpoint", "Created")
|
|
77
|
+
|
|
78
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
79
|
+
"""Refresh the handoffs table, preserving the operator's selection."""
|
|
80
|
+
table = self.query_one("#handoffs-table", DataTable)
|
|
81
|
+
empty = self.query_one("#handoffs-empty", EmptyState)
|
|
82
|
+
detail_panel = self.query_one("#handoffs-detail-panel", Panel)
|
|
83
|
+
|
|
84
|
+
rows = snapshot.handoffs
|
|
85
|
+
self._rows = {row.id: row for row in rows}
|
|
86
|
+
|
|
87
|
+
empty.display = not rows
|
|
88
|
+
table.display = bool(rows)
|
|
89
|
+
detail_panel.display = bool(rows)
|
|
90
|
+
|
|
91
|
+
selected = current_row_key(table)
|
|
92
|
+
table.clear()
|
|
93
|
+
for row in rows:
|
|
94
|
+
table.add_row(
|
|
95
|
+
Text(row.short_id),
|
|
96
|
+
Text(row.source_provider_id),
|
|
97
|
+
Text(row.target_provider_id),
|
|
98
|
+
marked(row.status, row.status),
|
|
99
|
+
Text(row.checkpoint_id[:12] if row.checkpoint_id else "—"),
|
|
100
|
+
Text(format_relative(row.created_at)),
|
|
101
|
+
key=row.id,
|
|
102
|
+
)
|
|
103
|
+
restore_cursor(table, selected)
|
|
104
|
+
self._render_detail()
|
|
105
|
+
|
|
106
|
+
def on_data_table_row_highlighted(self, _event: DataTable.RowHighlighted) -> None:
|
|
107
|
+
"""Render the highlighted handoff in full."""
|
|
108
|
+
self._render_detail()
|
|
109
|
+
|
|
110
|
+
def _render_detail(self) -> None:
|
|
111
|
+
key = current_row_key(self.query_one("#handoffs-table", DataTable))
|
|
112
|
+
row = self._rows.get(key) if key else None
|
|
113
|
+
|
|
114
|
+
detail = self.query_one("#handoffs-detail", FieldList)
|
|
115
|
+
completed = self.query_one("#handoffs-completed", BulletList)
|
|
116
|
+
remaining = self.query_one("#handoffs-remaining", BulletList)
|
|
117
|
+
files = self.query_one("#handoffs-files", BulletList)
|
|
118
|
+
|
|
119
|
+
if row is None:
|
|
120
|
+
detail.set_fields([])
|
|
121
|
+
completed.set_items([])
|
|
122
|
+
remaining.set_items([])
|
|
123
|
+
files.set_items([])
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
record = row.record
|
|
127
|
+
payload = record.payload
|
|
128
|
+
git = payload.git_state
|
|
129
|
+
test_line = (
|
|
130
|
+
Text(f"{payload.test_status.summary} (Reported / unverified)", style="yellow")
|
|
131
|
+
if payload.test_status.known
|
|
132
|
+
else Text(payload.test_status.summary)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
detail.set_fields(
|
|
136
|
+
[
|
|
137
|
+
("Handoff ID", record.id),
|
|
138
|
+
("Protocol", f"v{record.protocol_version}"),
|
|
139
|
+
("Task", f"{payload.task_title} ({record.task_id})"),
|
|
140
|
+
("Source session", record.source_session_id),
|
|
141
|
+
("Source provider", str(record.source_provider_id)),
|
|
142
|
+
("Target session", record.target_session_id or "— (not launched)"),
|
|
143
|
+
("Target provider", str(record.target_provider_id)),
|
|
144
|
+
("Source checkpoint", record.source_checkpoint_id or "—"),
|
|
145
|
+
("Git snapshot", record.git_snapshot_id or "—"),
|
|
146
|
+
("Delivery status", record.status.value),
|
|
147
|
+
(
|
|
148
|
+
"Failure",
|
|
149
|
+
record.failure_code.value if record.failure_code else "—",
|
|
150
|
+
),
|
|
151
|
+
("Created at", format_timestamp(record.created_at)),
|
|
152
|
+
("Delivered at", format_timestamp(record.delivered_at)),
|
|
153
|
+
("Objective", payload.original_objective),
|
|
154
|
+
("Current work", payload.current_work or "—"),
|
|
155
|
+
("Test status", test_line),
|
|
156
|
+
(
|
|
157
|
+
"Git state",
|
|
158
|
+
f"{git.status.value} · {git.branch or '(detached)'} · "
|
|
159
|
+
f"{'dirty' if git.dirty else 'clean'}",
|
|
160
|
+
),
|
|
161
|
+
("Next action", payload.recommended_next_action),
|
|
162
|
+
("Authority", AUTHORITY_LABELS[DataAuthority.HISTORICAL]),
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
completed.set_items(list(payload.completed), empty="Nothing recorded as completed.")
|
|
167
|
+
remaining.set_items(list(payload.remaining), empty="No remaining items recorded.")
|
|
168
|
+
files.set_items(list(payload.files_touched), limit=15, empty="No files recorded.")
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def selected_handoff(self) -> TuiHandoffRow | None:
|
|
172
|
+
"""The handoff row under the cursor, or None."""
|
|
173
|
+
key = current_row_key(self.query_one("#handoffs-table", DataTable))
|
|
174
|
+
return self._rows.get(key) if key else None
|
|
175
|
+
|
|
176
|
+
def on_section_shown(self) -> None:
|
|
177
|
+
"""Focus the table when there is anything to select."""
|
|
178
|
+
table = self.query_one("#handoffs-table", DataTable)
|
|
179
|
+
if table.display:
|
|
180
|
+
table.focus()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The Help modal: the keyboard contract, in one place."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Vertical, VerticalScroll
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Static
|
|
9
|
+
|
|
10
|
+
HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
11
|
+
(
|
|
12
|
+
"Navigation",
|
|
13
|
+
(
|
|
14
|
+
("1", "Overview"),
|
|
15
|
+
("2", "Task"),
|
|
16
|
+
("3", "Repository"),
|
|
17
|
+
("4", "Sessions"),
|
|
18
|
+
("5", "Checkpoints"),
|
|
19
|
+
("6", "Handoffs"),
|
|
20
|
+
("7", "Providers"),
|
|
21
|
+
("tab / shift+tab", "Move focus between widgets"),
|
|
22
|
+
("↑ ↓ / page up / page down", "Move within tables and lists"),
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
"Refresh",
|
|
27
|
+
(
|
|
28
|
+
("r", "Refresh everything, including live Git and provider discovery"),
|
|
29
|
+
("", "Persisted state also refreshes on a lightweight timer (~2s)"),
|
|
30
|
+
("", "Git runs on entry to Repository and on explicit refresh only"),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
(
|
|
34
|
+
"Task",
|
|
35
|
+
(
|
|
36
|
+
("w", "Set current work"),
|
|
37
|
+
("m", "Mark a remaining item completed"),
|
|
38
|
+
("n", "Add a remaining item"),
|
|
39
|
+
("i", "Record a known issue"),
|
|
40
|
+
("a / enter", "Activate the selected task (Task screen)"),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"Checkpoints & recovery",
|
|
45
|
+
(
|
|
46
|
+
("c", "Create a MANUAL checkpoint (no workspace lease held)"),
|
|
47
|
+
("shift+r", "Review and run crash recovery"),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"Providers",
|
|
52
|
+
(
|
|
53
|
+
("x", "Provider action palette — run, resume, or switch"),
|
|
54
|
+
("p", "Preview a canonical handoff (persists nothing, no model call)"),
|
|
55
|
+
("g", "Configure workspace MCP for Antigravity"),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"General",
|
|
60
|
+
(
|
|
61
|
+
("ctrl+p", "Command palette"),
|
|
62
|
+
("?", "This help"),
|
|
63
|
+
("q", "Quit"),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
TERMINAL_NOTE = (
|
|
69
|
+
"Provider-native TUIs are never embedded. When you choose run, resume, or switch, "
|
|
70
|
+
"CortexShift closes this dashboard, restores the terminal, and only then launches "
|
|
71
|
+
"Claude Code, Codex, or Antigravity so the agent owns the terminal directly."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HelpScreen(ModalScreen[None]):
|
|
76
|
+
"""A concise reference for the dashboard's keyboard contract."""
|
|
77
|
+
|
|
78
|
+
BINDINGS = [
|
|
79
|
+
Binding("escape", "dismiss_help", "Close", show=True),
|
|
80
|
+
Binding("question_mark", "dismiss_help", "Close", show=False),
|
|
81
|
+
Binding("q", "dismiss_help", "Close", show=False),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
"""Build the help modal."""
|
|
86
|
+
with Vertical(classes="modal", id="help-modal"):
|
|
87
|
+
yield Static(
|
|
88
|
+
Text("CortexShift — Keyboard Contract", style="bold"),
|
|
89
|
+
classes="modal-title",
|
|
90
|
+
)
|
|
91
|
+
with VerticalScroll(classes="modal-body"):
|
|
92
|
+
yield Static(self._render_body(), id="help-body")
|
|
93
|
+
yield Static(Text(TERMINAL_NOTE, style="dim italic"))
|
|
94
|
+
with Vertical(classes="modal-buttons"):
|
|
95
|
+
yield Button("Close", variant="primary", id="help-close")
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _render_body() -> Text:
|
|
99
|
+
body = Text()
|
|
100
|
+
for index, (title, entries) in enumerate(HELP_SECTIONS):
|
|
101
|
+
if index:
|
|
102
|
+
body.append("\n\n")
|
|
103
|
+
body.append(title.upper(), style="bold cyan")
|
|
104
|
+
for key, description in entries:
|
|
105
|
+
body.append("\n")
|
|
106
|
+
body.append(f" {key:<26}", style="bold")
|
|
107
|
+
body.append(description)
|
|
108
|
+
return body
|
|
109
|
+
|
|
110
|
+
def action_dismiss_help(self) -> None:
|
|
111
|
+
"""Close the help modal."""
|
|
112
|
+
self.dismiss(None)
|
|
113
|
+
|
|
114
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
115
|
+
"""Close on button activation."""
|
|
116
|
+
event.stop()
|
|
117
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The Overview section: the default landing view of the control center."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from cortexshift.tui.models import (
|
|
9
|
+
DataAuthority,
|
|
10
|
+
TuiMcpStatus,
|
|
11
|
+
TuiProviderStatus,
|
|
12
|
+
TuiRepositoryModel,
|
|
13
|
+
TuiStateSnapshot,
|
|
14
|
+
WorkspaceActivity,
|
|
15
|
+
format_relative,
|
|
16
|
+
truncate,
|
|
17
|
+
)
|
|
18
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
19
|
+
from cortexshift.tui.widgets import (
|
|
20
|
+
BulletList,
|
|
21
|
+
FieldList,
|
|
22
|
+
Panel,
|
|
23
|
+
marked,
|
|
24
|
+
render_progress_bar,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
MARKER_WARN = "!"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OverviewSection(SectionView):
|
|
31
|
+
"""Project identity, active task progress, repository state, and recent activity."""
|
|
32
|
+
|
|
33
|
+
section = TuiSection.OVERVIEW
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
"""Build the overview layout."""
|
|
37
|
+
yield Static(Text("Overview", style="bold"), classes="section-title")
|
|
38
|
+
yield Static("", id="overview-recovery", classes="notice")
|
|
39
|
+
|
|
40
|
+
with Horizontal(classes="columns"):
|
|
41
|
+
with Vertical(classes="column"):
|
|
42
|
+
with Panel("Project"):
|
|
43
|
+
yield FieldList(id="overview-project")
|
|
44
|
+
with Panel("Active task"):
|
|
45
|
+
yield Static("", id="overview-task-title")
|
|
46
|
+
yield Static("", id="overview-progress")
|
|
47
|
+
yield FieldList(id="overview-task")
|
|
48
|
+
with Vertical(classes="column"):
|
|
49
|
+
with Panel("Repository", authority=DataAuthority.LIVE):
|
|
50
|
+
yield FieldList(id="overview-repository")
|
|
51
|
+
with Panel("Activity"):
|
|
52
|
+
yield FieldList(id="overview-activity")
|
|
53
|
+
with Panel("Providers"):
|
|
54
|
+
yield BulletList(id="overview-providers")
|
|
55
|
+
|
|
56
|
+
# -- rendering ---------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
59
|
+
"""Render project, task, and activity facts from persisted state."""
|
|
60
|
+
project = snapshot.project
|
|
61
|
+
self.query_one("#overview-project", FieldList).set_fields(
|
|
62
|
+
[
|
|
63
|
+
("Project", project.name),
|
|
64
|
+
("Project ID", project.short_id),
|
|
65
|
+
("CortexShift", project.cortexshift_version),
|
|
66
|
+
("Schema", f"v{project.schema_version}"),
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._render_task(snapshot)
|
|
71
|
+
self._render_activity(snapshot)
|
|
72
|
+
|
|
73
|
+
def _render_task(self, snapshot: TuiStateSnapshot) -> None:
|
|
74
|
+
title = self.query_one("#overview-task-title", Static)
|
|
75
|
+
progress = self.query_one("#overview-progress", Static)
|
|
76
|
+
fields = self.query_one("#overview-task", FieldList)
|
|
77
|
+
|
|
78
|
+
task = snapshot.active_task
|
|
79
|
+
if task is None:
|
|
80
|
+
title.update(Text("No active task.", style="bold"))
|
|
81
|
+
progress.update(
|
|
82
|
+
Text(
|
|
83
|
+
"Activate a task on the Task screen (2), or create one first.",
|
|
84
|
+
style="dim",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
fields.set_fields([])
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
heading = Text()
|
|
91
|
+
heading.append(task.title, style="bold")
|
|
92
|
+
heading.append(f" {task.status}", style="dim")
|
|
93
|
+
title.update(heading)
|
|
94
|
+
progress.update(render_progress_bar(task.progress))
|
|
95
|
+
fields.set_fields(
|
|
96
|
+
[
|
|
97
|
+
("Task ID", task.short_id),
|
|
98
|
+
("Objective", truncate(task.objective, 70)),
|
|
99
|
+
("Current work", truncate(task.current_work, 70)),
|
|
100
|
+
("Completed", str(task.progress.completed_count)),
|
|
101
|
+
("Remaining", str(task.progress.remaining_count)),
|
|
102
|
+
("Known issues", str(task.progress.issue_count)),
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _render_activity(self, snapshot: TuiStateSnapshot) -> None:
|
|
107
|
+
activity = snapshot.activity
|
|
108
|
+
session = activity.latest_session
|
|
109
|
+
checkpoint = activity.latest_checkpoint
|
|
110
|
+
handoff = activity.latest_handoff
|
|
111
|
+
|
|
112
|
+
session_value: str | Text = "No session recorded."
|
|
113
|
+
if session is not None:
|
|
114
|
+
session_value = Text()
|
|
115
|
+
session_value.append(f"{session.provider_id} · ")
|
|
116
|
+
session_value.append_text(marked(session.status, session.status))
|
|
117
|
+
session_value.append(f" · {format_relative(session.started_at)}")
|
|
118
|
+
|
|
119
|
+
checkpoint_value: str | Text = "No checkpoint recorded."
|
|
120
|
+
if checkpoint is not None:
|
|
121
|
+
checkpoint_value = f"{checkpoint.kind} · {format_relative(checkpoint.created_at)}"
|
|
122
|
+
|
|
123
|
+
handoff_value: str | Text = "No handoff recorded."
|
|
124
|
+
if handoff is not None:
|
|
125
|
+
handoff_value = (
|
|
126
|
+
f"{handoff.source_provider_id} → {handoff.target_provider_id} · "
|
|
127
|
+
f"{handoff.status} · {format_relative(handoff.created_at)}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self.query_one("#overview-activity", FieldList).set_fields(
|
|
131
|
+
[
|
|
132
|
+
("Latest session", session_value),
|
|
133
|
+
("Latest checkpoint", checkpoint_value),
|
|
134
|
+
("Latest handoff", handoff_value),
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
notice = self.query_one("#overview-recovery", Static)
|
|
139
|
+
if activity.recovery_may_be_required:
|
|
140
|
+
count = activity.unfinalized_session_count
|
|
141
|
+
plural = "" if count == 1 else "s"
|
|
142
|
+
notice.update(
|
|
143
|
+
Text(
|
|
144
|
+
f"{MARKER_WARN} {count} unfinalized CortexShift Session{plural} recorded. "
|
|
145
|
+
"Recovery may be required — press R to review.",
|
|
146
|
+
style="bold yellow",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
notice.update("")
|
|
151
|
+
|
|
152
|
+
def update_repository(self, repository: TuiRepositoryModel | None) -> None:
|
|
153
|
+
"""Render the most recent live Git observation."""
|
|
154
|
+
fields = self.query_one("#overview-repository", FieldList)
|
|
155
|
+
if repository is None:
|
|
156
|
+
fields.set_fields([("Git", Text("Inspecting…", style="dim italic"))])
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if not repository.ready:
|
|
160
|
+
fields.set_fields(
|
|
161
|
+
[
|
|
162
|
+
("Git", marked("warn", repository.status.value)),
|
|
163
|
+
("Detail", truncate(repository.diagnostic, 60)),
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
branch = repository.branch or "(detached HEAD)"
|
|
169
|
+
state = "dirty" if repository.dirty else "clean"
|
|
170
|
+
fields.set_fields(
|
|
171
|
+
[
|
|
172
|
+
("Branch", branch),
|
|
173
|
+
("HEAD", repository.short_head),
|
|
174
|
+
("State", marked(state, state)),
|
|
175
|
+
("Changed files", str(repository.changed_file_count)),
|
|
176
|
+
("Observed", format_relative(repository.observed_at)),
|
|
177
|
+
]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def update_providers(
|
|
181
|
+
self,
|
|
182
|
+
providers: tuple[TuiProviderStatus, ...],
|
|
183
|
+
mcp: TuiMcpStatus | None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Render a compact provider availability list without account details."""
|
|
186
|
+
widget = self.query_one("#overview-providers", BulletList)
|
|
187
|
+
if not providers:
|
|
188
|
+
widget.set_items([], empty="Probing providers…")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
lines = [
|
|
192
|
+
f"{provider.display_name:<14}{'available' if provider.available else 'unavailable'}"
|
|
193
|
+
for provider in providers
|
|
194
|
+
]
|
|
195
|
+
if mcp is not None and not mcp.antigravity_configured:
|
|
196
|
+
lines.append("Antigravity workspace MCP is not configured (Providers · G)")
|
|
197
|
+
widget.set_items(lines, limit=10)
|
|
198
|
+
|
|
199
|
+
def update_activity(self, activity: WorkspaceActivity) -> None:
|
|
200
|
+
"""Workspace lease state is surfaced by the app's status line."""
|