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,594 @@
|
|
|
1
|
+
"""Modal dialogs for intentional, confirmed dashboard actions.
|
|
2
|
+
|
|
3
|
+
Every modal here is inert until explicitly confirmed: opening one never mutates state,
|
|
4
|
+
never launches a provider, and never consumes model quota. Each returns a plain value to
|
|
5
|
+
the app, which then calls the appropriate application service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import Button, Input, OptionList, Static
|
|
16
|
+
from textual.widgets.option_list import Option
|
|
17
|
+
|
|
18
|
+
from cortexshift.domain.checkpoint import (
|
|
19
|
+
MAX_DECISION_CHARS,
|
|
20
|
+
MAX_OPERATOR_NOTE_CHARS,
|
|
21
|
+
MAX_TEST_SUMMARY_CHARS,
|
|
22
|
+
)
|
|
23
|
+
from cortexshift.domain.task import MAX_CURRENT_WORK_CHARS, MAX_ITEM_CHARS
|
|
24
|
+
from cortexshift.tui.actions import TuiExitAction
|
|
25
|
+
from cortexshift.tui.models import TuiHandoffPreview, TuiRecoveryPreview, TuiSwitchPreview
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class CurrentWorkInput:
|
|
30
|
+
"""A confirmed current-work entry.
|
|
31
|
+
|
|
32
|
+
`value=None` means the operator deliberately cleared the entry. Cancelling the dialog
|
|
33
|
+
returns nothing at all, so a cancel can never be mistaken for a clear.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
value: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class CheckpointInput:
|
|
41
|
+
"""Operator-supplied content for a MANUAL checkpoint."""
|
|
42
|
+
|
|
43
|
+
decision: str | None
|
|
44
|
+
test_summary: str | None
|
|
45
|
+
note: str | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ProviderActionOption:
|
|
50
|
+
"""One concrete, pre-validated provider action offered to the operator."""
|
|
51
|
+
|
|
52
|
+
action: TuiExitAction
|
|
53
|
+
provider: str
|
|
54
|
+
label: str
|
|
55
|
+
detail: str
|
|
56
|
+
selected_session_id: str | None = None
|
|
57
|
+
force_new_session: bool = False
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
disabled_reason: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def key(self) -> str:
|
|
63
|
+
"""Stable option identifier."""
|
|
64
|
+
suffix = "new" if self.force_new_session else (self.selected_session_id or "default")
|
|
65
|
+
return f"{self.action.value}:{self.provider}:{suffix}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _ModalBase(ModalScreen[object]):
|
|
69
|
+
"""Shared chrome and dismissal behaviour for CortexShift modals.
|
|
70
|
+
|
|
71
|
+
Modals declare their initial focus with `AUTO_FOCUS` rather than querying for a widget
|
|
72
|
+
in `on_mount`. A screen receives `Mount` before its `compose` has finished mounting the
|
|
73
|
+
subtree, so `query_one` there is a race: on a slow machine the widget does not exist
|
|
74
|
+
yet and opening the dialog raises `NoMatches` instead of showing it. Textual applies
|
|
75
|
+
`AUTO_FOCUS` once the screen is composed, which is the same intent without the race.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
BINDINGS = [
|
|
79
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def action_cancel(self) -> None:
|
|
83
|
+
"""Dismiss without performing the action."""
|
|
84
|
+
self.dismiss(None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TextEntryModal(_ModalBase):
|
|
88
|
+
"""A single-field text entry dialog with explicit confirmation.
|
|
89
|
+
|
|
90
|
+
Empty input is rejected unless the dialog explicitly allows clearing a value.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
AUTO_FOCUS = "#entry-input"
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
title: str,
|
|
98
|
+
*,
|
|
99
|
+
label: str,
|
|
100
|
+
placeholder: str = "",
|
|
101
|
+
initial: str = "",
|
|
102
|
+
help_text: str = "",
|
|
103
|
+
max_length: int = MAX_ITEM_CHARS,
|
|
104
|
+
allow_empty: bool = False,
|
|
105
|
+
confirm_label: str = "Save",
|
|
106
|
+
) -> None:
|
|
107
|
+
super().__init__()
|
|
108
|
+
self._title = title
|
|
109
|
+
self._label = label
|
|
110
|
+
self._placeholder = placeholder
|
|
111
|
+
self._initial = initial
|
|
112
|
+
self._help_text = help_text
|
|
113
|
+
self._max_length = max_length
|
|
114
|
+
self._allow_empty = allow_empty
|
|
115
|
+
self._confirm_label = confirm_label
|
|
116
|
+
|
|
117
|
+
def compose(self) -> ComposeResult:
|
|
118
|
+
"""Build the entry dialog."""
|
|
119
|
+
with Vertical(classes="modal"):
|
|
120
|
+
yield Static(Text(self._title, style="bold"), classes="modal-title")
|
|
121
|
+
if self._help_text:
|
|
122
|
+
yield Static(Text(self._help_text, style="dim"), classes="modal-help")
|
|
123
|
+
yield Static(Text(self._label), classes="field-label")
|
|
124
|
+
yield Input(
|
|
125
|
+
value=self._initial,
|
|
126
|
+
placeholder=self._placeholder,
|
|
127
|
+
max_length=self._max_length,
|
|
128
|
+
id="entry-input",
|
|
129
|
+
)
|
|
130
|
+
yield Static("", id="entry-error", classes="modal-error")
|
|
131
|
+
with Horizontal(classes="modal-buttons"):
|
|
132
|
+
yield Button("Cancel", id="entry-cancel")
|
|
133
|
+
yield Button(self._confirm_label, variant="primary", id="entry-confirm")
|
|
134
|
+
|
|
135
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
136
|
+
"""Confirm on Enter."""
|
|
137
|
+
event.stop()
|
|
138
|
+
self._confirm()
|
|
139
|
+
|
|
140
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
141
|
+
"""Confirm or cancel from the button row."""
|
|
142
|
+
event.stop()
|
|
143
|
+
if event.button.id == "entry-confirm":
|
|
144
|
+
self._confirm()
|
|
145
|
+
else:
|
|
146
|
+
self.dismiss(None)
|
|
147
|
+
|
|
148
|
+
def _confirm(self) -> None:
|
|
149
|
+
value = self.query_one("#entry-input", Input).value.strip()
|
|
150
|
+
if not value and not self._allow_empty:
|
|
151
|
+
self.query_one("#entry-error", Static).update(
|
|
152
|
+
Text("A value is required.", style="bold red")
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
if len(value) > self._max_length:
|
|
156
|
+
self.query_one("#entry-error", Static).update(
|
|
157
|
+
Text(
|
|
158
|
+
f"Value exceeds the maximum of {self._max_length} characters.",
|
|
159
|
+
style="bold red",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
self.dismiss(self.build_result(value))
|
|
164
|
+
|
|
165
|
+
def build_result(self, value: str) -> object:
|
|
166
|
+
"""Convert confirmed text into the value handed back to the app."""
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SetCurrentWorkModal(TextEntryModal):
|
|
171
|
+
"""Replace the active Task's in-flight work description."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, initial: str = "") -> None:
|
|
174
|
+
super().__init__(
|
|
175
|
+
"Set current work",
|
|
176
|
+
label="What is being worked on right now?",
|
|
177
|
+
placeholder="Wiring the token refresh path",
|
|
178
|
+
initial=initial,
|
|
179
|
+
help_text="Confirm with an empty field to clear the current work entry.",
|
|
180
|
+
max_length=MAX_CURRENT_WORK_CHARS,
|
|
181
|
+
allow_empty=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def build_result(self, value: str) -> object:
|
|
185
|
+
"""Wrap the entry so a deliberate clear is distinct from a cancel."""
|
|
186
|
+
return CurrentWorkInput(value or None)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AddRemainingModal(TextEntryModal):
|
|
190
|
+
"""Add a newly discovered remaining item to the active Task."""
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None:
|
|
193
|
+
super().__init__(
|
|
194
|
+
"Add remaining item",
|
|
195
|
+
label="Remaining work item",
|
|
196
|
+
placeholder="Wire token refresh into the CLI",
|
|
197
|
+
help_text="Items are appended to the canonical Task and deduplicated.",
|
|
198
|
+
confirm_label="Add",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class RecordIssueModal(TextEntryModal):
|
|
203
|
+
"""Record a known issue or blocker on the active Task."""
|
|
204
|
+
|
|
205
|
+
def __init__(self) -> None:
|
|
206
|
+
super().__init__(
|
|
207
|
+
"Record issue",
|
|
208
|
+
label="Known issue or blocker",
|
|
209
|
+
placeholder="Refresh tokens expire earlier than documented",
|
|
210
|
+
help_text="Issues are appended to the canonical Task and deduplicated.",
|
|
211
|
+
confirm_label="Record",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class MarkCompletedModal(_ModalBase):
|
|
216
|
+
"""Mark one item completed on the active Task.
|
|
217
|
+
|
|
218
|
+
Uses the canonical rule: the item is appended to completed (deduplicated) and any
|
|
219
|
+
exactly matching remaining entry is removed. The Task itself is never completed.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
AUTO_FOCUS = "#entry-input"
|
|
223
|
+
|
|
224
|
+
def __init__(self, remaining: tuple[str, ...]) -> None:
|
|
225
|
+
super().__init__()
|
|
226
|
+
self._remaining = remaining
|
|
227
|
+
|
|
228
|
+
def compose(self) -> ComposeResult:
|
|
229
|
+
"""Build the mark-completed dialog."""
|
|
230
|
+
with Vertical(classes="modal"):
|
|
231
|
+
yield Static(Text("Mark completed", style="bold"), classes="modal-title")
|
|
232
|
+
yield Static(
|
|
233
|
+
Text(
|
|
234
|
+
"Marks one item as completed and removes it from remaining. "
|
|
235
|
+
"This never completes the Task itself.",
|
|
236
|
+
style="dim",
|
|
237
|
+
),
|
|
238
|
+
classes="modal-help",
|
|
239
|
+
)
|
|
240
|
+
if self._remaining:
|
|
241
|
+
yield Static(Text("Remaining items"), classes="field-label")
|
|
242
|
+
# Built with its options rather than filled in `on_mount`, so the list is
|
|
243
|
+
# complete the moment it exists.
|
|
244
|
+
yield OptionList(
|
|
245
|
+
*[Option(item, id=str(index)) for index, item in enumerate(self._remaining)],
|
|
246
|
+
id="completed-options",
|
|
247
|
+
)
|
|
248
|
+
yield Static(Text("Item to mark completed"), classes="field-label")
|
|
249
|
+
yield Input(
|
|
250
|
+
value=self._remaining[0] if self._remaining else "",
|
|
251
|
+
placeholder="Implement parser",
|
|
252
|
+
max_length=MAX_ITEM_CHARS,
|
|
253
|
+
id="entry-input",
|
|
254
|
+
)
|
|
255
|
+
yield Static("", id="entry-error", classes="modal-error")
|
|
256
|
+
with Horizontal(classes="modal-buttons"):
|
|
257
|
+
yield Button("Cancel", id="entry-cancel")
|
|
258
|
+
yield Button("Mark completed", variant="primary", id="entry-confirm")
|
|
259
|
+
|
|
260
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
261
|
+
"""Mirror the highlighted remaining item into the editable input."""
|
|
262
|
+
event.stop()
|
|
263
|
+
self.query_one("#entry-input", Input).value = self._remaining[event.option_index]
|
|
264
|
+
|
|
265
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
266
|
+
"""Selecting an option fills the input; confirmation is still explicit."""
|
|
267
|
+
event.stop()
|
|
268
|
+
entry = self.query_one("#entry-input", Input)
|
|
269
|
+
entry.value = self._remaining[event.option_index]
|
|
270
|
+
entry.focus()
|
|
271
|
+
|
|
272
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
273
|
+
"""Confirm on Enter."""
|
|
274
|
+
event.stop()
|
|
275
|
+
self._confirm()
|
|
276
|
+
|
|
277
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
278
|
+
"""Confirm or cancel from the button row."""
|
|
279
|
+
event.stop()
|
|
280
|
+
if event.button.id == "entry-confirm":
|
|
281
|
+
self._confirm()
|
|
282
|
+
else:
|
|
283
|
+
self.dismiss(None)
|
|
284
|
+
|
|
285
|
+
def _confirm(self) -> None:
|
|
286
|
+
value = self.query_one("#entry-input", Input).value.strip()
|
|
287
|
+
if not value:
|
|
288
|
+
self.query_one("#entry-error", Static).update(
|
|
289
|
+
Text("Select or type an item to mark completed.", style="bold red")
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
self.dismiss(value)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class CheckpointModal(_ModalBase):
|
|
296
|
+
"""Capture a cooperative MANUAL checkpoint."""
|
|
297
|
+
|
|
298
|
+
AUTO_FOCUS = "#checkpoint-decision"
|
|
299
|
+
|
|
300
|
+
def compose(self) -> ComposeResult:
|
|
301
|
+
"""Build the checkpoint dialog."""
|
|
302
|
+
with Vertical(classes="modal"):
|
|
303
|
+
yield Static(Text("Create checkpoint", style="bold"), classes="modal-title")
|
|
304
|
+
yield Static(
|
|
305
|
+
Text(
|
|
306
|
+
"Captures canonical Task state and a live repository observation. "
|
|
307
|
+
"No workspace lease is taken and no provider is contacted.",
|
|
308
|
+
style="dim",
|
|
309
|
+
),
|
|
310
|
+
classes="modal-help",
|
|
311
|
+
)
|
|
312
|
+
yield Static(Text("Decision (optional)"), classes="field-label")
|
|
313
|
+
yield Input(
|
|
314
|
+
placeholder="Chose exact native resume over transcript replay",
|
|
315
|
+
max_length=MAX_DECISION_CHARS,
|
|
316
|
+
id="checkpoint-decision",
|
|
317
|
+
)
|
|
318
|
+
yield Static(Text("Test summary (optional)"), classes="field-label")
|
|
319
|
+
yield Input(
|
|
320
|
+
placeholder="572 passed",
|
|
321
|
+
max_length=MAX_TEST_SUMMARY_CHARS,
|
|
322
|
+
id="checkpoint-tests",
|
|
323
|
+
)
|
|
324
|
+
yield Static(
|
|
325
|
+
Text(
|
|
326
|
+
"Recorded as Reported / unverified. CortexShift never converts a "
|
|
327
|
+
"claim into a verified result.",
|
|
328
|
+
style="dim italic",
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
yield Static(Text("Operator note (optional)"), classes="field-label")
|
|
332
|
+
yield Input(
|
|
333
|
+
placeholder="Stopping before the refactor lands",
|
|
334
|
+
max_length=MAX_OPERATOR_NOTE_CHARS,
|
|
335
|
+
id="checkpoint-note",
|
|
336
|
+
)
|
|
337
|
+
yield Static("", id="checkpoint-error", classes="modal-error")
|
|
338
|
+
with Horizontal(classes="modal-buttons"):
|
|
339
|
+
yield Button("Cancel", id="checkpoint-cancel")
|
|
340
|
+
yield Button("Create checkpoint", variant="primary", id="checkpoint-confirm")
|
|
341
|
+
|
|
342
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
343
|
+
"""Confirm on Enter from any field."""
|
|
344
|
+
event.stop()
|
|
345
|
+
self._confirm()
|
|
346
|
+
|
|
347
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
348
|
+
"""Confirm or cancel from the button row."""
|
|
349
|
+
event.stop()
|
|
350
|
+
if event.button.id == "checkpoint-confirm":
|
|
351
|
+
self._confirm()
|
|
352
|
+
else:
|
|
353
|
+
self.dismiss(None)
|
|
354
|
+
|
|
355
|
+
def _confirm(self) -> None:
|
|
356
|
+
decision = self.query_one("#checkpoint-decision", Input).value.strip()
|
|
357
|
+
tests = self.query_one("#checkpoint-tests", Input).value.strip()
|
|
358
|
+
note = self.query_one("#checkpoint-note", Input).value.strip()
|
|
359
|
+
self.dismiss(
|
|
360
|
+
CheckpointInput(
|
|
361
|
+
decision=decision or None,
|
|
362
|
+
test_summary=tests or None,
|
|
363
|
+
note=note or None,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class ConfirmModal(_ModalBase):
|
|
369
|
+
"""A generic confirmation dialog rendering a prepared summary."""
|
|
370
|
+
|
|
371
|
+
AUTO_FOCUS = "#confirm-cancel"
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
title: str,
|
|
376
|
+
body: Text | str,
|
|
377
|
+
*,
|
|
378
|
+
confirm_label: str = "Confirm",
|
|
379
|
+
confirm_variant: str = "primary",
|
|
380
|
+
) -> None:
|
|
381
|
+
super().__init__()
|
|
382
|
+
self._title = title
|
|
383
|
+
self._body = body
|
|
384
|
+
self._confirm_label = confirm_label
|
|
385
|
+
self._confirm_variant = confirm_variant
|
|
386
|
+
|
|
387
|
+
def compose(self) -> ComposeResult:
|
|
388
|
+
"""Build the confirmation dialog."""
|
|
389
|
+
with Vertical(classes="modal"):
|
|
390
|
+
yield Static(Text(self._title, style="bold"), classes="modal-title")
|
|
391
|
+
with VerticalScroll(classes="modal-body"):
|
|
392
|
+
yield Static(self._body, id="confirm-body")
|
|
393
|
+
with Horizontal(classes="modal-buttons"):
|
|
394
|
+
yield Button("Cancel", id="confirm-cancel")
|
|
395
|
+
yield Button(
|
|
396
|
+
self._confirm_label,
|
|
397
|
+
variant=self._confirm_variant, # type: ignore[arg-type]
|
|
398
|
+
id="confirm-ok",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
402
|
+
"""Return the operator's decision."""
|
|
403
|
+
event.stop()
|
|
404
|
+
self.dismiss(event.button.id == "confirm-ok")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class InfoModal(_ModalBase):
|
|
408
|
+
"""A read-only panel for bounded previews and error detail."""
|
|
409
|
+
|
|
410
|
+
AUTO_FOCUS = "#info-close"
|
|
411
|
+
|
|
412
|
+
BINDINGS = [
|
|
413
|
+
Binding("escape", "cancel", "Close", show=True),
|
|
414
|
+
Binding("q", "cancel", "Close", show=False),
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
def __init__(self, title: str, body: Text | str) -> None:
|
|
418
|
+
super().__init__()
|
|
419
|
+
self._title = title
|
|
420
|
+
self._body = body
|
|
421
|
+
|
|
422
|
+
def compose(self) -> ComposeResult:
|
|
423
|
+
"""Build the information panel."""
|
|
424
|
+
with Vertical(classes="modal"):
|
|
425
|
+
yield Static(Text(self._title, style="bold"), classes="modal-title")
|
|
426
|
+
with VerticalScroll(classes="modal-body"):
|
|
427
|
+
yield Static(self._body, id="info-body")
|
|
428
|
+
with Horizontal(classes="modal-buttons"):
|
|
429
|
+
yield Button("Close", variant="primary", id="info-close")
|
|
430
|
+
|
|
431
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
432
|
+
"""Close the panel."""
|
|
433
|
+
event.stop()
|
|
434
|
+
self.dismiss(None)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class ProviderActionModal(_ModalBase):
|
|
438
|
+
"""Choose a provider action.
|
|
439
|
+
|
|
440
|
+
Only actions that are currently valid can be selected. Invalid ones remain visible
|
|
441
|
+
but disabled with the reason stated, so the operator learns why.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
AUTO_FOCUS = "#provider-actions"
|
|
445
|
+
|
|
446
|
+
def __init__(self, options: tuple[ProviderActionOption, ...]) -> None:
|
|
447
|
+
super().__init__()
|
|
448
|
+
self._options = options
|
|
449
|
+
|
|
450
|
+
def compose(self) -> ComposeResult:
|
|
451
|
+
"""Build the provider action palette."""
|
|
452
|
+
with Vertical(classes="modal"):
|
|
453
|
+
yield Static(Text("Provider action", style="bold"), classes="modal-title")
|
|
454
|
+
yield Static(
|
|
455
|
+
Text(
|
|
456
|
+
"CortexShift closes this dashboard and restores the terminal before "
|
|
457
|
+
"the native provider starts. Provider TUIs are never embedded.",
|
|
458
|
+
style="dim",
|
|
459
|
+
),
|
|
460
|
+
classes="modal-help",
|
|
461
|
+
)
|
|
462
|
+
# Built with its options rather than filled in `on_mount`, so the palette
|
|
463
|
+
# is complete the moment it exists.
|
|
464
|
+
yield OptionList(*self._option_rows(), id="provider-actions")
|
|
465
|
+
with Horizontal(classes="modal-buttons"):
|
|
466
|
+
yield Button("Cancel", id="provider-cancel")
|
|
467
|
+
|
|
468
|
+
def _option_rows(self) -> list[Option]:
|
|
469
|
+
"""Render each provider action, stating why a disabled one is unavailable."""
|
|
470
|
+
rows: list[Option] = []
|
|
471
|
+
for index, option in enumerate(self._options):
|
|
472
|
+
prompt = Text()
|
|
473
|
+
prompt.append(option.label, style="bold" if option.enabled else "dim")
|
|
474
|
+
detail = option.detail if option.enabled else (option.disabled_reason or "unavailable")
|
|
475
|
+
prompt.append(f"\n {detail}", style="dim")
|
|
476
|
+
rows.append(Option(prompt, id=str(index), disabled=not option.enabled))
|
|
477
|
+
return rows
|
|
478
|
+
|
|
479
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
480
|
+
"""Return the chosen provider action."""
|
|
481
|
+
event.stop()
|
|
482
|
+
self.dismiss(self._options[event.option_index])
|
|
483
|
+
|
|
484
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
485
|
+
"""Cancel without choosing an action."""
|
|
486
|
+
event.stop()
|
|
487
|
+
self.dismiss(None)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def render_switch_preview(preview: TuiSwitchPreview) -> Text:
|
|
491
|
+
"""Render a switch confirmation summary, built without any model call."""
|
|
492
|
+
body = Text()
|
|
493
|
+
rows: list[tuple[str, str]] = [
|
|
494
|
+
("From", f"{preview.source_provider_id} ({preview.source_session_id})"),
|
|
495
|
+
("Target", f"{preview.target_provider_name} ({preview.target_provider_id})"),
|
|
496
|
+
("Task", f"{preview.task_title} ({preview.task_id})"),
|
|
497
|
+
("Target native mode", preview.target_native_mode),
|
|
498
|
+
(
|
|
499
|
+
"Prior target session",
|
|
500
|
+
preview.selected_prior_target_session_id or "— (new native conversation)",
|
|
501
|
+
),
|
|
502
|
+
(
|
|
503
|
+
"Git state",
|
|
504
|
+
f"{preview.git_status} · {preview.git_branch or '(detached)'} · "
|
|
505
|
+
f"{'dirty' if preview.git_dirty else 'clean'}",
|
|
506
|
+
),
|
|
507
|
+
("Checkpoint enrichment", preview.checkpoint_enrichment),
|
|
508
|
+
("Delivery", preview.delivery_strategy),
|
|
509
|
+
(
|
|
510
|
+
"Bootstrap model turn",
|
|
511
|
+
"yes — one read-only planning turn will run"
|
|
512
|
+
if preview.bootstrap_model_turn_required
|
|
513
|
+
else "no",
|
|
514
|
+
),
|
|
515
|
+
(
|
|
516
|
+
"Context size",
|
|
517
|
+
f"{preview.context_characters} / {preview.context_max_characters} characters"
|
|
518
|
+
+ (" (truncated)" if preview.context_truncated else ""),
|
|
519
|
+
),
|
|
520
|
+
]
|
|
521
|
+
for index, (label, value) in enumerate(rows):
|
|
522
|
+
if index:
|
|
523
|
+
body.append("\n")
|
|
524
|
+
body.append(f"{label:<24}", style="bold cyan")
|
|
525
|
+
body.append(value)
|
|
526
|
+
|
|
527
|
+
body.append(
|
|
528
|
+
"\n\nNothing has been persisted or launched yet. Confirming closes the dashboard, "
|
|
529
|
+
"restores the terminal, and then performs the switch.",
|
|
530
|
+
style="dim italic",
|
|
531
|
+
)
|
|
532
|
+
return body
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def render_recovery_preview(preview: TuiRecoveryPreview) -> Text:
|
|
536
|
+
"""Render a recovery confirmation summary from a non-mutating dry run."""
|
|
537
|
+
body = Text()
|
|
538
|
+
body.append("Task", style="bold cyan")
|
|
539
|
+
body.append(f" {preview.task_title} ({preview.task_id})\n")
|
|
540
|
+
body.append("Repository", style="bold cyan")
|
|
541
|
+
body.append(
|
|
542
|
+
f" {preview.repository_status} · "
|
|
543
|
+
f"{'dirty' if preview.dirty else 'clean'} · "
|
|
544
|
+
f"{len(preview.files_touched)} changed file(s)\n\n"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if preview.stale_count == 0:
|
|
548
|
+
body.append("No unfinalized sessions were found. Recovery would change nothing.")
|
|
549
|
+
return body
|
|
550
|
+
|
|
551
|
+
body.append(
|
|
552
|
+
f"{preview.stale_count} unfinalized session(s) would be reconciled:\n", style="bold"
|
|
553
|
+
)
|
|
554
|
+
for session_id in preview.stale_session_ids:
|
|
555
|
+
body.append(f" • {session_id}\n")
|
|
556
|
+
|
|
557
|
+
body.append(
|
|
558
|
+
"\nEach is recorded as status=interrupted with "
|
|
559
|
+
"exit_reason=unexpected_termination and a reconciled_at timestamp. "
|
|
560
|
+
"CortexShift never fabricates an unobserved process end time, so ended_at stays "
|
|
561
|
+
"empty. A RECOVERY checkpoint is captured from live repository state.\n\n",
|
|
562
|
+
style="dim",
|
|
563
|
+
)
|
|
564
|
+
body.append(
|
|
565
|
+
"Recovery requires the exclusive workspace lease. If another agent currently "
|
|
566
|
+
"owns the workspace, it will be refused safely and nothing will change.",
|
|
567
|
+
style="dim italic",
|
|
568
|
+
)
|
|
569
|
+
return body
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def render_handoff_preview(preview: TuiHandoffPreview) -> Text:
|
|
573
|
+
"""Render a bounded canonical handoff preview."""
|
|
574
|
+
body = Text()
|
|
575
|
+
body.append(f"Target {preview.target_provider_name}\n", style="bold cyan")
|
|
576
|
+
body.append(f"Delivery {preview.delivery_strategy}\n")
|
|
577
|
+
body.append(
|
|
578
|
+
"Bootstrap "
|
|
579
|
+
+ (
|
|
580
|
+
"one read-only planning turn on switch\n"
|
|
581
|
+
if preview.bootstrap_model_turn_required
|
|
582
|
+
else "none\n"
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
body.append(
|
|
586
|
+
f"Context {preview.context_characters} / {preview.context_max_characters} characters"
|
|
587
|
+
+ (" (truncated)\n" if preview.context_truncated else "\n")
|
|
588
|
+
)
|
|
589
|
+
body.append(
|
|
590
|
+
"\nPreview only — nothing was persisted and no model quota was used.\n\n",
|
|
591
|
+
style="dim italic",
|
|
592
|
+
)
|
|
593
|
+
body.append(preview.rendered_context)
|
|
594
|
+
return body
|