Skip to content

Commit 513bae8

Browse files
authored
Merge pull request #79 from joshribakoff/tui-integration
Merge tui-integration: footer fix, test isolation
2 parents 859301d + ea77631 commit 513bae8

File tree

16 files changed

+1106
-283
lines changed

16 files changed

+1106
-283
lines changed

tui/bearing-tui

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/Users/joshribakoff/.pyenv/versions/3.11.2/bin/python3.11
2+
import os
3+
import sys
4+
from pathlib import Path
5+
6+
root = Path(__file__).resolve().parent
7+
os.environ["BEARING_WORKSPACE"] = "/Users/joshribakoff/Projects"
8+
sys.path.insert(0, str(root))
9+
10+
from bearing_tui.app import main
11+
12+
if __name__ == "__main__":
13+
main()

tui/bearing_tui/app.py

Lines changed: 307 additions & 103 deletions
Large diffs are not rendered by default.

tui/bearing_tui/state.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ class HealthEntry:
5656
last_check: Optional[datetime]
5757

5858

59+
@dataclass
60+
class PREntry:
61+
"""Represents a GitHub PR from prs.jsonl."""
62+
repo: str
63+
number: int
64+
title: str
65+
state: str # OPEN, CLOSED, MERGED
66+
branch: str
67+
base_branch: str
68+
author: str
69+
updated: Optional[datetime]
70+
checks: Optional[str] # SUCCESS, FAILURE, PENDING, None
71+
draft: bool = False
72+
73+
5974
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
6075
"""Parse ISO format datetime, handling 'unknown' and None."""
6176
if not value or value == "unknown":
@@ -152,6 +167,91 @@ def get_workflow_for_branch(self, repo: str, branch: str) -> Optional[WorkflowEn
152167
return entry
153168
return None
154169

170+
def get_plans_for_project(self, project: str) -> list[dict]:
171+
"""Get plans for a specific project from plans directory."""
172+
from .widgets.plans import parse_plan_frontmatter
173+
plans_dir = self.workspace_dir / "plans" / project
174+
if not plans_dir.exists():
175+
return []
176+
177+
plans = []
178+
for plan_file in plans_dir.glob("*.md"):
179+
try:
180+
fm = parse_plan_frontmatter(plan_file)
181+
plans.append({
182+
"file_path": plan_file,
183+
"project": project,
184+
"title": fm.get("title", plan_file.stem),
185+
"issue": fm.get("issue"),
186+
"status": fm.get("status", "draft"),
187+
"pr": fm.get("pr"),
188+
})
189+
except Exception:
190+
continue
191+
192+
# Sort by status (active first), then title
193+
status_order = {"active": 0, "in_progress": 1, "draft": 2, "completed": 3}
194+
plans.sort(key=lambda p: (status_order.get(p["status"], 4), p["title"]))
195+
return plans
196+
197+
def get_plan_projects(self) -> list[str]:
198+
"""Get unique project names that have plans."""
199+
plans_dir = self.workspace_dir / "plans"
200+
if not plans_dir.exists():
201+
return []
202+
projects = []
203+
for project_dir in plans_dir.iterdir():
204+
if project_dir.is_dir() and list(project_dir.glob("*.md")):
205+
projects.append(project_dir.name)
206+
return sorted(projects)
207+
208+
def read_prs(self) -> list[PREntry]:
209+
"""Read prs.jsonl - cached PR data."""
210+
entries = _read_jsonl(self.workspace_dir / "prs.jsonl")
211+
return [
212+
PREntry(
213+
repo=e["repo"],
214+
number=e["number"],
215+
title=e.get("title", ""),
216+
state=e.get("state", "OPEN"),
217+
branch=e.get("branch", ""),
218+
base_branch=e.get("baseBranch", "main"),
219+
author=e.get("author", ""),
220+
updated=_parse_datetime(e.get("updated")),
221+
checks=e.get("checks"),
222+
draft=e.get("draft", False),
223+
)
224+
for e in entries
225+
]
226+
227+
def get_prs_for_project(self, repo: str) -> list[PREntry]:
228+
"""Get PRs for a specific repo, sorted: open first, then by updated."""
229+
prs = [p for p in self.read_prs() if p.repo == repo]
230+
# Sort: OPEN first, then by updated (newest first)
231+
state_order = {"OPEN": 0, "DRAFT": 1, "MERGED": 2, "CLOSED": 3}
232+
prs.sort(key=lambda p: (
233+
state_order.get(p.state, 4),
234+
-(p.updated.timestamp() if p.updated else 0)
235+
))
236+
return prs
237+
238+
def get_all_prs(self) -> list[PREntry]:
239+
"""Get all PRs, sorted: open first, then by updated."""
240+
prs = self.read_prs()
241+
state_order = {"OPEN": 0, "DRAFT": 1, "MERGED": 2, "CLOSED": 3}
242+
prs.sort(key=lambda p: (
243+
state_order.get(p.state, 4),
244+
-(p.updated.timestamp() if p.updated else 0)
245+
))
246+
return prs
247+
248+
def get_worktree_for_branch(self, repo: str, branch: str) -> Optional[LocalEntry]:
249+
"""Find worktree that matches a branch."""
250+
for entry in self.read_local():
251+
if entry.repo == repo and entry.branch == branch:
252+
return entry
253+
return None
254+
155255

156256
if __name__ == "__main__":
157257
# Test by reading actual files

tui/bearing_tui/styles/app.tcss

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ Screen {
6060
border: round $border-focus;
6161
}
6262

63-
/* Right panel - Worktrees */
64-
#worktrees-panel {
63+
/* Right panel - Main (Worktrees or Plans) */
64+
#main-panel {
6565
width: 1fr;
6666
background: $bg-panel;
6767
border: round $border;
6868
margin: 0 1 0 1;
6969
}
7070

