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,169 @@
|
|
|
1
|
+
"""The Providers section: passive discovery status and MCP integration.
|
|
2
|
+
|
|
3
|
+
Discovery is passive: no model prompts, no headless runs, no credential files, and no
|
|
4
|
+
account identifiers. Rendering MCP status never starts an MCP server.
|
|
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
|
+
TuiMcpStatus,
|
|
13
|
+
TuiProviderStatus,
|
|
14
|
+
WorkspaceActivity,
|
|
15
|
+
)
|
|
16
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
17
|
+
from cortexshift.tui.widgets import FieldList, Panel, marked
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProvidersSection(SectionView):
|
|
21
|
+
"""Renders provider CLI availability and CortexShift MCP integration modes."""
|
|
22
|
+
|
|
23
|
+
section = TuiSection.PROVIDERS
|
|
24
|
+
|
|
25
|
+
def __init__(self, id: str | None = None) -> None:
|
|
26
|
+
super().__init__(id=id)
|
|
27
|
+
self._providers: tuple[TuiProviderStatus, ...] = ()
|
|
28
|
+
self._mcp: TuiMcpStatus | None = None
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
"""Build the providers layout."""
|
|
32
|
+
yield Static(Text("Providers", style="bold"), classes="section-title")
|
|
33
|
+
yield Static("", id="providers-heading")
|
|
34
|
+
yield DataTable(id="providers-table", cursor_type="row")
|
|
35
|
+
|
|
36
|
+
with Panel("Provider detail"):
|
|
37
|
+
yield FieldList(id="providers-detail")
|
|
38
|
+
|
|
39
|
+
with Panel("CortexShift MCP"):
|
|
40
|
+
yield FieldList(id="providers-mcp")
|
|
41
|
+
|
|
42
|
+
yield Static(
|
|
43
|
+
Text(
|
|
44
|
+
"X opens the provider action palette (run · resume · switch). "
|
|
45
|
+
"G configures workspace MCP for Antigravity.\n"
|
|
46
|
+
"No account identifiers, credential paths, or tokens are read or shown.",
|
|
47
|
+
style="dim italic",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
"""Configure provider table columns."""
|
|
53
|
+
table = self.query_one("#providers-table", DataTable)
|
|
54
|
+
table.add_columns("Provider", "Installed", "Version", "Auth", "Native resume", "MCP")
|
|
55
|
+
|
|
56
|
+
def update_providers(
|
|
57
|
+
self,
|
|
58
|
+
providers: tuple[TuiProviderStatus, ...],
|
|
59
|
+
mcp: TuiMcpStatus | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Render discovery results and MCP integration status."""
|
|
62
|
+
self._providers = providers
|
|
63
|
+
self._mcp = mcp
|
|
64
|
+
|
|
65
|
+
heading = self.query_one("#providers-heading", Static)
|
|
66
|
+
table = self.query_one("#providers-table", DataTable)
|
|
67
|
+
|
|
68
|
+
if not providers:
|
|
69
|
+
heading.update(Text("Probing installed provider CLIs…", style="dim italic"))
|
|
70
|
+
else:
|
|
71
|
+
available = sum(1 for provider in providers if provider.available)
|
|
72
|
+
heading.update(
|
|
73
|
+
Text(f"{available} of {len(providers)} provider CLIs available on PATH.")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
table.clear()
|
|
77
|
+
for provider in providers:
|
|
78
|
+
state = "available" if provider.installed else "unavailable"
|
|
79
|
+
if provider.supports_exact_resume:
|
|
80
|
+
resume = Text("✓ exact native resume", style="green")
|
|
81
|
+
elif provider.supports_native_resume:
|
|
82
|
+
resume = Text("· partial", style="dim")
|
|
83
|
+
else:
|
|
84
|
+
resume = Text("— not supported", style="dim")
|
|
85
|
+
table.add_row(
|
|
86
|
+
Text(provider.display_name),
|
|
87
|
+
marked(state, state),
|
|
88
|
+
Text(provider.version or "—"),
|
|
89
|
+
Text(provider.authentication),
|
|
90
|
+
resume,
|
|
91
|
+
Text(provider.mcp_integration),
|
|
92
|
+
key=provider.provider_id,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._render_detail()
|
|
96
|
+
self._render_mcp()
|
|
97
|
+
|
|
98
|
+
def on_data_table_row_highlighted(self, _event: DataTable.RowHighlighted) -> None:
|
|
99
|
+
"""Render the highlighted provider's diagnostics."""
|
|
100
|
+
self._render_detail()
|
|
101
|
+
|
|
102
|
+
def _render_detail(self) -> None:
|
|
103
|
+
detail = self.query_one("#providers-detail", FieldList)
|
|
104
|
+
provider = self.selected_provider
|
|
105
|
+
if provider is None:
|
|
106
|
+
detail.set_fields([])
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
diagnostics = "; ".join(provider.diagnostics) if provider.diagnostics else "—"
|
|
110
|
+
detail.set_fields(
|
|
111
|
+
[
|
|
112
|
+
("Provider", provider.display_name),
|
|
113
|
+
("Provider ID", provider.provider_id),
|
|
114
|
+
("Executable", provider.executable),
|
|
115
|
+
("Installed", "yes" if provider.installed else "no"),
|
|
116
|
+
("Version", provider.version or "—"),
|
|
117
|
+
("Authentication", provider.authentication),
|
|
118
|
+
("Native resume", "yes" if provider.supports_native_resume else "no"),
|
|
119
|
+
("Exact native resume", "yes" if provider.supports_exact_resume else "no"),
|
|
120
|
+
("MCP integration", provider.mcp_integration),
|
|
121
|
+
("Diagnostics", diagnostics),
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _render_mcp(self) -> None:
|
|
126
|
+
widget = self.query_one("#providers-mcp", FieldList)
|
|
127
|
+
mcp = self._mcp
|
|
128
|
+
if mcp is None:
|
|
129
|
+
widget.set_fields([("MCP", Text("Loading…", style="dim italic"))])
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
antigravity = (
|
|
133
|
+
Text("✓ configured", style="green")
|
|
134
|
+
if mcp.antigravity_configured
|
|
135
|
+
else Text("! not configured — press G to configure", style="yellow")
|
|
136
|
+
)
|
|
137
|
+
widget.set_fields(
|
|
138
|
+
[
|
|
139
|
+
("MCP server", "cortexshift (not running from this dashboard)"),
|
|
140
|
+
("SDK", f"{'available' if mcp.sdk_available else 'unavailable'} {mcp.sdk_version}"),
|
|
141
|
+
("Transport", mcp.transport),
|
|
142
|
+
("Claude Code", mcp.claude_integration),
|
|
143
|
+
("OpenAI Codex", mcp.codex_integration),
|
|
144
|
+
("Antigravity", antigravity),
|
|
145
|
+
("Config path", mcp.antigravity_config_path or "—"),
|
|
146
|
+
("Read tools", f"{len(mcp.read_tools)}: {', '.join(mcp.read_tools)}"),
|
|
147
|
+
("Write tools", f"{len(mcp.write_tools)}: {', '.join(mcp.write_tools)}"),
|
|
148
|
+
("Resources", ", ".join(mcp.resources)),
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def selected_provider(self) -> TuiProviderStatus | None:
|
|
154
|
+
"""The provider row under the cursor, or None."""
|
|
155
|
+
table = self.query_one("#providers-table", DataTable)
|
|
156
|
+
if table.row_count == 0:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
160
|
+
except Exception:
|
|
161
|
+
return None
|
|
162
|
+
return next((p for p in self._providers if p.provider_id == row_key.value), None)
|
|
163
|
+
|
|
164
|
+
def update_activity(self, activity: WorkspaceActivity) -> None:
|
|
165
|
+
"""Workspace lease state is surfaced by the app's status line."""
|
|
166
|
+
|
|
167
|
+
def on_section_shown(self) -> None:
|
|
168
|
+
"""Focus the table so provider selection is keyboard-driven."""
|
|
169
|
+
self.query_one("#providers-table", DataTable).focus()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The Repository section: live, strictly read-only Git working tree state.
|
|
2
|
+
|
|
3
|
+
This screen inspects; it never mutates. There is no staging, committing, checkout,
|
|
4
|
+
reset, or stash control anywhere in CortexShift's dashboard, and full diffs are never
|
|
5
|
+
rendered — only counts, file paths, and shortstat summaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
from cortexshift.tui.models import (
|
|
14
|
+
DataAuthority,
|
|
15
|
+
TuiRepositoryModel,
|
|
16
|
+
format_timestamp,
|
|
17
|
+
)
|
|
18
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
19
|
+
from cortexshift.tui.widgets import BulletList, FieldList, Panel, marked
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RepositorySection(SectionView):
|
|
23
|
+
"""Renders the most recent live repository inspection."""
|
|
24
|
+
|
|
25
|
+
section = TuiSection.REPOSITORY
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
"""Build the repository layout."""
|
|
29
|
+
yield Static(Text("Repository", style="bold"), classes="section-title")
|
|
30
|
+
yield Static("", id="repo-heading")
|
|
31
|
+
|
|
32
|
+
# Git facts span the full width so absolute paths and commit SHAs stay readable.
|
|
33
|
+
with Panel("Git", authority=DataAuthority.LIVE):
|
|
34
|
+
yield FieldList(id="repo-git")
|
|
35
|
+
|
|
36
|
+
with Horizontal(classes="columns"):
|
|
37
|
+
with Vertical(classes="column"):
|
|
38
|
+
with Panel("Diff summary", authority=DataAuthority.LIVE):
|
|
39
|
+
yield FieldList(id="repo-diff")
|
|
40
|
+
with Panel("Staged"):
|
|
41
|
+
yield BulletList(id="repo-staged")
|
|
42
|
+
with Panel("Modified"):
|
|
43
|
+
yield BulletList(id="repo-modified")
|
|
44
|
+
with Vertical(classes="column"):
|
|
45
|
+
with Panel("Untracked"):
|
|
46
|
+
yield BulletList(id="repo-untracked")
|
|
47
|
+
with Panel("Conflicted"):
|
|
48
|
+
yield BulletList(id="repo-conflicted")
|
|
49
|
+
|
|
50
|
+
yield Static(
|
|
51
|
+
Text(
|
|
52
|
+
"Read-only view. CortexShift never stages, commits, checks out, resets, "
|
|
53
|
+
"or stashes; full diffs are not displayed.",
|
|
54
|
+
style="dim italic",
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def update_repository(self, repository: TuiRepositoryModel | None) -> None:
|
|
59
|
+
"""Render an inspection result, or the in-flight state while one runs."""
|
|
60
|
+
heading = self.query_one("#repo-heading", Static)
|
|
61
|
+
|
|
62
|
+
if repository is None:
|
|
63
|
+
heading.update(Text("Inspecting repository…", style="dim italic"))
|
|
64
|
+
self.query_one("#repo-git", FieldList).set_fields([])
|
|
65
|
+
self.query_one("#repo-diff", FieldList).set_fields([])
|
|
66
|
+
for widget_id in (
|
|
67
|
+
"#repo-staged",
|
|
68
|
+
"#repo-modified",
|
|
69
|
+
"#repo-untracked",
|
|
70
|
+
"#repo-conflicted",
|
|
71
|
+
):
|
|
72
|
+
self.query_one(widget_id, BulletList).set_items([], empty="—")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if not repository.ready:
|
|
76
|
+
heading.update(marked("warn", f"Repository unavailable: {repository.status.value}"))
|
|
77
|
+
self.query_one("#repo-git", FieldList).set_fields(
|
|
78
|
+
[
|
|
79
|
+
("Git available", "yes" if repository.git_available else "no"),
|
|
80
|
+
("Project root", str(repository.project_root)),
|
|
81
|
+
("Diagnostic", repository.diagnostic or "—"),
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
self.query_one("#repo-diff", FieldList).set_fields([])
|
|
85
|
+
for widget_id in (
|
|
86
|
+
"#repo-staged",
|
|
87
|
+
"#repo-modified",
|
|
88
|
+
"#repo-untracked",
|
|
89
|
+
"#repo-conflicted",
|
|
90
|
+
):
|
|
91
|
+
self.query_one(widget_id, BulletList).set_items([], empty="—")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
branch = repository.branch or "(detached HEAD)"
|
|
95
|
+
state = "dirty" if repository.dirty else "clean"
|
|
96
|
+
summary = Text()
|
|
97
|
+
summary.append(branch, style="bold")
|
|
98
|
+
summary.append(" · ")
|
|
99
|
+
summary.append_text(marked(state, state))
|
|
100
|
+
summary.append(f" · {repository.changed_file_count} changed file(s)")
|
|
101
|
+
heading.update(summary)
|
|
102
|
+
|
|
103
|
+
git_root_relationship = "same as project root"
|
|
104
|
+
if repository.git_root and repository.git_root != str(repository.project_root):
|
|
105
|
+
git_root_relationship = repository.git_root
|
|
106
|
+
|
|
107
|
+
self.query_one("#repo-git", FieldList).set_fields(
|
|
108
|
+
[
|
|
109
|
+
("Git available", "yes" if repository.git_available else "no"),
|
|
110
|
+
("Git version", repository.git_version or "—"),
|
|
111
|
+
("Project root", str(repository.project_root)),
|
|
112
|
+
("Git root", git_root_relationship),
|
|
113
|
+
("Branch", branch),
|
|
114
|
+
("HEAD", repository.head_sha or "— (no commit yet)"),
|
|
115
|
+
("Detached HEAD", "yes" if repository.detached_head else "no"),
|
|
116
|
+
("Dirty", "yes" if repository.dirty else "no"),
|
|
117
|
+
("Observed at", format_timestamp(repository.observed_at)),
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.query_one("#repo-diff", FieldList).set_fields(
|
|
122
|
+
[
|
|
123
|
+
("Staged", str(len(repository.staged_files))),
|
|
124
|
+
("Modified", str(len(repository.modified_files))),
|
|
125
|
+
("Untracked", str(len(repository.untracked_files))),
|
|
126
|
+
("Conflicted", str(len(repository.conflicted_files))),
|
|
127
|
+
("Working tree", repository.working_tree_diff_summary or "—"),
|
|
128
|
+
("Staged diff", repository.staged_diff_summary or "—"),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self.query_one("#repo-staged", BulletList).set_items(
|
|
133
|
+
list(repository.staged_files), limit=10, empty="No staged changes."
|
|
134
|
+
)
|
|
135
|
+
self.query_one("#repo-modified", BulletList).set_items(
|
|
136
|
+
list(repository.modified_files), limit=10, empty="No modified files."
|
|
137
|
+
)
|
|
138
|
+
self.query_one("#repo-untracked", BulletList).set_items(
|
|
139
|
+
list(repository.untracked_files), limit=10, empty="No untracked files."
|
|
140
|
+
)
|
|
141
|
+
self.query_one("#repo-conflicted", BulletList).set_items(
|
|
142
|
+
list(repository.conflicted_files), limit=10, empty="No conflicts."
|
|
143
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The Sessions section: CortexShift orchestration history.
|
|
2
|
+
|
|
3
|
+
Only orchestration metadata is shown. Provider transcripts, prompts, reasoning, and
|
|
4
|
+
terminal history are never read, stored, or rendered by CortexShift.
|
|
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
|
+
TuiSessionRow,
|
|
14
|
+
TuiStateSnapshot,
|
|
15
|
+
format_relative,
|
|
16
|
+
format_timestamp,
|
|
17
|
+
)
|
|
18
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
19
|
+
from cortexshift.tui.widgets import (
|
|
20
|
+
EmptyState,
|
|
21
|
+
FieldList,
|
|
22
|
+
Panel,
|
|
23
|
+
current_row_key,
|
|
24
|
+
marked,
|
|
25
|
+
restore_cursor,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionsSection(SectionView):
|
|
30
|
+
"""Lists CortexShift sessions newest first with native resume metadata."""
|
|
31
|
+
|
|
32
|
+
section = TuiSection.SESSIONS
|
|
33
|
+
|
|
34
|
+
def __init__(self, id: str | None = None) -> None:
|
|
35
|
+
super().__init__(id=id)
|
|
36
|
+
self._rows: dict[str, TuiSessionRow] = {}
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
"""Build the sessions layout."""
|
|
40
|
+
yield Static(Text("Sessions", style="bold"), classes="section-title")
|
|
41
|
+
yield EmptyState(
|
|
42
|
+
"No sessions yet.",
|
|
43
|
+
"Start one with:\n cortexshift run PROVIDER\n\n"
|
|
44
|
+
"Or press X here to choose a provider action.",
|
|
45
|
+
id="sessions-empty",
|
|
46
|
+
)
|
|
47
|
+
yield DataTable(id="sessions-table", cursor_type="row")
|
|
48
|
+
with Panel("Session detail", id="sessions-detail-panel"):
|
|
49
|
+
yield FieldList(id="sessions-detail")
|
|
50
|
+
yield Static(
|
|
51
|
+
Text(
|
|
52
|
+
"CortexShift orchestration metadata only — no provider transcripts, "
|
|
53
|
+
"prompts, or reasoning are ever read or displayed.",
|
|
54
|
+
style="dim italic",
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def on_mount(self) -> None:
|
|
59
|
+
"""Configure session table columns."""
|
|
60
|
+
table = self.query_one("#sessions-table", DataTable)
|
|
61
|
+
table.add_columns(
|
|
62
|
+
"Session", "Provider", "Status", "Native resume", "Started", "Ended", "Task"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
66
|
+
"""Refresh the sessions table, preserving the operator's selection."""
|
|
67
|
+
table = self.query_one("#sessions-table", DataTable)
|
|
68
|
+
empty = self.query_one("#sessions-empty", EmptyState)
|
|
69
|
+
detail_panel = self.query_one("#sessions-detail-panel", Panel)
|
|
70
|
+
|
|
71
|
+
rows = snapshot.sessions
|
|
72
|
+
self._rows = {row.id: row for row in rows}
|
|
73
|
+
|
|
74
|
+
empty.display = not rows
|
|
75
|
+
table.display = bool(rows)
|
|
76
|
+
detail_panel.display = bool(rows)
|
|
77
|
+
|
|
78
|
+
selected = current_row_key(table)
|
|
79
|
+
table.clear()
|
|
80
|
+
for row in rows:
|
|
81
|
+
if row.native_resumable:
|
|
82
|
+
resume = Text("✓ exact", style="green")
|
|
83
|
+
elif row.native_session_id:
|
|
84
|
+
resume = Text("· recorded", style="dim")
|
|
85
|
+
else:
|
|
86
|
+
resume = Text("— none", style="dim")
|
|
87
|
+
|
|
88
|
+
ended = format_relative(row.ended_at)
|
|
89
|
+
if row.ended_at is None and row.reconciled_at is not None:
|
|
90
|
+
ended = f"reconciled {format_relative(row.reconciled_at)}"
|
|
91
|
+
|
|
92
|
+
table.add_row(
|
|
93
|
+
Text(row.short_id),
|
|
94
|
+
Text(row.provider_id),
|
|
95
|
+
marked(row.status, row.status),
|
|
96
|
+
resume,
|
|
97
|
+
Text(format_relative(row.started_at)),
|
|
98
|
+
Text(ended),
|
|
99
|
+
Text(row.task_id[:12]),
|
|
100
|
+
key=row.id,
|
|
101
|
+
)
|
|
102
|
+
restore_cursor(table, selected)
|
|
103
|
+
self._render_detail()
|
|
104
|
+
|
|
105
|
+
def on_data_table_row_highlighted(self, _event: DataTable.RowHighlighted) -> None:
|
|
106
|
+
"""Show full canonical detail for the highlighted session."""
|
|
107
|
+
self._render_detail()
|
|
108
|
+
|
|
109
|
+
def _render_detail(self) -> None:
|
|
110
|
+
table = self.query_one("#sessions-table", DataTable)
|
|
111
|
+
detail = self.query_one("#sessions-detail", FieldList)
|
|
112
|
+
key = current_row_key(table)
|
|
113
|
+
row = self._rows.get(key) if key else None
|
|
114
|
+
if row is None:
|
|
115
|
+
detail.set_fields([])
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
detail.set_fields(
|
|
119
|
+
[
|
|
120
|
+
("Session ID", row.id),
|
|
121
|
+
("Task ID", row.task_id),
|
|
122
|
+
("Provider", row.provider_id),
|
|
123
|
+
("Native session ID", row.native_session_id or "— (not recorded)"),
|
|
124
|
+
("Native resumable", "yes" if row.native_resumable else "no"),
|
|
125
|
+
("Resumed from", row.resumed_from_session_id or "—"),
|
|
126
|
+
("Status", row.status),
|
|
127
|
+
("Exit reason", row.exit_reason or "—"),
|
|
128
|
+
("Exit code", "—" if row.exit_code is None else str(row.exit_code)),
|
|
129
|
+
("Started at", format_timestamp(row.started_at)),
|
|
130
|
+
("Ended at", format_timestamp(row.ended_at)),
|
|
131
|
+
("Reconciled at", format_timestamp(row.reconciled_at)),
|
|
132
|
+
("Authority", AUTHORITY_LABELS[row.authority]),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def selected_session(self) -> TuiSessionRow | None:
|
|
138
|
+
"""The session row under the cursor, or None."""
|
|
139
|
+
key = current_row_key(self.query_one("#sessions-table", DataTable))
|
|
140
|
+
return self._rows.get(key) if key else None
|
|
141
|
+
|
|
142
|
+
def on_section_shown(self) -> None:
|
|
143
|
+
"""Focus the table so the keyboard drives selection immediately."""
|
|
144
|
+
table = self.query_one("#sessions-table", DataTable)
|
|
145
|
+
if table.display:
|
|
146
|
+
table.focus()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The Task section: complete canonical Task state, progress controls, and activation."""
|
|
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 DataTable, Static
|
|
7
|
+
|
|
8
|
+
from cortexshift.tui.models import (
|
|
9
|
+
TuiStateSnapshot,
|
|
10
|
+
TuiTaskRow,
|
|
11
|
+
format_timestamp,
|
|
12
|
+
truncate,
|
|
13
|
+
)
|
|
14
|
+
from cortexshift.tui.screens import SectionView, TuiSection
|
|
15
|
+
from cortexshift.tui.widgets import (
|
|
16
|
+
BulletList,
|
|
17
|
+
FieldList,
|
|
18
|
+
Panel,
|
|
19
|
+
current_row_key,
|
|
20
|
+
marked,
|
|
21
|
+
render_progress_bar,
|
|
22
|
+
restore_cursor,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskSection(SectionView):
|
|
27
|
+
"""Renders the active Task in full and lists every Task in the project."""
|
|
28
|
+
|
|
29
|
+
section = TuiSection.TASK
|
|
30
|
+
|
|
31
|
+
def __init__(self, id: str | None = None) -> None:
|
|
32
|
+
super().__init__(id=id)
|
|
33
|
+
self._rows: dict[str, TuiTaskRow] = {}
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
"""Build the task layout."""
|
|
37
|
+
yield Static(Text("Task", style="bold"), classes="section-title")
|
|
38
|
+
yield Static("", id="task-heading")
|
|
39
|
+
yield Static("", id="task-progress")
|
|
40
|
+
|
|
41
|
+
# Identity spans the full width so canonical IDs and timestamps stay on one line
|
|
42
|
+
# and remain copy-friendly.
|
|
43
|
+
with Panel("Identity"):
|
|
44
|
+
yield FieldList(id="task-identity")
|
|
45
|
+
|
|
46
|
+
with Horizontal(classes="columns"):
|
|
47
|
+
with Vertical(classes="column"):
|
|
48
|
+
with Panel("Objective"):
|
|
49
|
+
yield Static("", id="task-objective")
|
|
50
|
+
with Panel("Current work"):
|
|
51
|
+
yield Static("", id="task-current-work")
|
|
52
|
+
with Panel("Requirements"):
|
|
53
|
+
yield BulletList(id="task-requirements")
|
|
54
|
+
with Panel("Constraints"):
|
|
55
|
+
yield BulletList(id="task-constraints")
|
|
56
|
+
with Vertical(classes="column"):
|
|
57
|
+
with Panel("Completed"):
|
|
58
|
+
yield BulletList(id="task-completed")
|
|
59
|
+
with Panel("Remaining"):
|
|
60
|
+
yield BulletList(id="task-remaining")
|
|
61
|
+
with Panel("Known issues"):
|
|
62
|
+
yield BulletList(id="task-issues")
|
|
63
|
+
|
|
64
|
+
with Panel("All tasks"):
|
|
65
|
+
yield Static(
|
|
66
|
+
Text(
|
|
67
|
+
"Enter or A activates the selected task · W set current work · "
|
|
68
|
+
"M mark completed · N add remaining · I record issue",
|
|
69
|
+
style="dim",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
yield DataTable(id="task-table", cursor_type="row", zebra_stripes=False)
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
"""Configure the task table columns."""
|
|
76
|
+
table = self.query_one("#task-table", DataTable)
|
|
77
|
+
table.add_columns("Task", "Title", "Status", "Done", "Left", "Active")
|
|
78
|
+
|
|
79
|
+
def update_state(self, snapshot: TuiStateSnapshot) -> None:
|
|
80
|
+
"""Render the active task in full and refresh the task table."""
|
|
81
|
+
self._render_active(snapshot)
|
|
82
|
+
self._render_table(snapshot)
|
|
83
|
+
|
|
84
|
+
def _render_active(self, snapshot: TuiStateSnapshot) -> None:
|
|
85
|
+
heading = self.query_one("#task-heading", Static)
|
|
86
|
+
progress = self.query_one("#task-progress", Static)
|
|
87
|
+
task = snapshot.active_task
|
|
88
|
+
|
|
89
|
+
if task is None:
|
|
90
|
+
heading.update(Text("No active task.", style="bold"))
|
|
91
|
+
progress.update(
|
|
92
|
+
Text(
|
|
93
|
+
"Activate a task from the table below, or create one with "
|
|
94
|
+
"`cortexshift task start`.",
|
|
95
|
+
style="dim",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
self.query_one("#task-identity", FieldList).set_fields([])
|
|
99
|
+
self.query_one("#task-objective", Static).update(Text("—", style="dim"))
|
|
100
|
+
self.query_one("#task-current-work", Static).update(Text("—", style="dim"))
|
|
101
|
+
for widget_id in (
|
|
102
|
+
"#task-requirements",
|
|
103
|
+
"#task-constraints",
|
|
104
|
+
"#task-completed",
|
|
105
|
+
"#task-remaining",
|
|
106
|
+
"#task-issues",
|
|
107
|
+
):
|
|
108
|
+
self.query_one(widget_id, BulletList).set_items([])
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
title = Text()
|
|
112
|
+
title.append(task.title, style="bold")
|
|
113
|
+
title.append_text(Text(" "))
|
|
114
|
+
title.append_text(marked(task.status, task.status))
|
|
115
|
+
heading.update(title)
|
|
116
|
+
progress.update(render_progress_bar(task.progress, width=28))
|
|
117
|
+
|
|
118
|
+
self.query_one("#task-identity", FieldList).set_fields(
|
|
119
|
+
[
|
|
120
|
+
("Task ID", task.id),
|
|
121
|
+
("Status", task.status),
|
|
122
|
+
("Created", format_timestamp(task.created_at)),
|
|
123
|
+
("Updated", format_timestamp(task.updated_at)),
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
self.query_one("#task-objective", Static).update(Text(task.objective))
|
|
127
|
+
self.query_one("#task-current-work", Static).update(
|
|
128
|
+
Text(task.current_work) if task.current_work else Text("—", style="dim italic")
|
|
129
|
+
)
|
|
130
|
+
self.query_one("#task-requirements", BulletList).set_items(
|
|
131
|
+
list(task.requirements), empty="No requirements recorded."
|
|
132
|
+
)
|
|
133
|
+
self.query_one("#task-constraints", BulletList).set_items(
|
|
134
|
+
list(task.constraints), empty="No constraints recorded."
|
|
135
|
+
)
|
|
136
|
+
self.query_one("#task-completed", BulletList).set_items(
|
|
137
|
+
list(task.completed), empty="Nothing completed yet.", marker="✓"
|
|
138
|
+
)
|
|
139
|
+
self.query_one("#task-remaining", BulletList).set_items(
|
|
140
|
+
list(task.remaining), empty="No remaining items recorded.", marker="□"
|
|
141
|
+
)
|
|
142
|
+
self.query_one("#task-issues", BulletList).set_items(
|
|
143
|
+
list(task.known_issues), empty="No known issues recorded.", marker="!"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _render_table(self, snapshot: TuiStateSnapshot) -> None:
|
|
147
|
+
table = self.query_one("#task-table", DataTable)
|
|
148
|
+
selected = current_row_key(table)
|
|
149
|
+
|
|
150
|
+
self._rows = {row.id: row for row in snapshot.tasks}
|
|
151
|
+
table.clear()
|
|
152
|
+
for row in snapshot.tasks:
|
|
153
|
+
table.add_row(
|
|
154
|
+
Text(row.short_id),
|
|
155
|
+
Text(truncate(row.title, 44)),
|
|
156
|
+
marked(row.status, row.status),
|
|
157
|
+
Text(str(row.completed_count)),
|
|
158
|
+
Text(str(row.remaining_count)),
|
|
159
|
+
Text("active" if row.is_active else "", style="bold green"),
|
|
160
|
+
key=row.id,
|
|
161
|
+
)
|
|
162
|
+
restore_cursor(table, selected)
|
|
163
|
+
|
|
164
|
+
# -- selection ---------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def selected_task(self) -> TuiTaskRow | None:
|
|
168
|
+
"""The task row under the cursor, or None when the table is empty."""
|
|
169
|
+
key = current_row_key(self.query_one("#task-table", DataTable))
|
|
170
|
+
return self._rows.get(key) if key else None
|
|
171
|
+
|
|
172
|
+
def on_section_shown(self) -> None:
|
|
173
|
+
"""Focus the task table so activation is reachable from the keyboard."""
|
|
174
|
+
self.query_one("#task-table", DataTable).focus()
|