@pennyfarthing/core 11.1.1 → 11.2.0
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.
- package/README.md +8 -8
- package/package.json +1 -1
- package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
- package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
- package/packages/core/dist/server/otlp-receiver.js +185 -24
- package/packages/core/dist/server/otlp-receiver.js.map +1 -1
- package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
- package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
- package/packages/core/dist/server/otlp-receiver.test.js +446 -0
- package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
- package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
- package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
- package/packages/core/dist/shared/portrait-resolver.js +27 -0
- package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
- package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
- package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
- package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
- package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
- package/pennyfarthing-dist/agents/dev.md +1 -1
- package/pennyfarthing-dist/agents/reviewer.md +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +1 -1
- package/pennyfarthing-dist/agents/sm.md +2 -2
- package/pennyfarthing-dist/agents/tea.md +1 -1
- package/pennyfarthing-dist/agents/testing-runner.md +2 -1
- package/pennyfarthing-dist/commands/pf-chore.md +2 -2
- package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
- package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
- package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
- package/pennyfarthing-dist/guides/bikerack.md +3 -3
- package/pennyfarthing-dist/guides/hooks.md +1 -1
- package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
- package/pennyfarthing-dist/guides/xml-tags.md +2 -2
- package/pennyfarthing-dist/scripts/README.md +1 -1
- package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
- package/pennyfarthing-dist/scripts/git/README.md +24 -14
- package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
- package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
- package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
- package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
- package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
- package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
- package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
- package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
- package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
- package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
- package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
- package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
- package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
- package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
- package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
- package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
- package/pennyfarthing_scripts/CLAUDE.md +26 -4
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/cli.py +3 -5
- package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
- package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
- package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
- package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
- package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
- package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
- package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
- package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
- package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
- package/pennyfarthing_scripts/bikerack/tui.py +336 -30
- package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
- package/pennyfarthing_scripts/cli.py +37 -65
- package/pennyfarthing_scripts/consultation/__init__.py +1 -0
- package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/cli.py +149 -0
- package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
- package/pennyfarthing_scripts/context.py +3 -3
- package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__init__.py +12 -1
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +3 -4
- package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
- package/pennyfarthing_scripts/git/repos.py +196 -0
- package/pennyfarthing_scripts/git/status_all.py +27 -11
- package/pennyfarthing_scripts/git/worktree.py +302 -0
- package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git_group/cli.py +143 -40
- package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
- package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
- package/pennyfarthing_scripts/hooks.py +3 -17
- package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
- package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/heatmap.py +655 -0
- package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/session_start_hook.py +1 -1
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/loader.py +15 -1
- package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
- package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
- package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
- package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
- package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
- package/pennyfarthing_scripts/validate/cli.py +17 -5
- package/pennyfarthing_scripts/workflow/__init__.py +40 -0
- package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/cli.py +1099 -0
- package/pennyfarthing_scripts/workflow/helpers.py +241 -0
- package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
- package/pennyfarthing_scripts/workflow/state.py +112 -0
- package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
- package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
- package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
- package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
- package/pennyfarthing-dist/skills/pf-workflow/scripts/workflow-status.sh +0 -167
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from rich.table import Table
|
|
12
11
|
from rich.text import Text
|
|
13
12
|
|
|
14
13
|
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
|
|
@@ -64,42 +63,47 @@ class ChangedPanel(BasePanel):
|
|
|
64
63
|
icon: str = PANEL_ICONS["changed"][0]
|
|
65
64
|
|
|
66
65
|
def render_panel(self, payload: dict[str, Any]) -> Any:
|
|
67
|
-
"""Render changed
|
|
66
|
+
"""Render changed files grouped by repository."""
|
|
68
67
|
repos = payload.get("repos", [])
|
|
69
68
|
if not isinstance(repos, list):
|
|
70
69
|
return Text("No changed files", style="dim italic")
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
# Group files by repo
|
|
72
|
+
repo_files: dict[str, list[dict[str, Any]]] = {}
|
|
73
73
|
for repo in repos:
|
|
74
74
|
if not isinstance(repo, dict):
|
|
75
75
|
continue
|
|
76
|
-
repo_name = repo.get("name", "")
|
|
76
|
+
repo_name = repo.get("name", "unknown")
|
|
77
77
|
dirty_files = repo.get("dirtyFiles", [])
|
|
78
|
-
if not isinstance(dirty_files, list):
|
|
78
|
+
if not isinstance(dirty_files, list) or not dirty_files:
|
|
79
79
|
continue
|
|
80
|
-
for f in dirty_files
|
|
81
|
-
if not isinstance(f, dict):
|
|
82
|
-
continue
|
|
83
|
-
files.append((repo_name, f))
|
|
80
|
+
repo_files[repo_name] = [f for f in dirty_files if isinstance(f, dict)]
|
|
84
81
|
|
|
85
|
-
if not
|
|
82
|
+
if not repo_files:
|
|
86
83
|
return Text("No changed files", style="dim italic")
|
|
87
84
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
85
|
+
from rich.console import Group as RichGroup
|
|
86
|
+
|
|
87
|
+
parts: list[Any] = []
|
|
88
|
+
for repo_name, files in repo_files.items():
|
|
89
|
+
count = len(files)
|
|
90
|
+
label = "file" if count == 1 else "files"
|
|
91
|
+
header = Text()
|
|
92
|
+
header.append(repo_name, style="bold cyan")
|
|
93
|
+
header.append(f" ({count} {label})", style="dim")
|
|
94
|
+
parts.append(header)
|
|
95
|
+
|
|
96
|
+
for f in files:
|
|
97
|
+
status_code = f.get("status", " ")
|
|
98
|
+
path = f.get("path", "")
|
|
99
|
+
icon, label_text, style = _parse_status(status_code)
|
|
100
|
+
line = Text()
|
|
101
|
+
line.append(" ")
|
|
102
|
+
line.append(icon, style=f"bold {style}")
|
|
103
|
+
line.append(f" {path}", style="cyan")
|
|
104
|
+
line.append(f" {label_text}", style=style)
|
|
105
|
+
parts.append(line)
|
|
106
|
+
|
|
107
|
+
parts.append(Text("")) # spacer between repos
|
|
108
|
+
|
|
109
|
+
return RichGroup(*parts)
|
|
@@ -7,13 +7,14 @@ token consumption stats (input, output, cache, cost).
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections import deque
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from rich.console import Group
|
|
13
14
|
from rich.table import Table
|
|
14
15
|
from rich.text import Text
|
|
15
16
|
|
|
16
|
-
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
|
|
17
|
+
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel, render_progress_bar
|
|
17
18
|
|
|
18
19
|
# Tier → Rich style mapping
|
|
19
20
|
_TIER_STYLES: dict[str, str] = {
|
|
@@ -68,6 +69,7 @@ class DebugPanel(BasePanel):
|
|
|
68
69
|
super().__init__(client=client, **kwargs)
|
|
69
70
|
self._context_data: dict[str, Any] | None = None
|
|
70
71
|
self._token_stats: dict[str, Any] | None = None
|
|
72
|
+
self._sparkline_history: deque[int] = deque(maxlen=20)
|
|
71
73
|
|
|
72
74
|
def on_mount(self) -> None:
|
|
73
75
|
"""Subscribe to both context and token-stats channels."""
|
|
@@ -83,6 +85,9 @@ class DebugPanel(BasePanel):
|
|
|
83
85
|
ctx = message.get("context")
|
|
84
86
|
if isinstance(ctx, dict):
|
|
85
87
|
self._context_data = ctx
|
|
88
|
+
pct = _safe_int(ctx.get("percent"))
|
|
89
|
+
if pct is not None:
|
|
90
|
+
self._sparkline_history.append(pct)
|
|
86
91
|
else:
|
|
87
92
|
self._context_data = {}
|
|
88
93
|
self._rerender()
|
|
@@ -110,6 +115,8 @@ class DebugPanel(BasePanel):
|
|
|
110
115
|
ctx = self._context_data
|
|
111
116
|
if ctx:
|
|
112
117
|
parts.append(_render_context(ctx))
|
|
118
|
+
if len(self._sparkline_history) >= 2:
|
|
119
|
+
parts.append(_render_sparkline(self._sparkline_history))
|
|
113
120
|
elif not self._token_stats:
|
|
114
121
|
return Text("No context data", style="dim italic")
|
|
115
122
|
|
|
@@ -157,6 +164,10 @@ def _render_context(ctx: dict[str, Any]) -> Any:
|
|
|
157
164
|
usage_text.append(f" ({percent}%)")
|
|
158
165
|
parts.append(usage_text)
|
|
159
166
|
|
|
167
|
+
# Context usage progress bar
|
|
168
|
+
if percent is not None:
|
|
169
|
+
parts.append(render_progress_bar(percent, warn_high=True))
|
|
170
|
+
|
|
160
171
|
# Breakdown: baseline / conversation / available
|
|
161
172
|
if baseline is not None:
|
|
162
173
|
breakdown = Table(show_header=False, show_edge=False, pad_edge=False, box=None)
|
|
@@ -176,6 +187,25 @@ def _render_context(ctx: dict[str, Any]) -> Any:
|
|
|
176
187
|
return Group(*parts)
|
|
177
188
|
|
|
178
189
|
|
|
190
|
+
_SPARKLINE_CHARS = "▁▂▃▄▅▆▇█"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _render_sparkline(history: deque[int]) -> Text:
|
|
194
|
+
"""Render a Unicode sparkline from context usage history."""
|
|
195
|
+
text = Text()
|
|
196
|
+
text.append("Context trend: ", style="dim")
|
|
197
|
+
for pct in history:
|
|
198
|
+
level = min(7, max(0, int(pct / 100 * 7.99)))
|
|
199
|
+
if pct < 50:
|
|
200
|
+
style = "green"
|
|
201
|
+
elif pct <= 80:
|
|
202
|
+
style = "yellow"
|
|
203
|
+
else:
|
|
204
|
+
style = "red"
|
|
205
|
+
text.append(_SPARKLINE_CHARS[level], style=style)
|
|
206
|
+
return text
|
|
207
|
+
|
|
208
|
+
|
|
179
209
|
def _render_token_stats(stats: dict[str, Any]) -> Any:
|
|
180
210
|
"""Render token stats section."""
|
|
181
211
|
table = Table(show_header=False, show_edge=False, pad_edge=False, box=None)
|
|
@@ -52,6 +52,8 @@ class DiffsPanel(BasePanel):
|
|
|
52
52
|
self._current_page: int = 0
|
|
53
53
|
self._max_page: int = 0
|
|
54
54
|
self._temp_files: list[str] = []
|
|
55
|
+
self._current_file_index: int = 0
|
|
56
|
+
self._total_files: int = 0
|
|
55
57
|
|
|
56
58
|
def next_page(self) -> None:
|
|
57
59
|
"""Advance to the next page of truncated diff content."""
|
|
@@ -63,11 +65,34 @@ class DiffsPanel(BasePanel):
|
|
|
63
65
|
if self._current_page > 0:
|
|
64
66
|
self._current_page -= 1
|
|
65
67
|
|
|
68
|
+
def next_file(self) -> None:
|
|
69
|
+
"""Advance to the next file."""
|
|
70
|
+
if self._current_file_index < self._total_files - 1:
|
|
71
|
+
self._current_file_index += 1
|
|
72
|
+
if self._last_payload:
|
|
73
|
+
rendered = self.render_panel(self._last_payload)
|
|
74
|
+
try:
|
|
75
|
+
self.update(rendered)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def prev_file(self) -> None:
|
|
80
|
+
"""Go back to the previous file."""
|
|
81
|
+
if self._current_file_index > 0:
|
|
82
|
+
self._current_file_index -= 1
|
|
83
|
+
if self._last_payload:
|
|
84
|
+
rendered = self.render_panel(self._last_payload)
|
|
85
|
+
try:
|
|
86
|
+
self.update(rendered)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
66
90
|
def handle_message(self, message: dict[str, Any] | None) -> None:
|
|
67
91
|
"""Handle incoming WebSocket message with pagination reset and temp management."""
|
|
68
92
|
if not self._mounted or message is None:
|
|
69
93
|
return
|
|
70
94
|
self._current_page = 0
|
|
95
|
+
self._current_file_index = 0
|
|
71
96
|
self._cleanup_temp_files()
|
|
72
97
|
self._store_large_diffs(message)
|
|
73
98
|
super().handle_message(message)
|
|
@@ -78,31 +103,63 @@ class DiffsPanel(BasePanel):
|
|
|
78
103
|
super().on_unmount()
|
|
79
104
|
|
|
80
105
|
def render_panel(self, payload: dict[str, Any]) -> Any:
|
|
81
|
-
"""Render diff data
|
|
106
|
+
"""Render diff data showing one file at a time with file selector header."""
|
|
82
107
|
diffs = payload.get("diffs", [])
|
|
83
108
|
if not diffs:
|
|
84
109
|
return Text("No diffs yet", style="dim italic")
|
|
85
110
|
|
|
111
|
+
self._total_files = len(diffs)
|
|
112
|
+
|
|
113
|
+
# Clamp file index
|
|
114
|
+
if self._current_file_index >= len(diffs):
|
|
115
|
+
self._current_file_index = len(diffs) - 1
|
|
116
|
+
|
|
86
117
|
parts: list[Any] = []
|
|
87
|
-
max_total = 0
|
|
88
|
-
for diff_entry in diffs:
|
|
89
|
-
# Skip syntax highlighting for very large diffs (>2000 lines) for performance
|
|
90
|
-
raw_diff = diff_entry.get("diff", "")
|
|
91
|
-
skip_highlight = raw_diff.count("\n") > HIGHLIGHT_THRESHOLD
|
|
92
118
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
# File selector header
|
|
120
|
+
selector = Text()
|
|
121
|
+
selector.append("Files: ", style="dim")
|
|
122
|
+
for i, d in enumerate(diffs):
|
|
123
|
+
path = d.get("path", "unknown")
|
|
124
|
+
additions = d.get("additions")
|
|
125
|
+
deletions = d.get("deletions")
|
|
126
|
+
stats = ""
|
|
127
|
+
if additions is not None and deletions is not None:
|
|
128
|
+
stats = f" +{additions} -{deletions}"
|
|
129
|
+
|
|
130
|
+
if i == self._current_file_index:
|
|
131
|
+
selector.append(f"[{i+1}/{len(diffs)}] ", style="bold")
|
|
132
|
+
selector.append(path, style="bold cyan")
|
|
133
|
+
if stats:
|
|
134
|
+
selector.append(stats, style="bold dim")
|
|
135
|
+
else:
|
|
136
|
+
selector.append(path, style="dim")
|
|
137
|
+
if stats:
|
|
138
|
+
selector.append(stats, style="dim")
|
|
139
|
+
|
|
140
|
+
if i < len(diffs) - 1:
|
|
141
|
+
selector.append(" | ", style="dim")
|
|
142
|
+
|
|
143
|
+
parts.append(selector)
|
|
144
|
+
parts.append(Text("n:next p:prev", style="dim"))
|
|
145
|
+
parts.append(Text(""))
|
|
146
|
+
|
|
147
|
+
# Render only current file's diff
|
|
148
|
+
diff_entry = diffs[self._current_file_index]
|
|
149
|
+
raw_diff = diff_entry.get("diff", "")
|
|
150
|
+
skip_highlight = raw_diff.count("\n") > HIGHLIGHT_THRESHOLD
|
|
151
|
+
|
|
152
|
+
file_parts, total_lines = _render_file_diff(
|
|
153
|
+
diff_entry,
|
|
154
|
+
page=self._current_page,
|
|
155
|
+
page_size=DEFAULT_LINE_LIMIT,
|
|
156
|
+
skip_highlight=skip_highlight,
|
|
157
|
+
)
|
|
158
|
+
parts.extend(file_parts)
|
|
102
159
|
|
|
103
160
|
# Track max page for pagination bounds
|
|
104
|
-
if
|
|
105
|
-
self._max_page = -(-
|
|
161
|
+
if total_lines > DEFAULT_LINE_LIMIT:
|
|
162
|
+
self._max_page = -(-total_lines // DEFAULT_LINE_LIMIT) - 1
|
|
106
163
|
else:
|
|
107
164
|
self._max_page = 0
|
|
108
165
|
|
|
@@ -8,11 +8,61 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from rich.
|
|
11
|
+
from rich.text import Text
|
|
12
12
|
|
|
13
13
|
from pennyfarthing_scripts.bikerack.base_panel import PANEL_ICONS, BasePanel
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _file_breakdown(dirty_files: list[dict]) -> Text:
|
|
17
|
+
"""Break down dirty files into +staged ~modified ?untracked counts."""
|
|
18
|
+
staged = 0
|
|
19
|
+
modified = 0
|
|
20
|
+
untracked = 0
|
|
21
|
+
for f in dirty_files:
|
|
22
|
+
if not isinstance(f, dict):
|
|
23
|
+
continue
|
|
24
|
+
status = f.get("status", " ")
|
|
25
|
+
idx = status[0] if len(status) >= 1 else " "
|
|
26
|
+
wt = status[1] if len(status) >= 2 else " "
|
|
27
|
+
if idx == "?" and wt == "?":
|
|
28
|
+
untracked += 1
|
|
29
|
+
elif idx not in (" ", "?"):
|
|
30
|
+
staged += 1
|
|
31
|
+
elif wt not in (" ", "?"):
|
|
32
|
+
modified += 1
|
|
33
|
+
|
|
34
|
+
parts = Text()
|
|
35
|
+
parts.append(f"+{staged}", style="green")
|
|
36
|
+
parts.append(" ")
|
|
37
|
+
parts.append(f"~{modified}", style="yellow")
|
|
38
|
+
parts.append(" ")
|
|
39
|
+
parts.append(f"?{untracked}", style="dim")
|
|
40
|
+
return parts
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_FILE_STATUS_MAP: dict[str, tuple[str, str, str]] = {
|
|
44
|
+
"M": ("~", "Modified", "yellow"),
|
|
45
|
+
"A": ("+", "Added", "green"),
|
|
46
|
+
"D": ("-", "Deleted", "red"),
|
|
47
|
+
"?": ("?", "Untracked", "dim"),
|
|
48
|
+
"R": ("→", "Renamed", "cyan"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_file_status(status: str) -> tuple[str, str, str]:
|
|
53
|
+
"""Parse git status code into (icon, label, style)."""
|
|
54
|
+
if len(status) < 2:
|
|
55
|
+
return _FILE_STATUS_MAP.get(status[:1], ("·", "Changed", "yellow"))
|
|
56
|
+
idx, wt = status[0], status[1]
|
|
57
|
+
if idx == "?" and wt == "?":
|
|
58
|
+
return _FILE_STATUS_MAP["?"]
|
|
59
|
+
if idx not in (" ", "?"):
|
|
60
|
+
return _FILE_STATUS_MAP.get(idx, ("·", "Changed", "yellow"))
|
|
61
|
+
if wt not in (" ", "?"):
|
|
62
|
+
return _FILE_STATUS_MAP.get(wt, ("·", "Changed", "yellow"))
|
|
63
|
+
return ("·", "Changed", "yellow")
|
|
64
|
+
|
|
65
|
+
|
|
16
66
|
class GitPanel(BasePanel):
|
|
17
67
|
"""Multi-repo git status panel.
|
|
18
68
|
|
|
@@ -26,44 +76,64 @@ class GitPanel(BasePanel):
|
|
|
26
76
|
icon: str = PANEL_ICONS["git"][0]
|
|
27
77
|
|
|
28
78
|
def render_panel(self, payload: dict[str, Any]) -> Any:
|
|
29
|
-
"""Render git status as Rich
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for repo in
|
|
79
|
+
"""Render git status as Rich Tree with expandable file lists."""
|
|
80
|
+
from rich.console import Group as RichGroup
|
|
81
|
+
|
|
82
|
+
repos = payload.get("repos", [])
|
|
83
|
+
if not repos:
|
|
84
|
+
return Text("No repository data", style="dim italic")
|
|
85
|
+
|
|
86
|
+
parts: list[Any] = []
|
|
87
|
+
for repo in repos:
|
|
38
88
|
branch = repo.get("branch", "")
|
|
39
89
|
ahead = repo.get("ahead", 0)
|
|
40
90
|
behind = repo.get("behind", 0)
|
|
41
91
|
clean = repo.get("clean", True)
|
|
42
92
|
dirty_files = repo.get("dirtyFiles", [])
|
|
93
|
+
name = repo.get("name", "")
|
|
43
94
|
|
|
44
|
-
#
|
|
45
|
-
|
|
95
|
+
# Build repo header line
|
|
96
|
+
header = Text()
|
|
97
|
+
arrow = "▼" if not clean and dirty_files else "▶"
|
|
98
|
+
header.append(f"{arrow} ", style="bold")
|
|
99
|
+
header.append(name, style="bold cyan")
|
|
100
|
+
header.append(f" \ue0a0 {branch}", style="dim")
|
|
46
101
|
|
|
47
|
-
# Commits
|
|
48
|
-
|
|
102
|
+
# Commits
|
|
103
|
+
commit_parts = []
|
|
49
104
|
if ahead:
|
|
50
|
-
|
|
105
|
+
commit_parts.append(f"↑{ahead}")
|
|
51
106
|
if behind:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
107
|
+
commit_parts.append(f"↓{behind}")
|
|
108
|
+
header.append(f" {' '.join(commit_parts) if commit_parts else '—'}", style="dim")
|
|
109
|
+
|
|
110
|
+
# File breakdown
|
|
111
|
+
header.append(" ")
|
|
112
|
+
header.append_text(_file_breakdown(dirty_files))
|
|
113
|
+
|
|
114
|
+
# Status
|
|
115
|
+
header.append(" ")
|
|
116
|
+
if clean:
|
|
117
|
+
header.append("✓ clean", style="green")
|
|
118
|
+
else:
|
|
119
|
+
header.append("✗ dirty", style="red")
|
|
120
|
+
|
|
121
|
+
parts.append(header)
|
|
122
|
+
|
|
123
|
+
# Expanded file list for dirty repos
|
|
124
|
+
if not clean and dirty_files:
|
|
125
|
+
for f in dirty_files:
|
|
126
|
+
if not isinstance(f, dict):
|
|
127
|
+
continue
|
|
128
|
+
status_code = f.get("status", " ")
|
|
129
|
+
path = f.get("path", "")
|
|
130
|
+
icon, label, style = _parse_file_status(status_code)
|
|
131
|
+
file_line = Text()
|
|
132
|
+
file_line.append(" ")
|
|
133
|
+
file_line.append(icon, style=f"bold {style}")
|
|
134
|
+
file_line.append(f" {path}", style=style)
|
|
135
|
+
parts.append(file_line)
|
|
136
|
+
|
|
137
|
+
parts.append(Text("")) # spacer
|
|
138
|
+
|
|
139
|
+
return RichGroup(*parts)
|
|
@@ -24,8 +24,8 @@ def is_process_alive(pid: int) -> bool:
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def cleanup_files(project_dir: Path) -> None:
|
|
27
|
-
"""Clean up .
|
|
28
|
-
for name in (".
|
|
27
|
+
"""Clean up .wheelhub-port, .wheelhub-pid, and .wheelhub-gui-pid files."""
|
|
28
|
+
for name in (".wheelhub-port", ".wheelhub-pid", ".wheelhub-gui-pid"):
|
|
29
29
|
try:
|
|
30
30
|
(project_dir / name).unlink()
|
|
31
31
|
except FileNotFoundError:
|
|
@@ -33,24 +33,24 @@ def cleanup_files(project_dir: Path) -> None:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def read_port_file(project_dir: Path) -> int | None:
|
|
36
|
-
"""Read port from .
|
|
36
|
+
"""Read port from .wheelhub-port file. Returns None if not found."""
|
|
37
37
|
try:
|
|
38
|
-
return int((project_dir / ".
|
|
38
|
+
return int((project_dir / ".wheelhub-port").read_text().strip())
|
|
39
39
|
except (FileNotFoundError, ValueError):
|
|
40
40
|
return None
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def read_pid_file(project_dir: Path) -> int | None:
|
|
44
|
-
"""Read PID from .
|
|
44
|
+
"""Read PID from .wheelhub-pid file. Returns None if not found."""
|
|
45
45
|
try:
|
|
46
|
-
return int((project_dir / ".
|
|
46
|
+
return int((project_dir / ".wheelhub-pid").read_text().strip())
|
|
47
47
|
except (FileNotFoundError, ValueError):
|
|
48
48
|
return None
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def write_pid_file(project_dir: Path, pid: int) -> None:
|
|
52
|
-
"""Write .
|
|
53
|
-
(project_dir / ".
|
|
52
|
+
"""Write .wheelhub-pid file."""
|
|
53
|
+
(project_dir / ".wheelhub-pid").write_text(str(pid))
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def build_otel_env(port: int) -> dict[str, str]:
|
|
@@ -104,8 +104,8 @@ def start_wheelhub(project_dir: Path) -> subprocess.Popen:
|
|
|
104
104
|
def poll_for_port_file(
|
|
105
105
|
project_dir: Path, timeout: float = 5.0, interval: float = 0.1
|
|
106
106
|
) -> int:
|
|
107
|
-
"""Poll for .
|
|
108
|
-
port_file = project_dir / ".
|
|
107
|
+
"""Poll for .wheelhub-port file, return port number."""
|
|
108
|
+
port_file = project_dir / ".wheelhub-port"
|
|
109
109
|
deadline = time.monotonic() + timeout
|
|
110
110
|
|
|
111
111
|
while True:
|
|
@@ -209,23 +209,23 @@ def get_status(project_dir: Path) -> dict:
|
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
def read_tui_pid_file(project_dir: Path) -> int | None:
|
|
212
|
-
"""Read TUI PID from .
|
|
212
|
+
"""Read TUI PID from .wheelhub-gui-pid file. Returns None if not found."""
|
|
213
213
|
try:
|
|
214
|
-
return int((project_dir / ".
|
|
214
|
+
return int((project_dir / ".wheelhub-gui-pid").read_text().strip())
|
|
215
215
|
except (FileNotFoundError, ValueError):
|
|
216
216
|
return None
|
|
217
217
|
|
|
218
218
|
|
|
219
219
|
def write_tui_pid_file(project_dir: Path, pid: int) -> None:
|
|
220
|
-
"""Write .
|
|
221
|
-
(project_dir / ".
|
|
220
|
+
"""Write .wheelhub-gui-pid file."""
|
|
221
|
+
(project_dir / ".wheelhub-gui-pid").write_text(str(pid))
|
|
222
222
|
|
|
223
223
|
|
|
224
224
|
def start_tui(project_dir: Path, port: int) -> subprocess.Popen:
|
|
225
225
|
"""Start TUI as independent subprocess.
|
|
226
226
|
|
|
227
227
|
Uses start_new_session=True so TUI survives parent exit.
|
|
228
|
-
Writes .
|
|
228
|
+
Writes .wheelhub-gui-pid for lifecycle tracking.
|
|
229
229
|
"""
|
|
230
230
|
import sys
|
|
231
231
|
|