71-
#worktrees-panel:focus-within {
71+
#main-panel:focus-within {
7272
border: round $border-focus;
7373
}
7474

@@ -83,15 +83,7 @@ Screen {
8383
padding: 0 1;
8484
}
8585

86-
/* Footer with keybindings */
87-
#footer-bar {
88-
width: 100%;
89-
height: 1;
90-
background: $bg-surface;
91-
color: $text-dim;
92-
padding: 0 1;
93-
border-top: solid $border;
94-
}
86+
/* Footer uses Textual's built-in Footer widget */
9587

9688
/* Project list styling */
9789
ProjectList {
@@ -176,6 +168,55 @@ WorktreeTable:focus > .datatable--odd-row {
176168
background: $bg-surface;
177169
}
178170

171+
/* Plans table styling */
172+
PlansTable {
173+
background: $bg-panel;
174+
scrollbar-background: $bg-surface;
175+
scrollbar-color: $border;
176+
scrollbar-color-hover: $text-dim;
177+
border: none;
178+
}
179+
180+
PlansTable > .datatable--header {
181+
background: $bg-surface;
182+
color: $accent-purple;
183+
text-style: bold;
184+
}
185+
186+
/* Cursor when table has focus - bright selection */
187+
PlansTable:focus > .datatable--cursor {
188+
background: #2d5a8a;
189+
color: $text-bright;
190+
text-style: bold;
191+
}
192+
193+
/* Cursor when table doesn't have focus - dimmer but visible */
194+
PlansTable > .datatable--cursor {
195+
background: $bg-selection;
196+
color: $text;
197+
}
198+
199+
PlansTable > .datatable--header-cursor {
200+
background: $bg-surface;
201+
color: $accent-purple;
202+
}
203+
204+
PlansTable > .datatable--even-row {
205+
background: $bg-panel;
206+
}
207+
208+
PlansTable > .datatable--odd-row {
209+
background: $bg-surface;
210+
}
211+
212+
PlansTable:focus > .datatable--even-row {
213+
background: $bg-panel;
214+
}
215+
216+
PlansTable:focus > .datatable--odd-row {
217+
background: $bg-surface;
218+
}
219+
179220
/* Details panel styling */
180221
DetailsPanel {
181222
height: auto;
@@ -284,3 +325,54 @@ PlansList:focus PlanListItem.-highlight {
284325
color: $text-bright;
285326
text-style: bold;
286327
}
328+
329+
/* PRs modal styling */
330+
PRsScreen {
331+
align: center middle;
332+
}
333+
334+
#prs-modal {
335+
width: 90;
336+
height: auto;
337+
max-height: 80%;
338+
background: $bg-panel;
339+
border: round $accent-green;
340+
padding: 1 2;
341+
}
342+
343+
#prs-header {
344+
height: 1;
345+
margin-bottom: 1;
346+
}
347+
348+
PRsTable {
349+
background: $bg-panel;
350+
height: auto;
351+
max-height: 20;
352+
border: none;
353+
}
354+
355+
PRsTable > .datatable--header {
356+
background: $bg-surface;
357+
color: $accent-green;
358+
text-style: bold;
359+
}
360+
361+
PRsTable:focus > .datatable--cursor {
362+
background: #2d5a8a;
363+
color: $text-bright;
364+
text-style: bold;
365+
}
366+
367+
PRsTable > .datatable--cursor {
368+
background: $bg-selection;
369+
color: $text;
370+
}
371+
372+
PRsTable > .datatable--even-row {
373+
background: $bg-panel;
374+
}
375+
376+
PRsTable > .datatable--odd-row {
377+
background: $bg-surface;
378+
}

