@pennyfarthing/core 11.2.2 → 11.3.2
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 +3 -3
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/doctor-legacy.test.js +2 -2
- package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
- package/packages/core/dist/cli/commands/doctor.d.ts +63 -0
- package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/doctor.js +280 -43
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/packages/core/dist/cli/commands/init.d.ts +12 -0
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +45 -0
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/commands/pyproject-install.test.d.ts +19 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.d.ts.map +1 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.js +261 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.js.map +1 -0
- package/packages/core/dist/cli/commands/update-consolidation.test.js +14 -6
- package/packages/core/dist/cli/commands/update-consolidation.test.js.map +1 -1
- package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/update.js +5 -1
- package/packages/core/dist/cli/commands/update.js.map +1 -1
- package/packages/core/dist/cli/index.js +2 -0
- package/packages/core/dist/cli/index.js.map +1 -1
- package/packages/core/dist/cli/utils/python.d.ts +1 -0
- package/packages/core/dist/cli/utils/python.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/python.js +22 -1
- package/packages/core/dist/cli/utils/python.js.map +1 -1
- package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts +17 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.js +382 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.js.map +1 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts +16 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js +377 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js.map +1 -0
- package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/settings.js +15 -2
- package/packages/core/dist/cli/utils/settings.js.map +1 -1
- package/packages/core/dist/server/paths.d.ts.map +1 -1
- package/packages/core/dist/server/paths.js +6 -0
- package/packages/core/dist/server/paths.js.map +1 -1
- package/packages/core/dist/server/settings.d.ts.map +1 -1
- package/packages/core/dist/server/settings.js +5 -0
- package/packages/core/dist/server/settings.js.map +1 -1
- package/packages/core/dist/workflow/tandem-workflow-templates.test.js +7 -5
- package/packages/core/dist/workflow/tandem-workflow-templates.test.js.map +1 -1
- package/packages/core/dist/workflow/workflow-graph-validation.d.ts +65 -0
- package/packages/core/dist/workflow/workflow-graph-validation.d.ts.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.js +190 -0
- package/packages/core/dist/workflow/workflow-graph-validation.js.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts +18 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.js +706 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.js.map +1 -0
- package/packages/core/dist/workflow/workflow-migration.test.js +6 -5
- package/packages/core/dist/workflow/workflow-migration.test.js.map +1 -1
- package/pennyfarthing-dist/agents/dev.md +4 -2
- package/pennyfarthing-dist/agents/devops.md +2 -10
- package/pennyfarthing-dist/agents/reviewer-preflight.md +4 -5
- package/pennyfarthing-dist/agents/sm.md +4 -17
- package/pennyfarthing-dist/commands/pf-health-check.md +30 -11
- package/pennyfarthing-dist/gates/{confidence-sm.md → confidence.md} +16 -17
- package/pennyfarthing-dist/gates/dev-exit.md +75 -0
- package/pennyfarthing-dist/gates/merge-ready.md +49 -0
- package/pennyfarthing-dist/gates/release-ready.md +95 -0
- package/pennyfarthing-dist/gates/reviewer-preflight-check.md +90 -0
- package/pennyfarthing-dist/gates/sm-setup-exit.md +82 -0
- package/pennyfarthing-dist/guides/agent-behavior.md +88 -30
- package/pennyfarthing-dist/guides/gates.md +7 -2
- package/pennyfarthing-dist/scripts/lib/find-root.sh +5 -0
- package/pennyfarthing-dist/scripts/lib/run-pf.sh +7 -0
- package/pennyfarthing-dist/skills/pf-settings/skill.md +42 -0
- package/pennyfarthing-dist/skills/skill-registry.yaml +15 -0
- package/pennyfarthing-dist/templates/pyproject.toml +27 -0
- package/pennyfarthing-dist/workflows/bdd-tandem.yaml +7 -3
- package/pennyfarthing-dist/workflows/bdd.yaml +7 -3
- package/pennyfarthing-dist/workflows/installation-check/steps/step-01-foundation.md +77 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-02-commands.md +82 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-03-hooks.md +121 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-04-scripts.md +83 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-05-layout.md +81 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-06-legacy.md +94 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-07-tools.md +80 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-08-summary.md +99 -0
- package/pennyfarthing-dist/workflows/installation-check/workflow.yaml +47 -0
- package/pennyfarthing-dist/workflows/tdd-tandem.yaml +7 -3
- package/pennyfarthing-dist/workflows/tdd.yaml +7 -3
- package/pennyfarthing-dist/workflows/trivial.yaml +7 -3
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/context.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/focus.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/split.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/cli.py +21 -0
- package/pennyfarthing_scripts/bc/focus.py +1 -0
- package/pennyfarthing_scripts/bc/split.py +52 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/audit_log_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__/context_meter_footer.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__/events.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/portrait_resolver.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/context_meter_footer.py +53 -3
- package/pennyfarthing_scripts/bikerack/tui.py +202 -8
- package/pennyfarthing_scripts/bmad/__init__.py +1 -0
- package/pennyfarthing_scripts/bmad/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/parser.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/test_parser.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/test_sync.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/bmad/cli.py +197 -0
- package/pennyfarthing_scripts/bmad/importer.py +200 -0
- package/pennyfarthing_scripts/bmad/parser.py +233 -0
- package/pennyfarthing_scripts/bmad/sync.py +464 -0
- package/pennyfarthing_scripts/bmad/test_parser.py +253 -0
- package/pennyfarthing_scripts/bmad/test_sync.py +223 -0
- package/pennyfarthing_scripts/cli.py +10 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/__pycache__/cli.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__/gate_file.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/gate_runner.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/marker.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/bell_mode.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/context_breaker.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/context_warning.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/cyclist_pretooluse.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/pre_edit_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/schema_validation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/session_stop.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/sprint_yaml_validation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/statusline.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__init__.py +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/settings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/cli.py +55 -0
- package/pennyfarthing_scripts/settings/settings.py +98 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_evaluation.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_gate.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_gate_file_resolution.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_gate_runner.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_resolve_gate_file_field.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_list_team.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_confidence_sm_gate.py +17 -16
- package/pennyfarthing_scripts/tests/test_resolve_gate_file_field.py +45 -47
- package/pennyfarthing_scripts/tests/test_workflow_list_team.py +0 -4
- package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
|
@@ -23,6 +23,10 @@ workflow:
|
|
|
23
23
|
- name: setup
|
|
24
24
|
agent: sm
|
|
25
25
|
output: [session_file, branches, story_context]
|
|
26
|
+
gate:
|
|
27
|
+
file: gates/sm-setup-exit
|
|
28
|
+
type: sm_setup_exit
|
|
29
|
+
condition: Session file created with workflow, phase, context, and branch
|
|
26
30
|
|
|
27
31
|
- name: red
|
|
28
32
|
agent: tea
|
|
@@ -41,9 +45,9 @@ workflow:
|
|
|
41
45
|
input: [failing_tests, story_context]
|
|
42
46
|
output: [implementation, passing_tests]
|
|
43
47
|
gate:
|
|
44
|
-
file: gates/
|
|
45
|
-
type:
|
|
46
|
-
condition:
|
|
48
|
+
file: gates/dev-exit
|
|
49
|
+
type: dev_exit
|
|
50
|
+
condition: Tests green, tree clean, no debug code, correct branch
|
|
47
51
|
tandem:
|
|
48
52
|
partner: architect
|
|
49
53
|
scope: file-watch
|
|
@@ -13,6 +13,10 @@ workflow:
|
|
|
13
13
|
- name: setup
|
|
14
14
|
agent: sm
|
|
15
15
|
output: [session_file, branches, story_context]
|
|
16
|
+
gate:
|
|
17
|
+
file: gates/sm-setup-exit
|
|
18
|
+
type: sm_setup_exit
|
|
19
|
+
condition: Session file created with workflow, phase, context, and branch
|
|
16
20
|
|
|
17
21
|
- name: red
|
|
18
22
|
agent: tea
|
|
@@ -28,9 +32,9 @@ workflow:
|
|
|
28
32
|
input: [failing_tests, story_context]
|
|
29
33
|
output: [implementation, passing_tests]
|
|
30
34
|
gate:
|
|
31
|
-
file: gates/
|
|
32
|
-
type:
|
|
33
|
-
condition:
|
|
35
|
+
file: gates/dev-exit
|
|
36
|
+
type: dev_exit
|
|
37
|
+
condition: Tests green, tree clean, no debug code, correct branch
|
|
34
38
|
|
|
35
39
|
- name: verify
|
|
36
40
|
agent: tea
|
|
@@ -13,15 +13,19 @@ workflow:
|
|
|
13
13
|
- name: setup
|
|
14
14
|
agent: sm
|
|
15
15
|
output: [session_file, branches]
|
|
16
|
+
gate:
|
|
17
|
+
file: gates/sm-setup-exit
|
|
18
|
+
type: sm_setup_exit
|
|
19
|
+
condition: Session file created with workflow, phase, and branch
|
|
16
20
|
|
|
17
21
|
- name: implement
|
|
18
22
|
agent: dev
|
|
19
23
|
input: [session_file]
|
|
20
24
|
output: [implementation]
|
|
21
25
|
gate:
|
|
22
|
-
file: gates/
|
|
23
|
-
type:
|
|
24
|
-
condition:
|
|
26
|
+
file: gates/dev-exit
|
|
27
|
+
type: dev_exit
|
|
28
|
+
condition: Tests green, tree clean, no debug code, correct branch
|
|
25
29
|
|
|
26
30
|
- name: review
|
|
27
31
|
agent: reviewer
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -29,6 +29,7 @@ from pennyfarthing_scripts.bc.focus import (
|
|
|
29
29
|
save_named_layout,
|
|
30
30
|
set_panel_focus,
|
|
31
31
|
)
|
|
32
|
+
from pennyfarthing_scripts.bc.split import set_split_layout
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def _get_current_layout() -> dict | None:
|
|
@@ -206,6 +207,26 @@ def clear_layout(name: str, dry_run: bool):
|
|
|
206
207
|
sys.exit(1)
|
|
207
208
|
|
|
208
209
|
|
|
210
|
+
@bc.command("split")
|
|
211
|
+
@click.argument("left")
|
|
212
|
+
@click.argument("right")
|
|
213
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
|
|
214
|
+
def split_layout(left: str, right: str, dry_run: bool):
|
|
215
|
+
"""Set split-pane layout with LEFT and RIGHT panels.
|
|
216
|
+
|
|
217
|
+
Example: pf bc split sprint diffs
|
|
218
|
+
"""
|
|
219
|
+
if dry_run:
|
|
220
|
+
click.echo(json.dumps({"dry_run": True, "action": "split", "left": left, "right": right}))
|
|
221
|
+
return
|
|
222
|
+
result = set_split_layout(left, right)
|
|
223
|
+
if result["success"]:
|
|
224
|
+
click.echo(json.dumps({"success": True, "split": result["data"]}))
|
|
225
|
+
else:
|
|
226
|
+
click.echo(json.dumps({"success": False, "error": result["error"]}), err=True)
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
|
|
209
230
|
@bc.command("clear-all")
|
|
210
231
|
@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
|
|
211
232
|
def clear_all_layouts(dry_run: bool):
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Split layout management — read/write split config in config.local.yaml.
|
|
2
|
+
|
|
3
|
+
Story 110-4: /bc split <left> <right> command support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pennyfarthing_scripts.bc.focus import (
|
|
11
|
+
VALID_PANELS,
|
|
12
|
+
_read_config,
|
|
13
|
+
_write_config,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_split_layout(
|
|
18
|
+
left: str, right: str, project_dir: Path | None = None
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""Set split layout in config.local.yaml.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
left: Panel for left pane (must be in VALID_PANELS).
|
|
24
|
+
right: Panel for right pane (must be in VALID_PANELS).
|
|
25
|
+
project_dir: Override project root (for testing).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
{success: bool, data?: dict, error?: str}
|
|
29
|
+
"""
|
|
30
|
+
if left not in VALID_PANELS:
|
|
31
|
+
return {
|
|
32
|
+
"success": False,
|
|
33
|
+
"error": f"Invalid panel '{left}'. Valid panels: {', '.join(VALID_PANELS)}",
|
|
34
|
+
}
|
|
35
|
+
if right not in VALID_PANELS:
|
|
36
|
+
return {
|
|
37
|
+
"success": False,
|
|
38
|
+
"error": f"Invalid panel '{right}'. Valid panels: {', '.join(VALID_PANELS)}",
|
|
39
|
+
}
|
|
40
|
+
if left == right:
|
|
41
|
+
return {
|
|
42
|
+
"success": False,
|
|
43
|
+
"error": f"Left and right panels must be different (both are '{left}'). Use same panel in single mode instead.",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
config_path, config = _read_config(project_dir)
|
|
48
|
+
config["split"] = {"left": left, "right": right}
|
|
49
|
+
_write_config(config_path, config)
|
|
50
|
+
return {"success": True, "data": {"left": left, "right": right}}
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return {"success": False, "error": str(exc)}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -4,11 +4,15 @@ Story 110-5: Context meter footer bar. Displays context window usage
|
|
|
4
4
|
percentage with color-coded tier thresholds, always visible at the
|
|
5
5
|
bottom of the layout.
|
|
6
6
|
|
|
7
|
+
Story 110-12: Periodic refresh timer, event-driven redraws, and
|
|
8
|
+
throttling for more frequent updates without performance degradation.
|
|
9
|
+
|
|
7
10
|
Subscribes to /ws/context WebSocket channel.
|
|
8
11
|
"""
|
|
9
12
|
|
|
10
13
|
from __future__ import annotations
|
|
11
14
|
|
|
15
|
+
import time
|
|
12
16
|
from typing import Any
|
|
13
17
|
|
|
14
18
|
from rich.text import Text
|
|
@@ -35,34 +39,80 @@ class ContextMeterFooter(Static):
|
|
|
35
39
|
#: WebSocket channel this footer subscribes to
|
|
36
40
|
channel: str = "context"
|
|
37
41
|
|
|
42
|
+
#: Seconds between periodic refresh redraws
|
|
43
|
+
refresh_interval: float = 5.0
|
|
44
|
+
|
|
45
|
+
#: Minimum seconds between redraws (throttle)
|
|
46
|
+
min_redraw_interval: float = 0.25
|
|
47
|
+
|
|
38
48
|
def __init__(self, client: Any = None, **kwargs: Any) -> None:
|
|
39
49
|
super().__init__(**kwargs)
|
|
40
50
|
self._client = client
|
|
41
51
|
self._context_data: dict[str, Any] | None = None
|
|
42
52
|
self._mounted = False
|
|
53
|
+
self._refresh_timer: Any = None
|
|
54
|
+
self._last_redraw_time: float = 0.0
|
|
55
|
+
self.last_update_time: float = 0.0
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_stale(self) -> bool:
|
|
59
|
+
"""True if no context data has been received."""
|
|
60
|
+
return self._context_data is None
|
|
43
61
|
|
|
44
62
|
def on_mount(self) -> None:
|
|
45
|
-
"""Subscribe to context channel on mount."""
|
|
63
|
+
"""Subscribe to context channel and start periodic refresh on mount."""
|
|
46
64
|
self._mounted = True
|
|
47
65
|
if self._client is not None:
|
|
48
66
|
self._client.subscribe("context", self.handle_context_message)
|
|
67
|
+
self._client.on_state_change(self.on_connection_state_change)
|
|
68
|
+
try:
|
|
69
|
+
self._refresh_timer = self.set_interval(
|
|
70
|
+
self.refresh_interval, self.request_refresh
|
|
71
|
+
)
|
|
72
|
+
except RuntimeError:
|
|
73
|
+
# No running event loop (e.g. unit tests calling on_mount directly)
|
|
74
|
+
pass
|
|
49
75
|
|
|
50
76
|
def on_unmount(self) -> None:
|
|
51
|
-
"""
|
|
77
|
+
"""Stop timer and mark as unmounted so further messages are ignored."""
|
|
52
78
|
self._mounted = False
|
|
79
|
+
if self._refresh_timer is not None:
|
|
80
|
+
self._refresh_timer.stop()
|
|
53
81
|
|
|
54
82
|
def on_context_meter_footer_meter_update(self, event: MeterUpdate) -> None:
|
|
55
83
|
"""Process MeterUpdate in Textual message context — triggers repaint."""
|
|
56
84
|
self.update(event.content)
|
|
57
85
|
|
|
86
|
+
def request_refresh(self) -> None:
|
|
87
|
+
"""Redraw from cached context data. Safe to call at any time."""
|
|
88
|
+
if not self._mounted or self._context_data is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
rendered = self.render_meter(self._context_data)
|
|
92
|
+
self.post_message(self.MeterUpdate(rendered))
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def on_connection_state_change(self, state: Any) -> None:
|
|
97
|
+
"""Redraw on WebSocket reconnection."""
|
|
98
|
+
self.request_refresh()
|
|
99
|
+
|
|
58
100
|
def handle_context_message(self, msg: dict[str, Any] | None) -> None:
|
|
59
|
-
"""Process incoming /ws/context message."""
|
|
101
|
+
"""Process incoming /ws/context message with throttling."""
|
|
60
102
|
if not self._mounted or msg is None:
|
|
61
103
|
return
|
|
62
104
|
ctx = msg.get("context")
|
|
63
105
|
if ctx is None:
|
|
64
106
|
return
|
|
65
107
|
self._context_data = ctx
|
|
108
|
+
self.last_update_time = time.monotonic()
|
|
109
|
+
|
|
110
|
+
# Throttle: skip redraw if too soon after last one
|
|
111
|
+
now = time.monotonic()
|
|
112
|
+
if now - self._last_redraw_time < self.min_redraw_interval:
|
|
113
|
+
return
|
|
114
|
+
self._last_redraw_time = now
|
|
115
|
+
|
|
66
116
|
try:
|
|
67
117
|
rendered = self.render_meter(ctx)
|
|
68
118
|
self.post_message(self.MeterUpdate(rendered))
|
|
@@ -106,6 +106,14 @@ PANEL_DISPLAY_NAMES: dict[str, str] = {
|
|
|
106
106
|
# Keys from PANEL_REGISTRY for fast lookup
|
|
107
107
|
_PANEL_KEYS = [key for key, _ in PANEL_REGISTRY]
|
|
108
108
|
|
|
109
|
+
# Split-pane layout presets: name → (left_panel, right_panel)
|
|
110
|
+
# Story 110-4: Named presets for common side-by-side views.
|
|
111
|
+
SPLIT_PRESETS: dict[str, tuple[str, str]] = {
|
|
112
|
+
"sprint+diffs": ("sprint", "diffs"),
|
|
113
|
+
"changed+diffs": ("changed", "diffs"),
|
|
114
|
+
"progress+debug": ("progress", "debug"),
|
|
115
|
+
}
|
|
116
|
+
|
|
109
117
|
|
|
110
118
|
class BindingFooter(Footer):
|
|
111
119
|
"""Footer subclass that exposes active binding text via render().
|
|
@@ -360,9 +368,10 @@ class BikeRackApp(App):
|
|
|
360
368
|
class FocusUpdate(Message, bubble=False):
|
|
361
369
|
"""Focus change from WS — routed through Textual message system."""
|
|
362
370
|
|
|
363
|
-
def __init__(self, focus: str | None) -> None:
|
|
371
|
+
def __init__(self, focus: str | None, split_config: dict | None = None) -> None:
|
|
364
372
|
super().__init__()
|
|
365
373
|
self.focus = focus
|
|
374
|
+
self.split_config = split_config
|
|
366
375
|
|
|
367
376
|
class WsStateUpdate(Message, bubble=False):
|
|
368
377
|
"""WS connection state change — routed through Textual message system."""
|
|
@@ -413,12 +422,23 @@ class BikeRackApp(App):
|
|
|
413
422
|
ContextMeterFooter {
|
|
414
423
|
height: 1;
|
|
415
424
|
}
|
|
425
|
+
#split-container {
|
|
426
|
+
display: none;
|
|
427
|
+
height: 1fr;
|
|
428
|
+
}
|
|
429
|
+
#split-left {
|
|
430
|
+
width: 1fr;
|
|
431
|
+
}
|
|
432
|
+
#split-right {
|
|
433
|
+
width: 1fr;
|
|
434
|
+
}
|
|
416
435
|
"""
|
|
417
436
|
|
|
418
437
|
COMMANDS = App.COMMANDS | {PanelCommands}
|
|
419
438
|
|
|
420
439
|
BINDINGS = [
|
|
421
440
|
Binding("q", "quit", "Quit"),
|
|
441
|
+
Binding("shift+s", "toggle_split", "Split"),
|
|
422
442
|
Binding("1", "switch_panel('sprint')", "Sprint", show=False),
|
|
423
443
|
Binding("2", "switch_panel('git')", "Git", show=False),
|
|
424
444
|
Binding("3", "switch_panel('diffs')", "Diffs", show=False),
|
|
@@ -429,8 +449,8 @@ class BikeRackApp(App):
|
|
|
429
449
|
Binding("8", "switch_panel('progress')", "Progress", show=False),
|
|
430
450
|
Binding("bracketright", "next_panel", "]Next"),
|
|
431
451
|
Binding("bracketleft", "prev_panel", "[Prev"),
|
|
432
|
-
Binding("tab", "next_panel", show=False),
|
|
433
|
-
Binding("shift+tab", "prev_panel", show=False),
|
|
452
|
+
Binding("tab", "next_panel", show=False, priority=True),
|
|
453
|
+
Binding("shift+tab", "prev_panel", show=False, priority=True),
|
|
434
454
|
Binding("n", "next_diff_file", "Next file", show=False),
|
|
435
455
|
Binding("p", "prev_diff_file", "Prev file", show=False),
|
|
436
456
|
Binding("j", "next_epic", show=False),
|
|
@@ -448,6 +468,12 @@ class BikeRackApp(App):
|
|
|
448
468
|
self._focused_panel: str = "sprint"
|
|
449
469
|
self._previous_panel: str | None = None
|
|
450
470
|
self._programmatic_tab_count: int = 0
|
|
471
|
+
self._context_meter: ContextMeterFooter | None = None
|
|
472
|
+
# Split-pane state (Story 110-4)
|
|
473
|
+
self._split_mode: bool = False
|
|
474
|
+
self._active_split_pane: str = "left"
|
|
475
|
+
self._split_left_key: str = "sprint"
|
|
476
|
+
self._split_right_key: str = "diffs"
|
|
451
477
|
|
|
452
478
|
def compose(self) -> ComposeResult:
|
|
453
479
|
project_dir_name = Path(
|
|
@@ -470,7 +496,11 @@ class BikeRackApp(App):
|
|
|
470
496
|
yield AuditLogPanel(client=self._client, id="panel-audit-log")
|
|
471
497
|
yield DebugPanel(client=self._client, id="panel-debug")
|
|
472
498
|
yield ProgressPanel(client=self._client, id="panel-progress")
|
|
473
|
-
|
|
499
|
+
with Horizontal(id="split-container"):
|
|
500
|
+
yield VerticalScroll(id="split-left")
|
|
501
|
+
yield VerticalScroll(id="split-right")
|
|
502
|
+
self._context_meter = ContextMeterFooter(client=self._client)
|
|
503
|
+
yield self._context_meter
|
|
474
504
|
yield BindingFooter()
|
|
475
505
|
|
|
476
506
|
async def on_mount(self) -> None:
|
|
@@ -543,8 +573,27 @@ class BikeRackApp(App):
|
|
|
543
573
|
save_last_panel(key, project_dir=None)
|
|
544
574
|
self._update_tab_bar(key)
|
|
545
575
|
|
|
576
|
+
# Refresh context meter on panel switch (110-12)
|
|
577
|
+
if self._context_meter is not None:
|
|
578
|
+
self._context_meter.request_refresh()
|
|
579
|
+
|
|
546
580
|
def action_next_panel(self) -> None:
|
|
547
|
-
"""Cycle to the next panel."""
|
|
581
|
+
"""Cycle to the next panel, or toggle pane focus in split mode."""
|
|
582
|
+
if self._split_mode:
|
|
583
|
+
# In split mode, Tab toggles between left and right pane
|
|
584
|
+
if self._active_split_pane == "left":
|
|
585
|
+
self._active_split_pane = "right"
|
|
586
|
+
try:
|
|
587
|
+
self.query_one("#split-right").focus()
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
else:
|
|
591
|
+
self._active_split_pane = "left"
|
|
592
|
+
try:
|
|
593
|
+
self.query_one("#split-left").focus()
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
return
|
|
548
597
|
try:
|
|
549
598
|
idx = _PANEL_KEYS.index(self._focused_panel)
|
|
550
599
|
except ValueError:
|
|
@@ -606,6 +655,131 @@ class BikeRackApp(App):
|
|
|
606
655
|
except Exception:
|
|
607
656
|
pass
|
|
608
657
|
|
|
658
|
+
# ------------------------------------------------------------------
|
|
659
|
+
# Split-pane layout (Story 110-4)
|
|
660
|
+
# ------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
def _sync_reparent(self, widget: Any, new_parent: Any) -> None:
|
|
663
|
+
"""Move widget from current parent to new parent synchronously.
|
|
664
|
+
|
|
665
|
+
Uses internal Textual DOM API so the move is visible immediately
|
|
666
|
+
without awaiting an async mount/remove cycle.
|
|
667
|
+
"""
|
|
668
|
+
old_parent = widget._parent
|
|
669
|
+
if old_parent is not None:
|
|
670
|
+
try:
|
|
671
|
+
old_parent._nodes._remove(widget)
|
|
672
|
+
except (ValueError, AttributeError):
|
|
673
|
+
# Fallback: remove from internal list directly
|
|
674
|
+
try:
|
|
675
|
+
old_parent._nodes._nodes.remove(widget)
|
|
676
|
+
except Exception:
|
|
677
|
+
pass
|
|
678
|
+
try:
|
|
679
|
+
new_parent._nodes._append(widget)
|
|
680
|
+
except AttributeError:
|
|
681
|
+
new_parent._nodes._nodes.append(widget)
|
|
682
|
+
widget._parent = new_parent
|
|
683
|
+
|
|
684
|
+
def _enter_split(self, left_key: str, right_key: str) -> None:
|
|
685
|
+
"""Activate split mode with specified panels (synchronous)."""
|
|
686
|
+
if left_key not in _PANEL_KEYS or right_key not in _PANEL_KEYS:
|
|
687
|
+
return
|
|
688
|
+
if left_key == right_key:
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
self._split_mode = True
|
|
692
|
+
self._split_left_key = left_key
|
|
693
|
+
self._split_right_key = right_key
|
|
694
|
+
self._active_split_pane = "left"
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
main_content = self.query_one("#main-content")
|
|
698
|
+
split_container = self.query_one("#split-container")
|
|
699
|
+
split_left = self.query_one("#split-left")
|
|
700
|
+
split_right = self.query_one("#split-right")
|
|
701
|
+
except Exception:
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
# Move left panel to split-left pane
|
|
705
|
+
try:
|
|
706
|
+
left_panel = self.query_one(f"#panel-{left_key}")
|
|
707
|
+
left_panel.display = True
|
|
708
|
+
self._sync_reparent(left_panel, split_left)
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
|
|
712
|
+
# Move right panel to split-right pane
|
|
713
|
+
try:
|
|
714
|
+
right_panel = self.query_one(f"#panel-{right_key}")
|
|
715
|
+
right_panel.display = True
|
|
716
|
+
self._sync_reparent(right_panel, split_right)
|
|
717
|
+
except Exception:
|
|
718
|
+
pass
|
|
719
|
+
|
|
720
|
+
# Hide all other panels remaining in main-content
|
|
721
|
+
for panel_key in _PANEL_KEYS:
|
|
722
|
+
if panel_key not in (left_key, right_key):
|
|
723
|
+
try:
|
|
724
|
+
p = self.query_one(f"#panel-{panel_key}")
|
|
725
|
+
p.display = False
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
main_content.display = False
|
|
730
|
+
split_container.display = True
|
|
731
|
+
|
|
732
|
+
def _exit_split(self) -> None:
|
|
733
|
+
"""Deactivate split mode, return panels to main-content."""
|
|
734
|
+
self._split_mode = False
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
main_content = self.query_one("#main-content")
|
|
738
|
+
split_container = self.query_one("#split-container")
|
|
739
|
+
split_left = self.query_one("#split-left")
|
|
740
|
+
split_right = self.query_one("#split-right")
|
|
741
|
+
except Exception:
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
# Move panels back from split panes to main-content
|
|
745
|
+
for pane in (split_left, split_right):
|
|
746
|
+
for child in list(pane.children):
|
|
747
|
+
self._sync_reparent(child, main_content)
|
|
748
|
+
|
|
749
|
+
split_container.display = False
|
|
750
|
+
main_content.display = True
|
|
751
|
+
|
|
752
|
+
# Restore single-panel visibility
|
|
753
|
+
for panel_key in _PANEL_KEYS:
|
|
754
|
+
try:
|
|
755
|
+
p = self.query_one(f"#panel-{panel_key}")
|
|
756
|
+
p.display = (panel_key == self._focused_panel)
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
|
|
760
|
+
def action_toggle_split(self) -> None:
|
|
761
|
+
"""Toggle split mode on/off (Shift+S keybinding)."""
|
|
762
|
+
if self._split_mode:
|
|
763
|
+
self._exit_split()
|
|
764
|
+
else:
|
|
765
|
+
# Default: current panel left, next panel right
|
|
766
|
+
left_key = self._focused_panel
|
|
767
|
+
try:
|
|
768
|
+
idx = _PANEL_KEYS.index(left_key)
|
|
769
|
+
except ValueError:
|
|
770
|
+
idx = 0
|
|
771
|
+
right_key = _PANEL_KEYS[(idx + 1) % len(_PANEL_KEYS)]
|
|
772
|
+
self._enter_split(left_key, right_key)
|
|
773
|
+
|
|
774
|
+
def action_apply_split_preset(self, name: str) -> None:
|
|
775
|
+
"""Apply a named split preset (synchronous)."""
|
|
776
|
+
if name not in SPLIT_PRESETS:
|
|
777
|
+
return
|
|
778
|
+
left_key, right_key = SPLIT_PRESETS[name]
|
|
779
|
+
if self._split_mode:
|
|
780
|
+
self._exit_split()
|
|
781
|
+
self._enter_split(left_key, right_key)
|
|
782
|
+
|
|
609
783
|
def _update_tab_bar(self, panel_key: str) -> None:
|
|
610
784
|
"""Update the tab bar widget with the given panel key.
|
|
611
785
|
|
|
@@ -638,7 +812,10 @@ class BikeRackApp(App):
|
|
|
638
812
|
def _handle_focus_message(self, message: dict[str, Any] | None) -> None:
|
|
639
813
|
"""Handle incoming focus channel messages.
|
|
640
814
|
|
|
641
|
-
Expected format:
|
|
815
|
+
Expected format:
|
|
816
|
+
Single panel: {type: 'update', focus: '<panel>'}
|
|
817
|
+
Split layout: {type: 'update', focus: 'split', split: {left: ..., right: ...}}
|
|
818
|
+
Split preset: {type: 'update', focus: 'split:<preset-name>'}
|
|
642
819
|
Only 'update' messages trigger panel switches (matching React hook).
|
|
643
820
|
Routes through Textual message system via post_message for proper repaint.
|
|
644
821
|
"""
|
|
@@ -648,7 +825,8 @@ class BikeRackApp(App):
|
|
|
648
825
|
return
|
|
649
826
|
if "focus" not in message:
|
|
650
827
|
return
|
|
651
|
-
|
|
828
|
+
split_config = message.get("split")
|
|
829
|
+
self.post_message(self.FocusUpdate(message["focus"], split_config=split_config))
|
|
652
830
|
|
|
653
831
|
def _handle_persona_message(self, message: dict[str, Any] | None) -> None:
|
|
654
832
|
"""Handle incoming persona channel messages.
|
|
@@ -677,7 +855,23 @@ class BikeRackApp(App):
|
|
|
677
855
|
def on_bike_rack_app_focus_update(self, event: FocusUpdate) -> None:
|
|
678
856
|
"""Apply focus change in Textual message context."""
|
|
679
857
|
focus = event.focus
|
|
680
|
-
|
|
858
|
+
split_config = event.split_config
|
|
859
|
+
|
|
860
|
+
if focus == "split" and split_config:
|
|
861
|
+
# Explicit split layout: {focus: "split", split: {left: ..., right: ...}}
|
|
862
|
+
left = split_config.get("left", "sprint")
|
|
863
|
+
right = split_config.get("right", "diffs")
|
|
864
|
+
if self._split_mode:
|
|
865
|
+
self._exit_split()
|
|
866
|
+
self._enter_split(left, right)
|
|
867
|
+
elif focus is not None and focus.startswith("split:"):
|
|
868
|
+
# Preset reference: {focus: "split:progress+debug"}
|
|
869
|
+
preset_name = focus[len("split:"):]
|
|
870
|
+
self.action_apply_split_preset(preset_name)
|
|
871
|
+
elif focus is not None and focus in _PANEL_KEYS:
|
|
872
|
+
# Single panel focus — exit split if active
|
|
873
|
+
if self._split_mode:
|
|
874
|
+
self._exit_split()
|
|
681
875
|
self.action_switch_panel(focus)
|
|
682
876
|
elif focus is not None:
|
|
683
877
|
self._previous_panel = self._focused_panel
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# BMAD adapter — bidirectional sprint sync between BMAD markdown and PF YAML.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|