From 7c22541a8b9097360b327ffc1c279788bccd451a Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Wed, 22 Jul 2026 16:36:19 +0800 Subject: [PATCH 1/3] feat: show Board in an automatic three-line HUD Add a read-only Board HUD that starts automatically for tmux sessions. The first two lines keep focus and health visible while the third line shows the latest Board revision change. Reuse panes per CLI session, clean up stale owners, and keep prompt delivery receipts independent. Document the tmux fallback and bump devloop to 0.2.2. --- devloop/AGENTS.md | 1 + devloop/README.md | 6 +- devloop/config/config.example.json | 2 + devloop/docs/board.md | 18 +- devloop/domain/board/model.py | 1 + devloop/hooks/board_hud_start.py | 19 ++ devloop/hooks/hooks.codex.json | 6 + devloop/hooks/hooks.json | 6 + devloop/lib/config.py | 12 +- devloop/scripts/board_hud.py | 86 ++++++++ devloop/tests/run_all.py | 14 +- devloop/tests/test_board.py | 1 + devloop/tests/test_board_hud.py | 199 ++++++++++++++++++ devloop/ui/__init__.py | 1 + devloop/ui/board/__init__.py | 1 + devloop/ui/board/hud.py | 325 +++++++++++++++++++++++++++++ devloop/ui/board/tmux.py | 147 +++++++++++++ 17 files changed, 840 insertions(+), 5 deletions(-) create mode 100644 devloop/hooks/board_hud_start.py create mode 100644 devloop/scripts/board_hud.py create mode 100644 devloop/tests/test_board_hud.py create mode 100644 devloop/ui/__init__.py create mode 100644 devloop/ui/board/__init__.py create mode 100644 devloop/ui/board/hud.py create mode 100644 devloop/ui/board/tmux.py diff --git a/devloop/AGENTS.md b/devloop/AGENTS.md index 638bb86..8c35143 100644 --- a/devloop/AGENTS.md +++ b/devloop/AGENTS.md @@ -72,6 +72,7 @@ devloop/ │ ├── posttool_git_refresh.py # PostToolUse:git 状态命令后刷新 branch 段 │ ├── sessionend_release.py # SessionEnd:释放本 session 的 owner 锁(正常退出路径) │ └── pretool_*.py # 命令/编辑硬拦截(guard harness;含 owner 锁与裸 worktree add 拦截) +├── ui/board/ # Board UI adapter:固定三行 HUD projection + tmux sidecar ├── scripts/ # 工作流驱动 adapter:enter / commit_flow + smart_* / pr / release / lint/test/review / init_* ├── monitors/monitors.json # ★PR-sweep 后台轮询(替代 hook 心跳 scheduler) ├── commands/ # slash:enter / gcam / gcamp / gcampr(lint/test 归 skill,gate 自动触发) diff --git a/devloop/README.md b/devloop/README.md index 8f9810c..b15a929 100644 --- a/devloop/README.md +++ b/devloop/README.md @@ -14,7 +14,7 @@ devloop 通过两个控制杠杆落实这条生命周期:**Board 上下文投 - **PR/MR 生命周期入口**:`/enter` 选择 repo/branch;多 session 并发时,`--worktree ` 将 branch 以隔离 checkout 形态展开,并统一处理基线、依赖准备和安全清理。`/gcam`、`/gcamp`、`/gcampr` 依次覆盖 commit、push 与创建/复用 PR/MR;已有 PR/MR 的冲突处理走可恢复的 `smart_rebase.sh start/continue/finish`,以 rebase 前保存的远端 SHA 做精确 `force-with-lease`。新工作从目标分支建立干净基线,最终 merge 始终留给人。 - **component 感知**(多代码目录仓):一个 git 仓可能有多个自带工具链、可独立 lint/test 的目录——`server/` + `cli/`、`packages/*`、`cmd/*`。devloop 按**本次改动**决定跑哪些:改了 `cli/**` 就只跑 `cli` 的 lint/test,不静默回落仓根或 `server/`;改动跨多个 component 就都跑;clean tree 从仓根发起时枚举**全部** component(绝不替你猜一个)。验证戳也按 component 记——「A 过 B 挂」不会被记成整仓已验。术语见 [`CONCEPTS.md`](./CONCEPTS.md)。 -- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。状态与投递游标都自动排除在 git 提交之外。 +- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。在 tmux 中启动 CLI 时,插件还会自动显示固定三行 Board HUD:工作上下文、当前健康状态和最新变化。状态与投递游标都自动排除在 git 提交之外。 - **硬拦截**(PreToolUse deny):保护分支 commit/push、`git add -A`、直接 `git worktree add`、过期分支(PR 已 merged/closed)改文件、别的 session 占用的 checkout 上切分支或改文件(引导 worktree)、工作区根跑子项目命令、裸 `pytest`、uv 项目 `pip install`、编辑 `requirements.txt`、`lifecycle.pre_commit` 含 lint 时 lint 过期的裸 `git commit` gate。 - **自动进项目**:`cd` 进子项目时(`CwdChanged`)自动刷新上下文、浮现 AGENTS.md References,无需手动 `/enter`。 - **生命周期 hook**:`pre_commit / post_commit / pre_mr / post_mr` 四相位可挂 hook,挂哪相位由 config 决定;两类——**inline 门禁**(失败挡 commit/MR)与 **signal hook**(advisory、后台跑、不挡)。当前内置三个:`lint`、`test`(门禁),`review`(signal——后台跑 [ocr](https://github.com/alibaba/open-code-review) 审全量改动、结果回流会话、有开放 MR 时发评论)。机制见 [`docs/lifecycle-hooks.md`](./docs/lifecycle-hooks.md);code-review 细节见 [`docs/code-review.md`](./docs/code-review.md)。 @@ -52,6 +52,7 @@ bash /scripts/smart_rebase.sh finish --repo 运行时要求:**Python 3.10+**。devloop launcher 会从 PATH 自动选择首个满足版本的 `python3`、 `python` 或带版本号的 `python3.x`;需要固定解释器时设置 `DEVLOOP_PYTHON`。 +Board HUD 需要 CLI 已运行在 tmux 中;非 tmux 环境会安静跳过,不影响其余能力。 ``` # Claude Code 内 @@ -109,6 +110,9 @@ devloop 对外部的依赖(连哪个 forge、用什么 token)+ 工作区注 // 这里一般留空;也可用 init_workspace.py 显式补充。 "workspaces": [], + // tmux 中默认自动显示三行 Board HUD;不需要时显式关闭。 + "board": { "hud": { "enabled": true } }, + // 代码评审平台,按 repo 的 origin host 索引。PR/MR 创建与状态注入需要 token; // 没有匹配 token 时相关功能静默跳过,其余照常。provider 由 host 推断,type 可覆写。 "forges": { diff --git a/devloop/config/config.example.json b/devloop/config/config.example.json index 90c0845..31582ca 100644 --- a/devloop/config/config.example.json +++ b/devloop/config/config.example.json @@ -10,6 +10,8 @@ "worktree": { "keep_recent": 5 }, + "board": { "hud": { "enabled": true } }, + "lifecycle": { "default": { "pre_commit": [], "post_commit": [], "pre_mr": [], "post_mr": [] }, "repos": { diff --git a/devloop/docs/board.md b/devloop/docs/board.md index ffa4a2b..1bc7850 100644 --- a/devloop/docs/board.md +++ b/devloop/docs/board.md @@ -31,8 +31,10 @@ focus,把事实投影为共享 `Board`,再得到相关 `BoardView`。每次 已经变化的部分。 UI 读取 `BoardRuntime.snapshot()` 得到 JSON-ready 的结构化 view;读取不经过 prompt -renderer,也不改变 delivery receipt。下一阶段的 Board UI 只消费这个 read surface, -不直接拼接各状态源。 +renderer,也不改变 delivery receipt。Board HUD 消费同一个 snapshot:CLI session 在 +tmux 中启动时,`SessionStart` 自动创建底部固定三行的只读 sidecar,前两行展示当前 +focus 与健康状态,第三行展示最近一次 snapshot 变化;新的变化覆盖旧消息。HUD 不直接 +拼接各状态源,也不参与 prompt receipt。 投递游标按 session 存在 `.devloop/board/sessions/`。`PostCompact` 会让状态条目 在下一轮重放;已经消费的 event 不会因压缩再次触发。只有 UI channel 的条目不经过 @@ -62,3 +64,15 @@ validation 等事实仍由原 owner 的 segment 提供。 branch、dirty、validation 等描述“现在在哪里”,compaction 后必须重放;review 结果和待办提醒描述“发生过什么 / 请做什么”,按身份限次投递,避免 agent 重复处理同一件事。 + +### 三行 HUD 是展示面,不是第二条状态总线 + +HUD 固定保留三种语义槽位:工作上下文、当前健康状态、最新变化。失败或阻塞事实始终 +留在健康状态行,不能只作为会被覆盖的实时消息出现。第三行由 watcher 比较前后两帧 +Board item revision 得出,仅在进程内保留最新一条,不另建事件 ledger;HUD 重启后从 +“watching Board”重新开始。 + +tmux 只提供 Codex/Claude 当前尚未开放给 plugin 的底部展示位置。HUD pane 以 CLI +session 与 leader pane 标识,重复 SessionStart 复用同一 pane,leader 回到 shell 后自动 +退出。非 tmux 会话安静降级,不影响 Board 的 prompt 投递;可通过 +`board.hud.enabled=false` 显式关闭。 diff --git a/devloop/domain/board/model.py b/devloop/domain/board/model.py index 88a13aa..365229e 100644 --- a/devloop/domain/board/model.py +++ b/devloop/domain/board/model.py @@ -176,6 +176,7 @@ def to_dict(self) -> dict[str, object]: """Return the presentation-neutral shape consumed by the future Board UI.""" return { "id": self.id, + "revision": self.signature, "type": self.type.value, "kind": self.kind.value, "scope": _json_ready(self.scope), diff --git a/devloop/hooks/board_hud_start.py b/devloop/hooks/board_hud_start.py new file mode 100644 index 0000000..e9e1917 --- /dev/null +++ b/devloop/hooks/board_hud_start.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""SessionStart side effect: best-effort automatic Board HUD inside tmux.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from hooks import hook_io # noqa: E402 +from ui.board.tmux import ensure_hud_pane # noqa: E402 + + +def handle(inp: hook_io.HookInput) -> None: + ensure_hud_pane(inp.cwd, inp.session_id) + + +if __name__ == "__main__": + raise SystemExit(hook_io.observe(handle)) diff --git a/devloop/hooks/hooks.codex.json b/devloop/hooks/hooks.codex.json index c940054..9ffc4ad 100644 --- a/devloop/hooks/hooks.codex.json +++ b/devloop/hooks/hooks.codex.json @@ -34,6 +34,12 @@ "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart_codex_init.py\"", "timeout": 10, "statusMessage": "Initializing devloop session" + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/board_hud_start.py\"", + "timeout": 5, + "statusMessage": "Starting Board HUD" } ] } diff --git a/devloop/hooks/hooks.json b/devloop/hooks/hooks.json index c13d8d1..171bc57 100644 --- a/devloop/hooks/hooks.json +++ b/devloop/hooks/hooks.json @@ -23,6 +23,12 @@ "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart_init.py\"", "timeout": 10, "statusMessage": "Initializing devloop session" + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/board_hud_start.py\"", + "timeout": 5, + "statusMessage": "Starting Board HUD" } ] } diff --git a/devloop/lib/config.py b/devloop/lib/config.py index 04c1d97..310630a 100644 --- a/devloop/lib/config.py +++ b/devloop/lib/config.py @@ -67,6 +67,9 @@ # load() 的距离分层覆盖——repo 的 .devloop/config.json 写一份 worktree.keep_recent 即覆盖全局。 # 任何规范入口建 worktree 时都消费它(worktree.create_or_reuse)。 "worktree": {"keep_recent": 5}, + # Board HUD is a read-only display surface. It starts automatically only when the + # current CLI already runs inside tmux; false is an explicit opt-out. + "board": {"hud": {"enabled": True}}, } _LOCAL_NAME = ".devloop" @@ -98,7 +101,7 @@ def plugin_root() -> Path: env_root = os.environ.get("PLUGIN_ROOT") or os.environ.get("CLAUDE_PLUGIN_ROOT") if env_root: return Path(env_root) - return Path(__file__).resolve().parent.parent.parent + return Path(__file__).resolve().parent.parent # ── read / write ───────────────────────────────────────────────────────────── @@ -201,6 +204,13 @@ def worktree(repo_dir: str | Path | None = None) -> dict: return load(repo_dir).get("worktree") or {} +def board_hud(repo_dir: str | Path | None = None) -> dict: + """Resolved Board HUD settings; enabled by default and harmless outside tmux.""" + board = load(repo_dir).get("board") or {} + hud = board.get("hud") if isinstance(board, dict) else None + return hud if isinstance(hud, dict) else {} + + def notify(repo_dir: str | Path | None = None) -> dict: """已解析的 `notify` section。`notify should-arm` 决议器读 `channels`(bool):本会话是否已有 常驻 `channel all` 覆盖——true → should-arm 报 skip(交给 channel、不 arm waiter),默认 false diff --git a/devloop/scripts/board_hud.py b/devloop/scripts/board_hud.py new file mode 100644 index 0000000..27fdb40 --- /dev/null +++ b/devloop/scripts/board_hud.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""One-shot/JSON/watch entrypoint for the Board's fixed three-line terminal HUD.""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import signal +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from domain.board import BoardRuntime # noqa: E402 +from ui.board.hud import HudPulseTracker, frame_from_snapshot, render_frame # noqa: E402 +from ui.board.tmux import LEADER_ENV, pane_command # noqa: E402 + + +def _snapshot(cwd: str, session_id: str) -> dict: + runtime = BoardRuntime.resolve(cwd, session_id) + return runtime.snapshot() if runtime else {"root": cwd, "focus": None, "items": []} + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Render devloop's three-line Board HUD") + parser.add_argument("--watch", action="store_true") + parser.add_argument("--json", action="store_true") + parser.add_argument("--cwd", default=str(Path.cwd())) + parser.add_argument("--session-id", default=os.environ.get("DEVLOOP_HUD_SESSION", "")) + parser.add_argument("--leader-pane", default=os.environ.get(LEADER_ENV, "")) + return parser.parse_args() + + +def main() -> int: + args = _args() + if args.json: + print(json.dumps(_snapshot(args.cwd, args.session_id), indent=2, ensure_ascii=False)) + return 0 + if not args.watch: + frame = frame_from_snapshot(_snapshot(args.cwd, args.session_id)) + print(render_frame(frame, shutil.get_terminal_size((120, 3)).columns, sys.stdout.isatty())) + return 0 + + stopped = False + + def stop(_signum=None, _frame=None): + nonlocal stopped + stopped = True + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + tracker = HudPulseTracker() + inactive_leader_ticks = 0 + shell_commands = {"bash", "dash", "fish", "sh", "zsh"} + sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H") + sys.stdout.flush() + try: + while not stopped: + if args.leader_pane: + leader_command = pane_command(args.leader_pane) + if leader_command is None: + break + inactive_leader_ticks = ( + inactive_leader_ticks + 1 + if Path(leader_command).name in shell_commands + else 0 + ) + if inactive_leader_ticks >= 3: + break + snapshot = _snapshot(args.cwd, args.session_id) + frame = frame_from_snapshot(snapshot, tracker) + text = render_frame(frame, shutil.get_terminal_size((120, 3)).columns, True) + lines = "\n".join("\x1b[2K" + line for line in text.splitlines()) + sys.stdout.write("\x1b[H" + lines + "\x1b[J") + sys.stdout.flush() + time.sleep(1) + finally: + sys.stdout.write("\x1b[?25h") + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devloop/tests/run_all.py b/devloop/tests/run_all.py index f14daea..30600f5 100644 --- a/devloop/tests/run_all.py +++ b/devloop/tests/run_all.py @@ -13,7 +13,19 @@ from _testkit import run_all # noqa: E402 (bootstrap first) -MODULES = ["test_cmdparse", "test_forge", "test_git_ops", "test_rebase", "test_notify", "test_board", "test_context", "test_guards", "test_lifecycle", "test_review"] +MODULES = [ + "test_cmdparse", + "test_forge", + "test_git_ops", + "test_rebase", + "test_notify", + "test_board", + "test_board_hud", + "test_context", + "test_guards", + "test_lifecycle", + "test_review", +] def main() -> int: diff --git a/devloop/tests/test_board.py b/devloop/tests/test_board.py index 24d633b..531d9f6 100644 --- a/devloop/tests/test_board.py +++ b/devloop/tests/test_board.py @@ -129,6 +129,7 @@ def test_board_snapshot_is_structured_and_does_not_consume_prompt_delivery(): assert snapshot["focus"]["repo_root"] == root identity = next(item for item in snapshot["items"] if item["type"] == "repo.identity") assert identity["kind"] == "state" + assert identity["revision"] assert identity["payload"]["branch"] == "feat/board" assert "text" not in identity diff --git a/devloop/tests/test_board_hud.py b/devloop/tests/test_board_hud.py new file mode 100644 index 0000000..6a53a0f --- /dev/null +++ b/devloop/tests/test_board_hud.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Board HUD fixed-line projection, pulse replacement, and tmux lifecycle.""" +from __future__ import annotations + +import subprocess + +from _testkit import _hook_input, _load_hook, run_main # noqa: E402 + + +def _item(item_type: str, payload: dict, revision: str) -> dict: + return { + "id": f"/repo:{item_type}", + "revision": revision, + "type": item_type, + "kind": "state", + "scope": {"workspace_root": "/ws", "repo_root": "/repo"}, + "payload": payload, + } + + +def _snapshot(*items: dict) -> dict: + return { + "root": "/ws", + "focus": {"workspace_root": "/ws", "repo_root": "/repo"}, + "items": list(items), + } + + +def _identity(revision: str = "i1", **changes) -> dict: + payload = { + "code_dir": "/repo/devloop", + "branch": "feat/board-ui", + "ahead": 1, + "behind": 0, + "modified_count": 2, + "untracked_count": 0, + "pr_label": "PR #110", + "pr_state": "open", + } + payload.update(changes) + return _item("repo.identity", payload, revision) + + +def test_hud_renders_three_semantic_lines(): + from ui.board.hud import HudPulseTracker, frame_from_snapshot, render_frame + + snapshot = _snapshot( + _identity(), + _item( + "requirement.current", + {"source": "requirement.current", "text": "Requirement: Board UI"}, + "r1", + ), + _item( + "repo.validation", + {"components": [{"component": ".", "lint_at": 1, "test_at": 2}]}, + "v1", + ), + _item( + "repo.review", + {"status": "success", "reviewed_sha": "abc", "findings": 2, "failed_files": 0}, + "rv1", + ), + ) + rendered = render_frame( + frame_from_snapshot(snapshot, HudPulseTracker()), + width=120, + color=False, + ) + lines = rendered.splitlines() + assert len(lines) == 3 + assert "req:Board UI" in lines[0] and "repo:devloop" in lines[0] + assert "feat/board-ui" in lines[1] and "PR #110:open" in lines[1] + assert "validation:✓" in lines[1] and "review:2 findings" in lines[1] + assert lines[2] == "watching Board" + + +def test_hud_pulse_is_latest_change_and_critical_state_stays_stable(): + from ui.board.hud import HudPulseTracker, frame_from_snapshot, render_frame + + tracker = HudPulseTracker() + base = _snapshot( + _identity(modified_count=0), + _item("repo.validation", {"components": []}, "v1"), + ) + frame_from_snapshot(base, tracker, now=1) + + dirty = _snapshot( + _identity("i2", modified_count=3), + _item("repo.validation", {"components": []}, "v1"), + ) + first = render_frame(frame_from_snapshot(dirty, tracker, now=2), color=False) + assert "working tree changed · 3 files" in first.splitlines()[2] + + blocked = _snapshot( + _identity("i2", modified_count=3), + _item("repo.validation", {"components": []}, "v1"), + _item("repo.pr-blocked", {"label": "PR #110", "readiness": "ci_blocked"}, "b1"), + ) + second = render_frame(frame_from_snapshot(blocked, tracker, now=3), color=False) + lines = second.splitlines() + assert "BLOCKED:ci_blocked" in lines[1] + assert "merge blocked · ci_blocked" in lines[2] + + review = _snapshot( + _identity("i2", modified_count=3), + _item("repo.validation", {"components": []}, "v1"), + _item("repo.pr-blocked", {"label": "PR #110", "readiness": "ci_blocked"}, "b1"), + _item( + "repo.review", + {"status": "success", "reviewed_sha": "abc", "findings": 2, "failed_files": 0}, + "rv1", + ), + ) + third = render_frame(frame_from_snapshot(review, tracker, now=4), color=False) + lines = third.splitlines() + assert "BLOCKED:ci_blocked" in lines[1] + assert "review success · 2 findings" in lines[2] + + +def test_hud_never_wraps_and_sanitizes_dynamic_text(): + from ui.board.hud import frame_from_snapshot, render_frame + + snapshot = _snapshot( + _identity(branch="feat/very-long\x1b[31m-branch-name"), + _item( + "requirement.current", + {"text": "Requirement: 一个非常长的 requirement title that cannot fit"}, + "r1", + ), + ) + lines = render_frame(frame_from_snapshot(snapshot), width=24, color=False).splitlines() + assert len(lines) == 3 + assert all(len(line) <= 24 for line in lines) + assert all("\x1b" not in line for line in lines) + + +class _FakeTmux: + def __init__(self, panes: str = ""): + self.panes = panes + self.calls: list[list[str]] = [] + + def __call__(self, args: list[str]) -> subprocess.CompletedProcess[str]: + self.calls.append(args) + if args[0] == "display-message": + return subprocess.CompletedProcess(args, 0, "80\n", "") + if args[0] == "list-panes": + return subprocess.CompletedProcess(args, 0, self.panes, "") + if args[0] == "split-window": + return subprocess.CompletedProcess(args, 0, "%9\n", "") + return subprocess.CompletedProcess(args, 0, "", "") + + +def test_tmux_hud_creates_then_reuses_owned_three_line_pane(): + from lib import config + from ui.board import tmux + + assert (config.plugin_root() / "scripts/board_hud.py").is_file() + env = {"TMUX": "/tmp/tmux", "TMUX_PANE": "%1"} + fake = _FakeTmux() + assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=fake) == "created" + split = next(call for call in fake.calls if call[0] == "split-window") + assert split[split.index("-l") + 1] == "3" + command = split[-1] + assert "DEVLOOP_HUD_OWNER=1" in command + assert "DEVLOOP_HUD_SESSION=session-a" in command + assert "--leader-pane %1" in command + + existing = ( + "%8\texec env DEVLOOP_HUD_OWNER=1 DEVLOOP_HUD_SESSION=session-a " + "DEVLOOP_HUD_LEADER_PANE=%1 python board_hud.py --watch\n" + ) + reused = _FakeTmux(existing) + assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=reused) == "reused" + assert any(call[:3] == ["resize-pane", "-t", "%8"] for call in reused.calls) + assert not any(call[0] == "split-window" for call in reused.calls) + + stale = _FakeTmux( + "%7\texec env DEVLOOP_HUD_OWNER=1 DEVLOOP_HUD_SESSION=old-session " + "DEVLOOP_HUD_LEADER_PANE=%1 python board_hud.py --watch\n" + ) + assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=stale) == "created" + assert ["kill-pane", "-t", "%7"] in stale.calls + + +def test_sessionstart_hud_hook_is_best_effort_observer(): + hook = _load_hook("board_hud_start") + seen = [] + original = hook.ensure_hud_pane + try: + hook.ensure_hud_pane = lambda cwd, session_id: seen.append((cwd, session_id)) + assert hook.handle(_hook_input("", {"cwd": "/ws", "session_id": "s1"})) is None + finally: + hook.ensure_hud_pane = original + assert seen == [("/ws", "s1")] + + +if __name__ == "__main__": + run_main(globals()) diff --git a/devloop/ui/__init__.py b/devloop/ui/__init__.py new file mode 100644 index 0000000..c31aa4d --- /dev/null +++ b/devloop/ui/__init__.py @@ -0,0 +1 @@ +"""Human-facing adapters over devloop's domain read models.""" diff --git a/devloop/ui/board/__init__.py b/devloop/ui/board/__init__.py new file mode 100644 index 0000000..c87da58 --- /dev/null +++ b/devloop/ui/board/__init__.py @@ -0,0 +1 @@ +"""Board terminal UI adapters.""" diff --git a/devloop/ui/board/hud.py b/devloop/ui/board/hud.py new file mode 100644 index 0000000..898da66 --- /dev/null +++ b/devloop/ui/board/hud.py @@ -0,0 +1,325 @@ +"""Fixed three-line terminal projection over a presentation-neutral Board snapshot.""" +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Iterable + + +class HudTone(str, Enum): + NORMAL = "normal" + DIM = "dim" + TITLE = "title" + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True) +class HudSegment: + text: str + tone: HudTone = HudTone.NORMAL + priority: int = 5 + + +@dataclass(frozen=True) +class HudPulse: + text: str + tone: HudTone = HudTone.DIM + occurred_at: float | None = None + + +@dataclass(frozen=True) +class HudFrame: + """Three semantic slots: context, current health, and latest transient pulse.""" + + context: tuple[HudSegment, ...] + health: tuple[HudSegment, ...] + pulse: HudPulse + + +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m") +_ANSI = { + HudTone.DIM: "\x1b[2m", + HudTone.TITLE: "\x1b[1m", + HudTone.INFO: "\x1b[36m", + HudTone.SUCCESS: "\x1b[32m", + HudTone.WARNING: "\x1b[33m", + HudTone.ERROR: "\x1b[31m", +} +_RESET = "\x1b[0m" + + +def _clean(value: object) -> str: + return _CONTROL_RE.sub("", _ANSI_SGR_RE.sub("", str(value or ""))).strip() + + +def _items(snapshot: dict) -> dict[str, dict]: + rows = snapshot.get("items") if isinstance(snapshot, dict) else None + if not isinstance(rows, list): + return {} + return { + str(item.get("type")): item + for item in rows + if isinstance(item, dict) and item.get("type") + } + + +def _payload(item: dict | None) -> dict: + payload = item.get("payload") if isinstance(item, dict) else None + return payload if isinstance(payload, dict) else {} + + +def _context_segments(snapshot: dict, items: dict[str, dict]) -> tuple[HudSegment, ...]: + out = [HudSegment("[Board]", HudTone.TITLE, 0)] + workspace = _payload(items.get("workspace")) + workspace_root = _clean(workspace.get("root") or snapshot.get("root")) + if workspace_root: + out.append(HudSegment(f"ws:{Path(workspace_root).name}", HudTone.DIM, 3)) + requirement = _clean(_payload(items.get("requirement.current")).get("text")) + if requirement: + requirement = re.sub(r"^Requirement:\s*", "", requirement) + out.append(HudSegment(f"req:{requirement}", HudTone.NORMAL, 1)) + + identity = _payload(items.get("repo.identity")) + code_dir = _clean(identity.get("code_dir")) + if code_dir: + out.append(HudSegment(f"repo:{Path(code_dir).name}", HudTone.INFO, 0)) + elif not workspace_root: + root = _clean(snapshot.get("root")) + out.append(HudSegment(f"workspace:{Path(root).name}" if root else "no workspace", HudTone.DIM, 2)) + return tuple(out) + + +def _validation_segment(item: dict | None) -> HudSegment: + components = _payload(item).get("components") + if not isinstance(components, list) or not components: + return HudSegment("validation:never", HudTone.DIM, 2) + lint_ok = all(isinstance(row, dict) and row.get("lint_at") for row in components) + test_ok = all(isinstance(row, dict) and row.get("test_at") for row in components) + if lint_ok and test_ok: + return HudSegment("validation:✓", HudTone.SUCCESS, 1) + parts = [] + if lint_ok: + parts.append("lint✓") + if test_ok: + parts.append("test✓") + return HudSegment("validation:" + ("/".join(parts) or "pending"), HudTone.WARNING, 1) + + +def _health_segments(items: dict[str, dict]) -> tuple[HudSegment, ...]: + out: list[HudSegment] = [] + identity = _payload(items.get("repo.identity")) + if not identity: + subprojects = _payload(items.get("workspace")).get("subprojects") + count = len(subprojects) if isinstance(subprojects, list) else 0 + return ( + HudSegment("focus:workspace", HudTone.DIM, 0), + HudSegment(f"repos:{count}", HudTone.NORMAL, 1), + ) + branch = _clean(identity.get("branch")) + if branch: + out.append(HudSegment(branch, HudTone.INFO, 0)) + modified = int(identity.get("modified_count") or 0) + untracked = int(identity.get("untracked_count") or 0) + dirty = modified + untracked + out.append(HudSegment(f"dirty:{dirty}" if dirty else "clean", HudTone.WARNING if dirty else HudTone.SUCCESS, 2)) + ahead = int(identity.get("ahead") or 0) + behind = int(identity.get("behind") or 0) + if ahead or behind: + out.append(HudSegment(f"↑{ahead} ↓{behind}", HudTone.DIM, 4)) + pr_label = _clean(identity.get("pr_label")) + pr_state = _clean(identity.get("pr_state")) + if pr_label: + out.append(HudSegment(f"{pr_label}:{pr_state or '?'}", HudTone.NORMAL, 1)) + + blocked = _payload(items.get("repo.pr-blocked")) + if blocked: + out.append(HudSegment(f"BLOCKED:{_clean(blocked.get('readiness'))}", HudTone.ERROR, 0)) + out.append(_validation_segment(items.get("repo.validation"))) + + review = _payload(items.get("repo.review")) + if review: + status = _clean(review.get("status")) or "?" + findings = int(review.get("findings") or 0) + if status in {"error", "stale"}: + tone = HudTone.ERROR + elif status == "running": + tone = HudTone.INFO + elif findings: + tone = HudTone.WARNING + else: + tone = HudTone.SUCCESS + detail = f"{findings} findings" if findings else status + out.append(HudSegment(f"review:{detail}", tone, 1)) + pending = int(_payload(items.get("repo.review-label")).get("pending") or 0) + if pending: + out.append(HudSegment(f"labels:{pending}", HudTone.WARNING, 3)) + return tuple(out) + + +def _revision(item: dict) -> str: + revision = item.get("revision") + return str(revision) if revision else repr(item.get("payload")) + + +class HudPulseTracker: + """Remember the latest visible change in-process; newer Board revisions replace it.""" + + def __init__(self): + self._items: dict[str, dict] | None = None + self.current = HudPulse("watching Board", HudTone.DIM) + + def observe(self, items: dict[str, dict], now: float | None = None) -> HudPulse: + if self._items is None: + self._items = items + return self.current + + changed: list[tuple[int, HudPulse]] = [] + for item_type in set(self._items) | set(items): + before = self._items.get(item_type) + after = items.get(item_type) + if before is not None and after is not None and _revision(before) == _revision(after): + continue + pulse = _pulse_for_change(item_type, before, after, now) + if pulse: + changed.append((_pulse_priority(item_type), pulse)) + self._items = items + if changed: + self.current = min(changed, key=lambda row: row[0])[1] + return self.current + + +def _pulse_priority(item_type: str) -> int: + return { + "repo.pr-blocked": 0, + "repo.review": 1, + "repo.validation": 2, + "repo.identity": 3, + "requirement.current": 4, + "repo.review-label": 5, + }.get(item_type, 9) + + +def _pulse_for_change( + item_type: str, + before: dict | None, + after: dict | None, + now: float | None, +) -> HudPulse | None: + old, new = _payload(before), _payload(after) + at = datetime.now().timestamp() if now is None else now + if item_type == "repo.pr-blocked": + return HudPulse( + f"merge blocked · {_clean(new.get('readiness'))}" if after else "merge blocker cleared", + HudTone.ERROR if after else HudTone.SUCCESS, + at, + ) + if item_type == "repo.review": + if not after: + return HudPulse("review state cleared", HudTone.DIM, at) + findings = int(new.get("findings") or 0) + status = _clean(new.get("status")) or "updated" + text = f"review {status} · {findings} findings" if findings else f"review {status}" + tone = HudTone.ERROR if status in {"error", "stale"} else HudTone.WARNING if findings else HudTone.SUCCESS + return HudPulse(text, tone, at) + if item_type == "repo.validation": + segment = _validation_segment(after) + return HudPulse(segment.text.replace(":", " ", 1), segment.tone, at) + if item_type == "repo.identity": + old_branch, new_branch = _clean(old.get("branch")), _clean(new.get("branch")) + if old_branch != new_branch: + return HudPulse(f"switched {old_branch or '?'} → {new_branch or '?'}", HudTone.INFO, at) + old_dirty = int(old.get("modified_count") or 0) + int(old.get("untracked_count") or 0) + new_dirty = int(new.get("modified_count") or 0) + int(new.get("untracked_count") or 0) + if old_dirty != new_dirty: + return HudPulse(f"working tree changed · {new_dirty} files", HudTone.WARNING if new_dirty else HudTone.SUCCESS, at) + if old.get("pr_state") != new.get("pr_state"): + return HudPulse(f"{_clean(new.get('pr_label'))} became {_clean(new.get('pr_state'))}", HudTone.INFO, at) + return HudPulse("repository state updated", HudTone.DIM, at) + if item_type == "requirement.current": + if not after: + return HudPulse("requirement closed", HudTone.SUCCESS, at) + text = re.sub(r"^Requirement:\s*", "", _clean(new.get("text"))) + return HudPulse(f"requirement focused · {text}", HudTone.INFO, at) + if item_type == "repo.review-label": + return HudPulse(f"review labels pending · {int(new.get('pending') or 0)}", HudTone.WARNING, at) + return None + + +def frame_from_snapshot( + snapshot: dict, + tracker: HudPulseTracker | None = None, + now: float | None = None, +) -> HudFrame: + items = _items(snapshot) + pulse = tracker.observe(items, now) if tracker else HudPulse("watching Board", HudTone.DIM) + return HudFrame( + context=_context_segments(snapshot, items), + health=_health_segments(items), + pulse=pulse, + ) + + +def _display_width(text: str) -> int: + return sum( + 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1 + for char in text + ) + + +def _truncate(text: str, width: int) -> str: + if _display_width(text) <= width: + return text + if width <= 1: + return "…"[:width] + out = "" + for char in text: + if _display_width(out + char) > width - 1: + break + out += char + return out.rstrip() + "…" + + +def _fit_segments(segments: Iterable[HudSegment], width: int) -> list[HudSegment]: + selected = [segment for segment in segments if _clean(segment.text)] + width = max(1, width) + while len(selected) > 1 and _display_width(" · ".join(_clean(item.text) for item in selected)) > width: + worst = max(range(len(selected)), key=lambda index: (selected[index].priority, index)) + selected.pop(worst) + if not selected: + return [HudSegment("")] + plain = " · ".join(_clean(item.text) for item in selected) + if _display_width(plain) <= width: + return selected + first = selected[0] + return [HudSegment(_truncate(_clean(first.text), width), first.tone, first.priority)] + + +def _paint(segment: HudSegment, color: bool) -> str: + text = _clean(segment.text) + code = _ANSI.get(segment.tone) if color else None + return f"{code}{text}{_RESET}" if code else text + + +def _render_segments(segments: Iterable[HudSegment], width: int, color: bool) -> str: + return " · ".join(_paint(item, color) for item in _fit_segments(segments, width)) + + +def render_frame(frame: HudFrame, width: int = 120, color: bool = True) -> str: + """Render exactly three lines; semantic slots never wrap into one another.""" + pulse_prefix = "" + if frame.pulse.occurred_at is not None: + pulse_prefix = datetime.fromtimestamp(frame.pulse.occurred_at).strftime("%H:%M:%S ") + pulse = HudSegment(pulse_prefix + frame.pulse.text, frame.pulse.tone, 0) + return "\n".join(( + _render_segments(frame.context, width, color), + _render_segments(frame.health, width, color), + _render_segments((pulse,), width, color), + )) diff --git a/devloop/ui/board/tmux.py b/devloop/ui/board/tmux.py new file mode 100644 index 0000000..0c0243f --- /dev/null +++ b/devloop/ui/board/tmux.py @@ -0,0 +1,147 @@ +"""Small tmux lifecycle adapter for the automatic Board HUD sidecar.""" +from __future__ import annotations + +import os +import re +import shlex +import subprocess +from pathlib import Path +from typing import Callable + +from lib import config + + +HUD_HEIGHT = 3 +MIN_WINDOW_HEIGHT = 45 +OWNER_ENV = "DEVLOOP_HUD_OWNER" +SESSION_ENV = "DEVLOOP_HUD_SESSION" +LEADER_ENV = "DEVLOOP_HUD_LEADER_PANE" + +RunTmux = Callable[[list[str]], subprocess.CompletedProcess[str]] + + +def _default_run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["tmux", *args], + capture_output=True, + text=True, + timeout=1, + check=False, + ) + + +def _pane_id(value: str | None) -> str | None: + value = (value or "").strip() + return value if re.fullmatch(r"%\d+", value) else None + + +def _watch_command(plugin_root: Path, cwd: str, session_id: str, leader: str) -> str: + launcher = plugin_root / "scripts" / "python" + script = plugin_root / "scripts" / "board_hud.py" + env = { + OWNER_ENV: "1", + SESSION_ENV: session_id, + LEADER_ENV: leader, + } + assignments = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items()) + argv = " ".join(shlex.quote(str(value)) for value in ( + launcher, + script, + "--watch", + "--cwd", + cwd, + "--session-id", + session_id, + "--leader-pane", + leader, + )) + return f"exec env {assignments} {argv}" + + +def _owned_panes(output: str, session_id: str | None, leader: str) -> list[str]: + wanted = [f"{OWNER_ENV}=1", f"{LEADER_ENV}={shlex.quote(leader)}"] + if session_id is not None: + wanted.append(f"{SESSION_ENV}={shlex.quote(session_id)}") + found: list[str] = [] + for line in output.splitlines(): + pane, _, command = line.partition("\t") + pane = _pane_id(pane) or "" + if pane and all(marker in command for marker in wanted): + found.append(pane) + return found + + +def ensure_hud_pane( + cwd: str, + session_id: str, + *, + env: dict[str, str] | None = None, + run_tmux: RunTmux = _default_run, +) -> str: + """Ensure one three-line HUD for this CLI pane; all failures degrade to a no-op.""" + env = dict(os.environ if env is None else env) + leader = _pane_id(env.get("TMUX_PANE")) + if not env.get("TMUX") or not leader: + return "skipped_not_tmux" + if not config.board_hud(cwd).get("enabled", True): + return "skipped_disabled" + + try: + height_result = run_tmux(["display-message", "-p", "-t", leader, "#{window_height}"]) + try: + window_height = int((height_result.stdout or "").strip()) + except ValueError: + window_height = 0 + if 0 < window_height < MIN_WINDOW_HEIGHT: + return "skipped_window_too_small" + + panes_result = run_tmux([ + "list-panes", "-t", leader, "-F", "#{pane_id}\t#{pane_start_command}", + ]) + pane_output = panes_result.stdout or "" + panes = _owned_panes(pane_output, session_id, leader) + if panes: + keeper, *duplicates = panes + run_tmux(["resize-pane", "-t", keeper, "-y", str(HUD_HEIGHT)]) + for pane in duplicates: + run_tmux(["kill-pane", "-t", pane]) + return "reused" + + # A new CLI session may reuse the same shell/tmux pane after the previous CLI exits. + # Replace its stale HUD before creating this session's owner, rather than stacking panes. + for pane in _owned_panes(pane_output, None, leader): + run_tmux(["kill-pane", "-t", pane]) + + root = config.plugin_root() + command = _watch_command(root, cwd, session_id, leader) + created = run_tmux([ + "split-window", "-v", "-l", str(HUD_HEIGHT), "-d", "-P", "-F", "#{pane_id}", + "-t", leader, "-c", cwd, command, + ]) + return "created" if _pane_id(created.stdout) else "failed" + except (OSError, subprocess.SubprocessError): + return "failed" + + +def pane_exists(pane_id: str, run_tmux: RunTmux = _default_run) -> bool: + pane = _pane_id(pane_id) + if not pane: + return False + try: + result = run_tmux(["display-message", "-p", "-t", pane, "#{pane_id}"]) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 and _pane_id(result.stdout) == pane + + +def pane_command(pane_id: str, run_tmux: RunTmux = _default_run) -> str | None: + """Foreground command for a pane, or None once the leader can no longer be observed.""" + pane = _pane_id(pane_id) + if not pane: + return None + try: + result = run_tmux(["display-message", "-p", "-t", pane, "#{pane_current_command}"]) + except (OSError, subprocess.SubprocessError): + return None + command = (result.stdout or "").strip() + return command if result.returncode == 0 and command else None From 5a5a08bab823c27e07a2b0f149280b00e7feacbd Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Wed, 22 Jul 2026 23:42:25 +0800 Subject: [PATCH 2/3] feat: add native Claude Board status line Render Board context and health through Claude Code's command-backed native status line, with a safe one-time setup and version-aware plugin launcher. Keep the tmux sidecar for Codex, preserve critical blockers at narrow widths, and update the Board documentation and regression coverage. --- devloop/.claude-plugin/plugin.json | 2 +- devloop/.codex-plugin/plugin.json | 2 +- devloop/AGENTS.md | 4 +- devloop/README.md | 20 +++- devloop/commands/setup-board.md | 18 ++++ devloop/docs/board.md | 31 +++--- devloop/domain/board/model.py | 4 +- devloop/hooks/hooks.json | 6 -- devloop/lib/config.py | 2 +- devloop/scripts/board_hud.py | 45 +++++++- devloop/scripts/setup_claude_board.py | 142 ++++++++++++++++++++++++++ devloop/tests/test_board_hud.py | 97 +++++++++++++++++- devloop/ui/board/hud.py | 17 ++- devloop/ui/board/tmux.py | 5 + 14 files changed, 356 insertions(+), 39 deletions(-) create mode 100644 devloop/commands/setup-board.md create mode 100644 devloop/scripts/setup_claude_board.py diff --git a/devloop/.claude-plugin/plugin.json b/devloop/.claude-plugin/plugin.json index 7114595..04c1df0 100644 --- a/devloop/.claude-plugin/plugin.json +++ b/devloop/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "devloop", - "version": "0.2.2", + "version": "0.2.3", "description": "Dev-loop workflow, native-first rebuild: git/PR (GitHub + GitLab) + cwd-aware enter + Board-managed context delivery + lint/test gates. Built on native Claude Code events (CwdChanged / PostCompact / FileChanged / monitors).", "author": { "name": "qiankunli", diff --git a/devloop/.codex-plugin/plugin.json b/devloop/.codex-plugin/plugin.json index b08adac..c700c0b 100644 --- a/devloop/.codex-plugin/plugin.json +++ b/devloop/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "devloop", - "version": "0.2.2", + "version": "0.2.3", "description": "Dev-loop workflow: git/PR (GitHub + GitLab) + Board-managed workspace/repo context delivery + lint/test gates + execution-level hard intercepts for Claude Code and Codex.", "author": { "name": "qiankunli", diff --git a/devloop/AGENTS.md b/devloop/AGENTS.md index 8c35143..809d021 100644 --- a/devloop/AGENTS.md +++ b/devloop/AGENTS.md @@ -72,8 +72,8 @@ devloop/ │ ├── posttool_git_refresh.py # PostToolUse:git 状态命令后刷新 branch 段 │ ├── sessionend_release.py # SessionEnd:释放本 session 的 owner 锁(正常退出路径) │ └── pretool_*.py # 命令/编辑硬拦截(guard harness;含 owner 锁与裸 worktree add 拦截) -├── ui/board/ # Board UI adapter:固定三行 HUD projection + tmux sidecar -├── scripts/ # 工作流驱动 adapter:enter / commit_flow + smart_* / pr / release / lint/test/review / init_* +├── ui/board/ # Board UI adapter:稳定状态 projection + Claude status line / Codex tmux sidecar +├── scripts/ # 工作流驱动 adapter:enter / commit_flow + smart_* / pr / release / lint/test/review / init_* / Claude Board setup ├── monitors/monitors.json # ★PR-sweep 后台轮询(替代 hook 心跳 scheduler) ├── commands/ # slash:enter / gcam / gcamp / gcampr(lint/test 归 skill,gate 自动触发) ├── skills/ # git-ops / gcam / gcamp / gcampr / fix-lint / run-test diff --git a/devloop/README.md b/devloop/README.md index b15a929..8b7bc32 100644 --- a/devloop/README.md +++ b/devloop/README.md @@ -14,7 +14,7 @@ devloop 通过两个控制杠杆落实这条生命周期:**Board 上下文投 - **PR/MR 生命周期入口**:`/enter` 选择 repo/branch;多 session 并发时,`--worktree ` 将 branch 以隔离 checkout 形态展开,并统一处理基线、依赖准备和安全清理。`/gcam`、`/gcamp`、`/gcampr` 依次覆盖 commit、push 与创建/复用 PR/MR;已有 PR/MR 的冲突处理走可恢复的 `smart_rebase.sh start/continue/finish`,以 rebase 前保存的远端 SHA 做精确 `force-with-lease`。新工作从目标分支建立干净基线,最终 merge 始终留给人。 - **component 感知**(多代码目录仓):一个 git 仓可能有多个自带工具链、可独立 lint/test 的目录——`server/` + `cli/`、`packages/*`、`cmd/*`。devloop 按**本次改动**决定跑哪些:改了 `cli/**` 就只跑 `cli` 的 lint/test,不静默回落仓根或 `server/`;改动跨多个 component 就都跑;clean tree 从仓根发起时枚举**全部** component(绝不替你猜一个)。验证戳也按 component 记——「A 过 B 挂」不会被记成整仓已验。术语见 [`CONCEPTS.md`](./CONCEPTS.md)。 -- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。在 tmux 中启动 CLI 时,插件还会自动显示固定三行 Board HUD:工作上下文、当前健康状态和最新变化。状态与投递游标都自动排除在 git 提交之外。 +- **Board 与 PR/MR 感知**:workspace/repo 的 `.devloop/` 保存当前 branch、工作区、近期 PR/MR、验证和 session 归属等结构化运行态;hook 与 monitor 持续刷新事实,Board 按相关性选择紧凑内容并决定 session/turn 投递。Claude 可用原生 status line 常驻展示工作上下文与健康状态;Codex 在 tmux 中使用三行 sidecar。状态与投递游标都自动排除在 git 提交之外。 - **硬拦截**(PreToolUse deny):保护分支 commit/push、`git add -A`、直接 `git worktree add`、过期分支(PR 已 merged/closed)改文件、别的 session 占用的 checkout 上切分支或改文件(引导 worktree)、工作区根跑子项目命令、裸 `pytest`、uv 项目 `pip install`、编辑 `requirements.txt`、`lifecycle.pre_commit` 含 lint 时 lint 过期的裸 `git commit` gate。 - **自动进项目**:`cd` 进子项目时(`CwdChanged`)自动刷新上下文、浮现 AGENTS.md References,无需手动 `/enter`。 - **生命周期 hook**:`pre_commit / post_commit / pre_mr / post_mr` 四相位可挂 hook,挂哪相位由 config 决定;两类——**inline 门禁**(失败挡 commit/MR)与 **signal hook**(advisory、后台跑、不挡)。当前内置三个:`lint`、`test`(门禁),`review`(signal——后台跑 [ocr](https://github.com/alibaba/open-code-review) 审全量改动、结果回流会话、有开放 MR 时发评论)。机制见 [`docs/lifecycle-hooks.md`](./docs/lifecycle-hooks.md);code-review 细节见 [`docs/code-review.md`](./docs/code-review.md)。 @@ -28,6 +28,7 @@ devloop 通过两个控制杠杆落实这条生命周期:**Board 上下文投 | `/gcam ""` | 只 commit | | `/gcamp ""` | commit + push | | `/gcampr "" [--branch ]` | commit + push + 建/复用 PR/MR | +| `/devloop:setup-board` | 为 Claude Code 安装原生 Board status line | lint / test 无独立 slash 命令:正常由 gcam* 的 `pre_commit` gate 自动触发;手动跑走 fix-lint / run-test skill(自然语言"修下 lint"/"跑下测试")。两条路径**共用同一套 component 选择**,所以 gate 替你跑的和你手动跑的永远是同一批 component;每次执行会自述本轮选了哪些 component、为什么(`changed files under: cli` / `clean tree, all components: …`),选错一眼可见,不用等错的测试跑完再猜。 @@ -52,12 +53,25 @@ bash /scripts/smart_rebase.sh finish --repo 运行时要求:**Python 3.10+**。devloop launcher 会从 PATH 自动选择首个满足版本的 `python3`、 `python` 或带版本号的 `python3.x`;需要固定解释器时设置 `DEVLOOP_PYTHON`。 -Board HUD 需要 CLI 已运行在 tmux 中;非 tmux 环境会安静跳过,不影响其余能力。 +Claude Board HUD 使用 Claude Code 原生 status line,不依赖 tmux。安装 plugin 后运行一次 +`/devloop:setup-board`;安装器会备份 `~/.claude/settings.json`,发现已有非 devloop status line +时不会覆盖,只有得到明确确认后才允许替换。 + +Codex 暂无自定义 status line provider,当前 sidecar 依赖 +[tmux](https://github.com/tmux/tmux)。devloop 会自动检测:未安装 tmux 或 CLI 不在 tmux +会话中时不启动 sidecar,其余能力不受影响。macOS 如需 Codex HUD,从 tmux 内启动: + +```bash +brew install tmux +tmux +# 然后在 tmux 会话中启动 codex +``` ``` # Claude Code 内 /plugin marketplace add https://github.com/qiankunli/devloop.git /plugin install devloop@devloop +/devloop:setup-board ``` Codex: @@ -110,7 +124,7 @@ devloop 对外部的依赖(连哪个 forge、用什么 token)+ 工作区注 // 这里一般留空;也可用 init_workspace.py 显式补充。 "workspaces": [], - // tmux 中默认自动显示三行 Board HUD;不需要时显式关闭。 + // Board HUD 总开关:Claude 原生 status line 与 Codex tmux sidecar 共用。 "board": { "hud": { "enabled": true } }, // 代码评审平台,按 repo 的 origin host 索引。PR/MR 创建与状态注入需要 token; diff --git a/devloop/commands/setup-board.md b/devloop/commands/setup-board.md new file mode 100644 index 0000000..e5ed305 --- /dev/null +++ b/devloop/commands/setup-board.md @@ -0,0 +1,18 @@ +--- +description: Configure Claude Code's native devloop Board status line +--- + +Configure the native Claude Code Board status line using the plugin's installer: + +```bash +"${CLAUDE_PLUGIN_ROOT}/scripts/python" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_claude_board.py" \ + --plugin-root "${CLAUDE_PLUGIN_ROOT}" +``` + +If the installer exits with `CONFLICT`, an existing non-devloop `statusLine` was found. +Ask the user whether to replace it. Only after explicit confirmation, rerun the same +command with `--replace`. Never edit or overwrite the existing status line manually. + +On success, tell the user Claude reloads settings automatically and the Board should +appear after the next interaction. The installer prints a backup path when it changes +an existing settings file. diff --git a/devloop/docs/board.md b/devloop/docs/board.md index 1bc7850..24fe467 100644 --- a/devloop/docs/board.md +++ b/devloop/docs/board.md @@ -31,9 +31,10 @@ focus,把事实投影为共享 `Board`,再得到相关 `BoardView`。每次 已经变化的部分。 UI 读取 `BoardRuntime.snapshot()` 得到 JSON-ready 的结构化 view;读取不经过 prompt -renderer,也不改变 delivery receipt。Board HUD 消费同一个 snapshot:CLI session 在 -tmux 中启动时,`SessionStart` 自动创建底部固定三行的只读 sidecar,前两行展示当前 -focus 与健康状态,第三行展示最近一次 snapshot 变化;新的变化覆盖旧消息。HUD 不直接 +renderer,也不改变 delivery receipt。Board HUD 的两个 adapter 消费同一个 snapshot: +Claude 原生 `statusLine.command` 每次从官方 stdin 取得当前 `cwd + session_id`,展示 +focus 与健康状态两行;Codex 在 tmux 中启动时,`SessionStart` 创建固定三行只读 +sidecar,前两行相同,第三行展示 watcher 观察到的最近一次 snapshot 变化。HUD 不直接 拼接各状态源,也不参与 prompt receipt。 投递游标按 session 存在 `.devloop/board/sessions/`。`PostCompact` 会让状态条目 @@ -49,8 +50,8 @@ Board 只投递当前工作所需信息。当前分支的 PR 会进入 branch token。每个条目独立去重,某个事实变化不会捎带未变化的整块上下文。 相关性属于 `BoardView`,投递节奏属于 `DeliveryPolicy`;事实生产者不选择 channel、 -prompt scope 或重放行为。Requirement 当前仅以兼容卡片加入 Board,其独立 provider -与领域抽象留到 Board UI 完成后再推进。 +prompt scope 或重放行为。Requirement 当前仅以兼容卡片加入 Board;其独立 provider +与领域抽象留到 requirement 域推进时处理,不以某个 UI adapter 是否完成为前置。 ### 投递状态不是业务事实 @@ -65,14 +66,16 @@ branch、dirty、validation 等描述“现在在哪里”,compaction 后必 结果和待办提醒描述“发生过什么 / 请做什么”,按身份限次投递,避免 agent 重复处理同一件事。 -### 三行 HUD 是展示面,不是第二条状态总线 +### HUD 是展示面,不是第二条状态总线 -HUD 固定保留三种语义槽位:工作上下文、当前健康状态、最新变化。失败或阻塞事实始终 -留在健康状态行,不能只作为会被覆盖的实时消息出现。第三行由 watcher 比较前后两帧 -Board item revision 得出,仅在进程内保留最新一条,不另建事件 ledger;HUD 重启后从 -“watching Board”重新开始。 +HUD 固定保留工作上下文与当前健康状态;失败或阻塞事实始终留在健康状态行,不能只作为 +会被覆盖的实时消息出现。Codex sidecar 的第三行由常驻 watcher 比较前后两帧 Board item +revision 得出,仅在进程内保留最新一条,不另建事件 ledger;HUD 重启后从 “watching +Board”重新开始。Claude 每次 status line 刷新都会启动新进程,因此只渲染两条稳定状态, +不为凑齐第三行额外持久化一套 UI 事件游标。 -tmux 只提供 Codex/Claude 当前尚未开放给 plugin 的底部展示位置。HUD pane 以 CLI -session 与 leader pane 标识,重复 SessionStart 复用同一 pane,leader 回到 shell 后自动 -退出。非 tmux 会话安静降级,不影响 Board 的 prompt 投递;可通过 -`board.hud.enabled=false` 显式关闭。 +Claude 原生 status line 通过一次性 `/devloop:setup-board` 配置;安装器备份 settings, +发现其它 status line 时必须经用户确认才替换。tmux 只补 Codex 尚未开放给 plugin 的 +自定义展示位置:HUD pane 以 CLI session 与 leader pane 标识,重复 SessionStart 复用同一 +pane,leader 回到 shell 后自动退出。非 tmux Codex 会话安静降级,不影响 Board 的 prompt +投递;两端均可通过 `board.hud.enabled=false` 显式关闭。 diff --git a/devloop/domain/board/model.py b/devloop/domain/board/model.py index 365229e..499ed8e 100644 --- a/devloop/domain/board/model.py +++ b/devloop/domain/board/model.py @@ -173,7 +173,7 @@ def signature(self) -> str: return hashlib.sha1(raw.encode("utf-8")).hexdigest() def to_dict(self) -> dict[str, object]: - """Return the presentation-neutral shape consumed by the future Board UI.""" + """Return the presentation-neutral shape consumed by Board UI adapters.""" return { "id": self.id, "revision": self.signature, @@ -229,7 +229,7 @@ def to_dict(self) -> dict[str, object]: @dataclass(frozen=True) class Board: - """Shared structured facts for prompt delivery today and collaborative UI later.""" + """Shared structured facts for prompt delivery and UI projections.""" root: str items: tuple[BoardItem, ...] diff --git a/devloop/hooks/hooks.json b/devloop/hooks/hooks.json index 171bc57..c13d8d1 100644 --- a/devloop/hooks/hooks.json +++ b/devloop/hooks/hooks.json @@ -23,12 +23,6 @@ "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart_init.py\"", "timeout": 10, "statusMessage": "Initializing devloop session" - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/python\" \"${CLAUDE_PLUGIN_ROOT}/hooks/board_hud_start.py\"", - "timeout": 5, - "statusMessage": "Starting Board HUD" } ] } diff --git a/devloop/lib/config.py b/devloop/lib/config.py index 310630a..ba30e62 100644 --- a/devloop/lib/config.py +++ b/devloop/lib/config.py @@ -205,7 +205,7 @@ def worktree(repo_dir: str | Path | None = None) -> dict: def board_hud(repo_dir: str | Path | None = None) -> dict: - """Resolved Board HUD settings; enabled by default and harmless outside tmux.""" + """Resolved Board HUD settings shared by native and sidecar UI adapters.""" board = load(repo_dir).get("board") or {} hud = board.get("hud") if isinstance(board, dict) else None return hud if isinstance(hud, dict) else {} diff --git a/devloop/scripts/board_hud.py b/devloop/scripts/board_hud.py index 27fdb40..db6f87c 100644 --- a/devloop/scripts/board_hud.py +++ b/devloop/scripts/board_hud.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""One-shot/JSON/watch entrypoint for the Board's fixed three-line terminal HUD.""" +"""Render Board state for native status lines and the tmux sidecar.""" from __future__ import annotations import argparse @@ -14,19 +14,30 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from domain.board import BoardRuntime # noqa: E402 -from ui.board.hud import HudPulseTracker, frame_from_snapshot, render_frame # noqa: E402 +from lib import config # noqa: E402 +from ui.board.hud import ( # noqa: E402 + HudPulseTracker, + frame_from_snapshot, + render_frame, + render_statusline, +) from ui.board.tmux import LEADER_ENV, pane_command # noqa: E402 +def _runtime(cwd: str, session_id: str) -> BoardRuntime | None: + return BoardRuntime.resolve(cwd, session_id) + + def _snapshot(cwd: str, session_id: str) -> dict: - runtime = BoardRuntime.resolve(cwd, session_id) + runtime = _runtime(cwd, session_id) return runtime.snapshot() if runtime else {"root": cwd, "focus": None, "items": []} def _args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Render devloop's three-line Board HUD") + parser = argparse.ArgumentParser(description="Render devloop's Board HUD") parser.add_argument("--watch", action="store_true") parser.add_argument("--json", action="store_true") + parser.add_argument("--claude-statusline", action="store_true") parser.add_argument("--cwd", default=str(Path.cwd())) parser.add_argument("--session-id", default=os.environ.get("DEVLOOP_HUD_SESSION", "")) parser.add_argument("--leader-pane", default=os.environ.get(LEADER_ENV, "")) @@ -35,6 +46,32 @@ def _args() -> argparse.Namespace: def main() -> int: args = _args() + if args.claude_statusline: + try: + payload = json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError: + return 0 + workspace = payload.get("workspace") if isinstance(payload, dict) else None + cwd = ( + workspace.get("current_dir") + if isinstance(workspace, dict) and workspace.get("current_dir") + else payload.get("cwd") if isinstance(payload, dict) else None + ) or args.cwd + session_id = ( + str(payload.get("session_id") or args.session_id) + if isinstance(payload, dict) + else args.session_id + ) + if not config.board_hud(cwd).get("enabled", True): + return 0 + runtime = _runtime(cwd, session_id) + if runtime is None: + return 0 + columns = os.environ.get("COLUMNS", "") + width = int(columns) if columns.isdigit() else shutil.get_terminal_size((120, 2)).columns + frame = frame_from_snapshot(runtime.snapshot()) + print(render_statusline(frame, max(1, width - 4), not os.environ.get("NO_COLOR"))) + return 0 if args.json: print(json.dumps(_snapshot(args.cwd, args.session_id), indent=2, ensure_ascii=False)) return 0 diff --git a/devloop/scripts/setup_claude_board.py b/devloop/scripts/setup_claude_board.py new file mode 100644 index 0000000..5db4289 --- /dev/null +++ b/devloop/scripts/setup_claude_board.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Install devloop's command-backed Board status line into Claude settings.""" +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import sys +import tempfile +from datetime import datetime +from pathlib import Path + + +LAUNCHER_NAME = "board_statusline.py" + + +def _claude_dir(value: str | None = None) -> Path: + return Path(value or os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser() + + +def _load_settings(path: Path) -> dict: + if not path.exists() or not path.read_text(encoding="utf-8").strip(): + return {} + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"Claude settings must be a JSON object: {path}") + return value + + +def _atomic_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, ensure_ascii=False) + handle.write("\n") + os.replace(name, path) + except BaseException: + Path(name).unlink(missing_ok=True) + raise + + +def _launcher_source(claude_dir: Path, plugin_root: Path) -> str: + """A stable user-level launcher that follows marketplace version updates.""" + return f'''#!/usr/bin/env python3 +from __future__ import annotations + +import os +from pathlib import Path + +CLAUDE_DIR = Path({str(claude_dir)!r}) +FALLBACK_ROOT = Path({str(plugin_root)!r}) + + +def version_key(path: Path) -> tuple[int, ...]: + parts = path.name.split(".") + return tuple(int(part) for part in parts) if parts and all(part.isdigit() for part in parts) else () + + +def plugin_root() -> Path: + base = Path(os.environ.get("CLAUDE_CONFIG_DIR") or CLAUDE_DIR) + candidates = [ + path for path in base.glob("plugins/cache/*/devloop/*") + if version_key(path) and (path / "scripts/board_hud.py").is_file() + ] + if candidates: + return max(candidates, key=lambda path: (version_key(path), path.stat().st_mtime)) + return FALLBACK_ROOT + + +root = plugin_root() +launcher = root / "scripts/python" +script = root / "scripts/board_hud.py" +if launcher.is_file() and script.is_file(): + os.execv(str(launcher), [str(launcher), str(script), "--claude-statusline"]) +''' + + +def install(claude_dir: Path, plugin_root: Path, replace: bool = False) -> tuple[Path, Path | None]: + settings_path = claude_dir / "settings.json" + settings = _load_settings(settings_path) + existing = settings.get("statusLine") + existing_command = existing.get("command", "") if isinstance(existing, dict) else "" + + launcher_path = claude_dir / "plugins" / "devloop" / LAUNCHER_NAME + command = f"{shlex.quote(sys.executable)} {shlex.quote(str(launcher_path))}" + owned = LAUNCHER_NAME in existing_command + if existing_command and not owned and not replace: + raise FileExistsError( + "Claude already has a non-devloop statusLine; rerun with --replace only after user confirmation" + ) + + backup = None + if settings_path.exists(): + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + backup = settings_path.with_name(f"{settings_path.name}.bak.{stamp}") + shutil.copy2(settings_path, backup) + + launcher_path.parent.mkdir(parents=True, exist_ok=True) + launcher_path.write_text(_launcher_source(claude_dir, plugin_root), encoding="utf-8") + launcher_path.chmod(0o700) + settings["statusLine"] = { + "type": "command", + "command": command, + "refreshInterval": 2, + } + _atomic_json(settings_path, settings) + return settings_path, backup + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Configure Claude's native devloop Board status line") + parser.add_argument("--claude-config-dir") + parser.add_argument("--plugin-root", default=str(Path(__file__).resolve().parent.parent)) + parser.add_argument("--replace", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = _args() + try: + settings, backup = install( + _claude_dir(args.claude_config_dir), + Path(args.plugin_root).expanduser().resolve(), + args.replace, + ) + except FileExistsError as exc: + print(f"CONFLICT: {exc}", file=sys.stderr) + return 2 + except (OSError, ValueError) as exc: + print(f"ERROR: unable to configure Claude Board status line: {exc}", file=sys.stderr) + return 1 + print(f"Configured Claude Board status line in {settings}") + if backup: + print(f"Backup: {backup}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devloop/tests/test_board_hud.py b/devloop/tests/test_board_hud.py index 6a53a0f..67fa33d 100644 --- a/devloop/tests/test_board_hud.py +++ b/devloop/tests/test_board_hud.py @@ -2,9 +2,12 @@ """Board HUD fixed-line projection, pulse replacement, and tmux lifecycle.""" from __future__ import annotations +import json import subprocess +import tempfile +from pathlib import Path -from _testkit import _hook_input, _load_hook, run_main # noqa: E402 +from _testkit import _hook_input, _load_hook, _load_script, run_main # noqa: E402 def _item(item_type: str, payload: dict, revision: str) -> dict: @@ -135,6 +138,20 @@ def test_hud_never_wraps_and_sanitizes_dynamic_text(): assert all("\x1b" not in line for line in lines) +def test_native_statusline_keeps_stable_slots_and_never_drops_blocker(): + from ui.board.hud import frame_from_snapshot, render_statusline + + snapshot = _snapshot( + _identity(modified_count=2), + _item("repo.pr-blocked", {"readiness": "ci_blocked"}, "b1"), + _item("repo.validation", {"components": []}, "v1"), + ) + lines = render_statusline(frame_from_snapshot(snapshot), width=30, color=False).splitlines() + assert len(lines) == 2 + assert "BLOCKED:ci_blocked" in lines[1] + assert "feat/board-ui" not in lines[1] + + class _FakeTmux: def __init__(self, panes: str = ""): self.panes = panes @@ -157,8 +174,14 @@ def test_tmux_hud_creates_then_reuses_owned_three_line_pane(): assert (config.plugin_root() / "scripts/board_hud.py").is_file() env = {"TMUX": "/tmp/tmux", "TMUX_PANE": "%1"} + + def installed(_: str) -> str: + return "/usr/bin/tmux" + fake = _FakeTmux() - assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=fake) == "created" + assert tmux.ensure_hud_pane( + "/repo", "session-a", env=env, run_tmux=fake, find_executable=installed, + ) == "created" split = next(call for call in fake.calls if call[0] == "split-window") assert split[split.index("-l") + 1] == "3" command = split[-1] @@ -171,7 +194,9 @@ def test_tmux_hud_creates_then_reuses_owned_three_line_pane(): "DEVLOOP_HUD_LEADER_PANE=%1 python board_hud.py --watch\n" ) reused = _FakeTmux(existing) - assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=reused) == "reused" + assert tmux.ensure_hud_pane( + "/repo", "session-a", env=env, run_tmux=reused, find_executable=installed, + ) == "reused" assert any(call[:3] == ["resize-pane", "-t", "%8"] for call in reused.calls) assert not any(call[0] == "split-window" for call in reused.calls) @@ -179,10 +204,27 @@ def test_tmux_hud_creates_then_reuses_owned_three_line_pane(): "%7\texec env DEVLOOP_HUD_OWNER=1 DEVLOOP_HUD_SESSION=old-session " "DEVLOOP_HUD_LEADER_PANE=%1 python board_hud.py --watch\n" ) - assert tmux.ensure_hud_pane("/repo", "session-a", env=env, run_tmux=stale) == "created" + assert tmux.ensure_hud_pane( + "/repo", "session-a", env=env, run_tmux=stale, find_executable=installed, + ) == "created" assert ["kill-pane", "-t", "%7"] in stale.calls +def test_tmux_hud_is_disabled_when_tmux_is_not_installed(): + from ui.board import tmux + + fake = _FakeTmux() + result = tmux.ensure_hud_pane( + "/repo", + "session-a", + env={"TMUX": "/tmp/tmux", "TMUX_PANE": "%1"}, + run_tmux=fake, + find_executable=lambda _: None, + ) + assert result == "skipped_tmux_unavailable" + assert fake.calls == [] + + def test_sessionstart_hud_hook_is_best_effort_observer(): hook = _load_hook("board_hud_start") seen = [] @@ -195,5 +237,52 @@ def test_sessionstart_hud_hook_is_best_effort_observer(): assert seen == [("/ws", "s1")] +def test_claude_uses_native_statusline_while_codex_keeps_sidecar_hook(): + root = Path(__file__).resolve().parent.parent + claude = json.loads((root / "hooks/hooks.json").read_text(encoding="utf-8")) + codex = json.loads((root / "hooks/hooks.codex.json").read_text(encoding="utf-8")) + + def commands(value: dict) -> list[str]: + return [ + hook["command"] + for groups in value["hooks"].values() + for group in groups + for hook in group.get("hooks", []) + ] + + assert not any("board_hud_start.py" in command for command in commands(claude)) + assert any("board_hud_start.py" in command for command in commands(codex)) + + +def test_claude_statusline_setup_preserves_settings_and_refuses_foreign_owner(): + setup = _load_script("setup_claude_board") + plugin_root = Path(__file__).resolve().parent.parent + with tempfile.TemporaryDirectory() as tmp: + claude_dir = Path(tmp) / ".claude" + claude_dir.mkdir() + settings_path = claude_dir / "settings.json" + settings_path.write_text(json.dumps({"theme": "dark"}), encoding="utf-8") + + saved, backup = setup.install(claude_dir, plugin_root) + value = json.loads(saved.read_text(encoding="utf-8")) + assert value["theme"] == "dark" + assert value["statusLine"]["type"] == "command" + assert value["statusLine"]["refreshInterval"] == 2 + assert setup.LAUNCHER_NAME in value["statusLine"]["command"] + assert backup is not None and backup.is_file() + launcher = claude_dir / "plugins/devloop" / setup.LAUNCHER_NAME + assert "plugins/cache/*/devloop/*" in launcher.read_text(encoding="utf-8") + + foreign = {"theme": "dark", "statusLine": {"type": "command", "command": "other-hud"}} + settings_path.write_text(json.dumps(foreign), encoding="utf-8") + try: + setup.install(claude_dir, plugin_root) + except FileExistsError: + pass + else: + raise AssertionError("foreign status line must require explicit replacement") + assert json.loads(settings_path.read_text(encoding="utf-8")) == foreign + + if __name__ == "__main__": run_main(globals()) diff --git a/devloop/ui/board/hud.py b/devloop/ui/board/hud.py index 898da66..d58eb3b 100644 --- a/devloop/ui/board/hud.py +++ b/devloop/ui/board/hud.py @@ -141,7 +141,9 @@ def _health_segments(items: dict[str, dict]) -> tuple[HudSegment, ...]: blocked = _payload(items.get("repo.pr-blocked")) if blocked: - out.append(HudSegment(f"BLOCKED:{_clean(blocked.get('readiness'))}", HudTone.ERROR, 0)) + # A narrow status line may drop ordinary context, but never the fact that + # the current PR is blocked (docs/board.md: stable health beats decoration). + out.append(HudSegment(f"BLOCKED:{_clean(blocked.get('readiness'))}", HudTone.ERROR, -1)) out.append(_validation_segment(items.get("repo.validation"))) review = _payload(items.get("repo.review")) @@ -323,3 +325,16 @@ def render_frame(frame: HudFrame, width: int = 120, color: bool = True) -> str: _render_segments(frame.health, width, color), _render_segments((pulse,), width, color), )) + + +def render_statusline(frame: HudFrame, width: int = 120, color: bool = True) -> str: + """Render stable Board state for a command-backed status line. + + Claude starts a fresh process for each refresh, so the tmux watcher's + in-memory pulse has no honest equivalent here. Keep the native surface to + the two durable slots instead of persisting a second event ledger. + """ + return "\n".join(( + _render_segments(frame.context, width, color), + _render_segments(frame.health, width, color), + )) diff --git a/devloop/ui/board/tmux.py b/devloop/ui/board/tmux.py index 0c0243f..b8872a3 100644 --- a/devloop/ui/board/tmux.py +++ b/devloop/ui/board/tmux.py @@ -4,6 +4,7 @@ import os import re import shlex +import shutil import subprocess from pathlib import Path from typing import Callable @@ -18,6 +19,7 @@ LEADER_ENV = "DEVLOOP_HUD_LEADER_PANE" RunTmux = Callable[[list[str]], subprocess.CompletedProcess[str]] +FindExecutable = Callable[[str], str | None] def _default_run(args: list[str]) -> subprocess.CompletedProcess[str]: @@ -77,9 +79,12 @@ def ensure_hud_pane( *, env: dict[str, str] | None = None, run_tmux: RunTmux = _default_run, + find_executable: FindExecutable = shutil.which, ) -> str: """Ensure one three-line HUD for this CLI pane; all failures degrade to a no-op.""" env = dict(os.environ if env is None else env) + if find_executable("tmux") is None: + return "skipped_tmux_unavailable" leader = _pane_id(env.get("TMUX_PANE")) if not env.get("TMUX") or not leader: return "skipped_not_tmux" From b4b00f285f72c96df1350578b80dbc6fba0b1896 Mon Sep 17 00:00:00 2001 From: "liqiankun.1111" Date: Thu, 23 Jul 2026 09:53:30 +0800 Subject: [PATCH 3/3] fix: keep Board HUD sidecar resilient Preserve the last rendered frame when a transient Board read fails, and honor the user's configured shell when deciding that the leader CLI has exited. --- devloop/scripts/board_hud.py | 32 +++++++++++++++++++++++++------- devloop/tests/test_board_hud.py | 12 ++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/devloop/scripts/board_hud.py b/devloop/scripts/board_hud.py index db6f87c..5418583 100644 --- a/devloop/scripts/board_hud.py +++ b/devloop/scripts/board_hud.py @@ -33,6 +33,25 @@ def _snapshot(cwd: str, session_id: str) -> dict: return runtime.snapshot() if runtime else {"root": cwd, "focus": None, "items": []} +def _watch_text(cwd: str, session_id: str, tracker: HudPulseTracker) -> str | None: + """Keep the last visible frame when a transient Board read is unavailable.""" + try: + snapshot = _snapshot(cwd, session_id) + frame = frame_from_snapshot(snapshot, tracker) + return render_frame(frame, shutil.get_terminal_size((120, 3)).columns, True) + except (OSError, ValueError): + return None + + +def _shell_commands(environ: dict[str, str] | None = None) -> set[str]: + environ = os.environ if environ is None else environ + commands = {"bash", "dash", "fish", "sh", "zsh"} + configured = Path(environ.get("SHELL", "")).name + if configured: + commands.add(configured) + return commands + + def _args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Render devloop's Board HUD") parser.add_argument("--watch", action="store_true") @@ -90,7 +109,7 @@ def stop(_signum=None, _frame=None): signal.signal(signal.SIGTERM, stop) tracker = HudPulseTracker() inactive_leader_ticks = 0 - shell_commands = {"bash", "dash", "fish", "sh", "zsh"} + shell_commands = _shell_commands() sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H") sys.stdout.flush() try: @@ -106,12 +125,11 @@ def stop(_signum=None, _frame=None): ) if inactive_leader_ticks >= 3: break - snapshot = _snapshot(args.cwd, args.session_id) - frame = frame_from_snapshot(snapshot, tracker) - text = render_frame(frame, shutil.get_terminal_size((120, 3)).columns, True) - lines = "\n".join("\x1b[2K" + line for line in text.splitlines()) - sys.stdout.write("\x1b[H" + lines + "\x1b[J") - sys.stdout.flush() + text = _watch_text(args.cwd, args.session_id, tracker) + if text is not None: + lines = "\n".join("\x1b[2K" + line for line in text.splitlines()) + sys.stdout.write("\x1b[H" + lines + "\x1b[J") + sys.stdout.flush() time.sleep(1) finally: sys.stdout.write("\x1b[?25h") diff --git a/devloop/tests/test_board_hud.py b/devloop/tests/test_board_hud.py index 67fa33d..3d7573c 100644 --- a/devloop/tests/test_board_hud.py +++ b/devloop/tests/test_board_hud.py @@ -152,6 +152,18 @@ def test_native_statusline_keeps_stable_slots_and_never_drops_blocker(): assert "feat/board-ui" not in lines[1] +def test_watch_frame_survives_transient_board_read_error(): + script = _load_script("board_hud") + script._snapshot = lambda *_: (_ for _ in ()).throw(ValueError("partial state")) + + assert script._watch_text("/repo", "session-a", script.HudPulseTracker()) is None + + +def test_watch_recognizes_the_users_configured_shell(): + script = _load_script("board_hud") + assert "nu" in script._shell_commands({"SHELL": "/opt/homebrew/bin/nu"}) + + class _FakeTmux: def __init__(self, panes: str = ""): self.panes = panes