tui/bearing_tui/widgets/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from .worktrees import WorktreeTable, WorktreeEntry, HealthEntry
44
from .details import DetailsPanel, LocalEntry, WorkflowEntry
55
from .details import HealthEntry as DetailsHealthEntry
6-
from .plans import PlansList, PlanEntry, load_plans
6+
from .plans import PlansList, load_plans
7+
from .plans import PlanEntry as LegacyPlanEntry
8+
from .plans_table import PlansTable, PlanEntry
9+
from .prs import PRsTable, PRDisplayEntry
710

811
__all__ = [
912
"ProjectList",
@@ -14,6 +17,10 @@
1417
"LocalEntry",
1518
"WorkflowEntry",
1619
"PlansList",
20+
"PlansTable",
1721
"PlanEntry",
22+
"LegacyPlanEntry",
1823
"load_plans",
24+
"PRsTable",
25+
"PRDisplayEntry",
1926
]
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Plans table widget for the plans view."""
2+
from dataclasses import dataclass
3+
from pathlib import Path
4+
from typing import NamedTuple
5+
6+
from textual.widgets import DataTable
7+
from textual.message import Message
8+
9+
10+
class PlanEntry(NamedTuple):
11+
"""Represents a plan entry."""
12+
file_path: Path
13+
project: str
14+
title: str
15+
issue: str | None
16+
status: str
17+
pr: str | None = None
18+
19+
20+
class PlansTable(DataTable):
21+
"""Table showing plans for selected project."""
22+
23+
class PlanSelected(Message):
24+
"""Emitted when a plan row is selected."""
25+
def __init__(self, plan: PlanEntry) -> None:
26+
self.plan = plan
27+
super().__init__()
28+
29+
def __init__(self, **kwargs) -> None:
30+
super().__init__(**kwargs)
31+
self.cursor_type = "row"
32+
self._plans: list[PlanEntry] = []
33+
34+
def _setup_columns(self) -> None:
35+
"""Add table columns."""
36+
self.add_columns("Title", "Status", "PR", "Issue")
37+
38+
def on_mount(self) -> None:
39+
"""Set up table when mounted."""
40+
self._setup_columns()
41+
42+
def set_plans(self, plans: list[PlanEntry]) -> None:
43+
"""Update table with plans."""
44+
self._plans = plans
45+
self.clear()
46+
47+
if not plans:
48+
self.add_row("No plans", "", "", "", key="empty")
49+
return
50+
51+
for plan in plans:
52+
status_display = self._format_status(plan.status)
53+
pr_display = plan.pr if plan.pr else "-"
54+
issue_display = f"#{plan.issue}" if plan.issue else "-"
55+
title = (plan.title[:35] + "...") if len(plan.title) > 35 else plan.title
56+
self.add_row(title, status_display, pr_display, issue_display, key=str(plan.file_path))
57+
58+
def _format_status(self, status: str) -> str:
59+
"""Format status with color indicator."""
60+
indicators = {
61+
"active": "[green]\u25cf[/] active",
62+
"in_progress": "[yellow]\u25cf[/] in_progress",
63+
"draft": "[dim]\u25cf[/] draft",
64+
"completed": "[blue]\u25cf[/] completed",
65+
}
66+
return indicators.get(status, f"[dim]\u25cb[/] {status}")
67+
68+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
69+
"""Handle row selection and emit PlanSelected message."""
70+
if event.row_key and event.row_key.value != "empty":
71+
# Find the plan by file path
72+
for plan in self._plans:
73+
if str(plan.file_path) == event.row_key.value:
74+
self.post_message(self.PlanSelected(plan))
75+
break
76+
77+
def clear_plans(self) -> None:
78+
"""Clear the table and show empty state."""
79+
self._plans = []
80+
self.clear()
81+
self.add_row("Select a project", "", "", "", key="empty")
82+
83+
def get_selected_plan(self) -> PlanEntry | None:
84+
"""Get the currently selected plan."""
85+
if self.row_count == 0:
86+
return None
87+
try:
88+
from textual.coordinate import Coordinate
89+
cell_key = self.coordinate_to_cell_key(Coordinate(self.cursor_row, 0))
90+
key = str(cell_key.row_key.value)
91+
if key == "empty":
92+
return None
93+
for plan in self._plans:
94+
if str(plan.file_path) == key:
95+
return plan
96+
except Exception:
97+
pass
98+
return None

0 commit comments

Comments
 (0)