cortexshift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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