@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.
Files changed (160) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/packages/core/dist/server/otlp-receiver.d.ts +16 -11
  4. package/packages/core/dist/server/otlp-receiver.d.ts.map +1 -1
  5. package/packages/core/dist/server/otlp-receiver.js +185 -24
  6. package/packages/core/dist/server/otlp-receiver.js.map +1 -1
  7. package/packages/core/dist/server/otlp-receiver.test.d.ts +21 -0
  8. package/packages/core/dist/server/otlp-receiver.test.d.ts.map +1 -0
  9. package/packages/core/dist/server/otlp-receiver.test.js +446 -0
  10. package/packages/core/dist/server/otlp-receiver.test.js.map +1 -0
  11. package/packages/core/dist/shared/portrait-resolver.d.ts +9 -0
  12. package/packages/core/dist/shared/portrait-resolver.d.ts.map +1 -1
  13. package/packages/core/dist/shared/portrait-resolver.js +27 -0
  14. package/packages/core/dist/shared/portrait-resolver.js.map +1 -1
  15. package/packages/core/dist/shared/portrait-resolver.test.js +47 -1
  16. package/packages/core/dist/shared/portrait-resolver.test.js.map +1 -1
  17. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts +13 -0
  18. package/packages/core/dist/shared/tandem-portrait-inventory.test.d.ts.map +1 -0
  19. package/packages/core/dist/shared/tandem-portrait-inventory.test.js +126 -0
  20. package/packages/core/dist/shared/tandem-portrait-inventory.test.js.map +1 -0
  21. package/pennyfarthing-dist/agents/dev.md +1 -1
  22. package/pennyfarthing-dist/agents/reviewer.md +1 -1
  23. package/pennyfarthing-dist/agents/sm-setup.md +1 -1
  24. package/pennyfarthing-dist/agents/sm.md +2 -2
  25. package/pennyfarthing-dist/agents/tea.md +1 -1
  26. package/pennyfarthing-dist/agents/testing-runner.md +2 -1
  27. package/pennyfarthing-dist/commands/pf-chore.md +2 -2
  28. package/pennyfarthing-dist/commands/pf-standalone.md +7 -2
  29. package/pennyfarthing-dist/guides/agent-behavior.md +1 -1
  30. package/pennyfarthing-dist/guides/agent-tag-taxonomy.md +1 -1
  31. package/pennyfarthing-dist/guides/bikerack.md +3 -3
  32. package/pennyfarthing-dist/guides/hooks.md +1 -1
  33. package/pennyfarthing-dist/guides/worktree-mode.md +3 -3
  34. package/pennyfarthing-dist/guides/xml-tags.md +2 -2
  35. package/pennyfarthing-dist/scripts/README.md +1 -1
  36. package/pennyfarthing-dist/scripts/core/check-context.sh +1 -1
  37. package/pennyfarthing-dist/scripts/git/README.md +24 -14
  38. package/pennyfarthing-dist/scripts/git/create-feature-branches.sh +5 -266
  39. package/pennyfarthing-dist/scripts/git/git-status-all.sh +5 -151
  40. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +6 -144
  41. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +5 -496
  42. package/pennyfarthing-dist/scripts/hooks/README.md +1 -1
  43. package/pennyfarthing-dist/scripts/hooks/bell-mode-hook.sh +1 -1
  44. package/pennyfarthing-dist/scripts/hooks/otel-auto-config.sh +9 -11
  45. package/pennyfarthing-dist/scripts/hooks/welcome-hook.sh +1 -1
  46. package/pennyfarthing-dist/scripts/portraits/generate-tandem-portraits.sh +76 -0
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +4 -221
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.sh +5 -13
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +4 -123
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +4 -33
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +4 -156
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +4 -131
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +4 -249
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +4 -160
  55. package/pennyfarthing-dist/skills/pf-bc/usage.md +1 -1
  56. package/pennyfarthing-dist/skills/pf-jira/examples.md +5 -2
  57. package/pennyfarthing-dist/skills/pf-workflow/examples.md +27 -16
  58. package/pennyfarthing-dist/skills/pf-workflow/skill.md +9 -12
  59. package/pennyfarthing-dist/skills/pf-workflow/usage.md +33 -8
  60. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +18 -6
  61. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-01-analyze.md +1 -1
  62. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-04-verify.md +1 -1
  63. package/pennyfarthing-dist/workflows/git-cleanup/steps/step-05-complete.md +1 -1
  64. package/pennyfarthing-dist/workflows/review-tandem.yaml +65 -0
  65. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +16 -8
  66. package/pennyfarthing_scripts/CLAUDE.md +26 -4
  67. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  68. package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
  69. package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/__pycache__/session_start_hook.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/bc/cli.py +3 -5
  73. package/pennyfarthing_scripts/bikerack/__pycache__/background_panel.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  76. package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/bikerack/__pycache__/git_panel.cpython-314.pyc +0 -0
  80. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/bikerack/__pycache__/portrait.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/bikerack/background_panel.py +86 -5
  87. package/pennyfarthing_scripts/bikerack/base_panel.py +62 -0
  88. package/pennyfarthing_scripts/bikerack/changed_panel.py +32 -28
  89. package/pennyfarthing_scripts/bikerack/debug_panel.py +31 -1
  90. package/pennyfarthing_scripts/bikerack/diffs_panel.py +74 -17
  91. package/pennyfarthing_scripts/bikerack/git_panel.py +103 -33
  92. package/pennyfarthing_scripts/bikerack/launcher.py +15 -15
  93. package/pennyfarthing_scripts/bikerack/progress_panel.py +315 -0
  94. package/pennyfarthing_scripts/bikerack/sprint_panel.py +158 -26
  95. package/pennyfarthing_scripts/bikerack/tui.py +336 -30
  96. package/pennyfarthing_scripts/bikerack/ws_client.py +2 -2
  97. package/pennyfarthing_scripts/cli.py +37 -65
  98. package/pennyfarthing_scripts/consultation/__init__.py +1 -0
  99. package/pennyfarthing_scripts/consultation/__pycache__/__init__.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/consultation/cli.py +149 -0
  102. package/pennyfarthing_scripts/consultation/dialogue_manager.py +417 -0
  103. package/pennyfarthing_scripts/context.py +3 -3
  104. package/pennyfarthing_scripts/epic/__pycache__/__init__.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/epic/__pycache__/cli.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/git/__init__.py +12 -1
  107. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/git/__pycache__/hooks_installer.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/git/__pycache__/repos.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/git/__pycache__/worktree.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/git/create_branches.py +3 -4
  114. package/pennyfarthing_scripts/git/hooks_installer.py +152 -0
  115. package/pennyfarthing_scripts/git/repos.py +196 -0
  116. package/pennyfarthing_scripts/git/status_all.py +27 -11
  117. package/pennyfarthing_scripts/git/worktree.py +302 -0
  118. package/pennyfarthing_scripts/git_group/__pycache__/__init__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/git_group/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/git_group/cli.py +143 -40
  121. package/pennyfarthing_scripts/handoff/__pycache__/__init__.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/handoff/complete_phase.py +12 -0
  126. package/pennyfarthing_scripts/handoff/resolve_gate.py +5 -14
  127. package/pennyfarthing_scripts/hooks.py +3 -17
  128. package/pennyfarthing_scripts/pretooluse_hook.py +1 -1
  129. package/pennyfarthing_scripts/prime/__pycache__/heatmap.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/heatmap.py +655 -0
  131. package/pennyfarthing_scripts/session/__pycache__/__init__.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/session/__pycache__/cli.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/session_start_hook.py +1 -1
  134. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/sprint/loader.py +15 -1
  136. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_cli.cpython-314-pytest-9.0.2.pyc +0 -0
  137. package/pennyfarthing_scripts/tests/__pycache__/test_handoff_e2e.cpython-314-pytest-9.0.2.pyc +0 -0
  138. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/pennyfarthing_scripts/tests/test_bikerack.py +51 -51
  140. package/pennyfarthing_scripts/tests/test_dialogue_manager.py +811 -0
  141. package/pennyfarthing_scripts/tests/test_handoff_cli.py +16 -11
  142. package/pennyfarthing_scripts/tests/test_workflow_check.py +2 -3
  143. package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/validate/adapters/tandem_awareness.py +254 -0
  145. package/pennyfarthing_scripts/validate/cli.py +17 -5
  146. package/pennyfarthing_scripts/workflow/__init__.py +40 -0
  147. package/pennyfarthing_scripts/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/workflow/__pycache__/helpers.cpython-314.pyc +0 -0
  150. package/pennyfarthing_scripts/workflow/__pycache__/scale.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/workflow/cli.py +1099 -0
  153. package/pennyfarthing_scripts/workflow/helpers.py +241 -0
  154. package/pennyfarthing_scripts/{workflow.py → workflow/scale.py} +0 -104
  155. package/pennyfarthing_scripts/workflow/state.py +112 -0
  156. package/pennyfarthing-dist/skills/pf-workflow/scripts/list-workflows.sh +0 -91
  157. package/pennyfarthing-dist/skills/pf-workflow/scripts/resume-workflow.sh +0 -163
  158. package/pennyfarthing-dist/skills/pf-workflow/scripts/show-workflow.sh +0 -138
  159. package/pennyfarthing-dist/skills/pf-workflow/scripts/start-workflow.sh +0 -273
  160. 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 file data from WebSocket payload."""
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
- files: list[tuple[str, dict[str, Any]]] = []
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 files:
82
+ if not repo_files:
86
83
  return Text("No changed files", style="dim italic")
87
84
 
88
- table = Table()
89
- table.add_column("", width=2)
90
- table.add_column("File", style="cyan")
91
- table.add_column("Status")
92
- table.add_column("Repo", style="dim")
93
-
94
- for repo_name, f in files:
95
- status_code = f.get("status", " ")
96
- path = f.get("path", "")
97
- icon, label, style = _parse_status(status_code)
98
- table.add_row(
99
- Text(icon, style=f"bold {style}"),
100
- path,
101
- Text(label, style=style),
102
- repo_name,
103
- )
104
-
105
- return table
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 from WebSocket payload with truncation/pagination."""
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
- file_parts, total_lines = _render_file_diff(
94
- diff_entry,
95
- page=self._current_page,
96
- page_size=DEFAULT_LINE_LIMIT,
97
- skip_highlight=skip_highlight,
98
- )
99
- parts.extend(file_parts)
100
- parts.append(Text("")) # separator between files
101
- max_total = max(max_total, total_lines)
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 max_total > DEFAULT_LINE_LIMIT:
105
- self._max_page = -(-max_total // DEFAULT_LINE_LIMIT) - 1
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.table import Table
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 table with Nerd Font glyphs."""
30
- table = Table()
31
- table.add_column("Repository", style="cyan")
32
- table.add_column("Branch")
33
- table.add_column("Commits")
34
- table.add_column("Changes", justify="right")
35
- table.add_column("Status")
36
-
37
- for repo in payload.get("repos", []):
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
- # Branch with Nerd Font glyph
45
- branch_col = f"\ue0a0 {branch}"
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: ahead/behind with arrow glyphs
48
- parts = []
102
+ # Commits
103
+ commit_parts = []
49
104
  if ahead:
50
- parts.append(f"\u2b06{ahead}")
105
+ commit_parts.append(f"{ahead}")
51
106
  if behind:
52
- parts.append(f"\u2b07{behind}")
53
- commits_col = " ".join(parts) if parts else "—"
54
-
55
- # Changes: count of dirty files
56
- changes_col = str(len(dirty_files))
57
-
58
- # Status: checkmark or cross
59
- status_col = "\u2713" if clean else "\u2717"
60
-
61
- table.add_row(
62
- repo.get("name", ""),
63
- branch_col,
64
- commits_col,
65
- changes_col,
66
- status_col,
67
- )
68
-
69
- return table
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 .bikerack-port, .bikerack-pid, and .bikerack-tui-pid files."""
28
- for name in (".bikerack-port", ".bikerack-pid", ".bikerack-tui-pid"):
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 .bikerack-port file. Returns None if not found."""
36
+ """Read port from .wheelhub-port file. Returns None if not found."""
37
37
  try:
38
- return int((project_dir / ".bikerack-port").read_text().strip())
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 .bikerack-pid file. Returns None if not found."""
44
+ """Read PID from .wheelhub-pid file. Returns None if not found."""
45
45
  try:
46
- return int((project_dir / ".bikerack-pid").read_text().strip())
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 .bikerack-pid file."""
53
- (project_dir / ".bikerack-pid").write_text(str(pid))
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 .bikerack-port file, return port number."""
108
- port_file = project_dir / ".bikerack-port"
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 .bikerack-tui-pid file. Returns None if not found."""
212
+ """Read TUI PID from .wheelhub-gui-pid file. Returns None if not found."""
213
213
  try:
214
- return int((project_dir / ".bikerack-tui-pid").read_text().strip())
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 .bikerack-tui-pid file."""
221
- (project_dir / ".bikerack-tui-pid").write_text(str(pid))
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 .bikerack-tui-pid for lifecycle tracking.
228
+ Writes .wheelhub-gui-pid for lifecycle tracking.
229
229
  """
230
230
  import sys
231
231