diff --git a/common/tuios/.config/tuios/config.d/dotfiles.toml b/common/tuios/.config/tuios/config.d/dotfiles.toml index f77a434e..3391bbb4 100644 --- a/common/tuios/.config/tuios/config.d/dotfiles.toml +++ b/common/tuios/.config/tuios/config.d/dotfiles.toml @@ -33,22 +33,16 @@ show_clock = true [appearance.sidebar] position = "left" width = 32 -sections = "sessions,custom,agents" +sections = "sessions,agents" show_agents = true -# tmux sidebar 相当の session ごとの agent 数と branch。 -[appearance.sidebar.custom] -title = "overview" -command = "~/dotfiles/scripts/tui rail" -refresh = "3s" - # tmux.expose 相当。pane navigator を画面カード一覧で開く。 [panes] navigator_layout = "cards" # tmux status-right 相当。mode・prefix・copy表示はdock標準のpillを使う。 [dock] -right = ["transfers", "notifications", "copy-help", "cpu", "ram", "custom/gpu", "custom/storage", "custom/branch", "clock", "custom/host", "session-controls"] +right = ["transfers", "notifications", "copy-help", "cpu", "ram", "custom/gpu", "custom/storage", "custom/agents", "custom/branch", "clock", "custom/host", "session-controls"] [dock.clock] format = "01/02 15:04" @@ -61,6 +55,11 @@ refresh = "5s" command = "~/dotfiles/scripts/tui dock storage" refresh = "60s" +# tmux の Claude badge / hang 表示相当。表示中 session の agent 数と状態色。 +[dock.custom.agents] +command = "~/dotfiles/scripts/tui dock agents" +refresh = "3s" + [dock.custom.branch] command = "~/dotfiles/scripts/tui dock branch" refresh = "3s" diff --git a/docs/tools/tuios.md b/docs/tools/tuios.md index 3ed34b1d..50ef6815 100644 --- a/docs/tools/tuios.md +++ b/docs/tools/tuios.md @@ -1,6 +1,6 @@ # TUIOS -> **由来:** **Upstream** [TUIOS](https://github.com/Gaurav-Gosain/tuios) / **Configuration** `common/tuios/`・`mac/tuios/` / **Custom** `scripts/tui`のproject picker・space設定と巡回・sidebar overview・theme / pane入替picker・dock status・Neovim navigation・ssh/fzf/tmuxへのキー委譲・`tui rcon` container session([区分](../provenance.md#区分)) +> **由来:** **Upstream** [TUIOS](https://github.com/Gaurav-Gosain/tuios) / **Configuration** `common/tuios/`・`mac/tuios/` / **Custom** `scripts/tui`のproject picker・space設定と巡回・dock agents・theme / pane入替picker・dock status・Neovim navigation・ssh/fzf/tmuxへのキー委譲・`tui rcon` container session([区分](../provenance.md#区分)) TUIOS 0.10.1の標準機能を中心に、local / remote session、layout、AI表示、復元を管理する。既存tmuxは引き続き利用できる。 @@ -48,7 +48,7 @@ prefixは`Ctrl+Space`。表はprefixの後に押すキー。 | `e` | native hints | | `s` / `f` | native session switcher。local / remoteのsessionを選択 | | `Ctrl+f` / `/` | native pane navigator。全sessionのpaneを画面カードで一覧(tmux.expose相当) | -| `m` | focus中paneと入れ替えるpaneをfzfで選ぶ。番号はpaneタイトルの番号 | +| `m` | focus中paneと入れ替えるpaneをfzfで選ぶ。候補はpaneタイトルと同じ`番号: 名前`で、最小化したpaneは除く | | `y` | themeをfzfで選ぶ。このclientだけに一時適用し、設定ファイルは変えない | | `F` | project picker。remoteは候補通知後に`prefix s`で選択 | | `Ctrl+s` | native spaceへ登録、作成、解除 | @@ -181,8 +181,9 @@ tmuxの`pane-border-status`に合わせる。 - pane上端に`番号: タイトル`を表示し、window buttonは隠す。TUIOS 0.10.1は`shared_borders`有効時にtitleを描画しないため無効にしている。 - focus外paneの`dim_unfocused`は使わない。TUIOS 0.10.1はtheme設定時に既定背景のセルへthemeの背景色を塗るため、背景透過が消える。代わりに枠線をtokyonightの青系で色分けする(focus `#7aa2f7`、focus外 `#3b4261`)。 -- sidebarの`overview`は`tui rail`の出力で、sessionごとのagent数・状態色(黄=作業中、赤=要応答/エラー、緑=完了、紫=hang、灰=待機)と、focus中paneのcwdのbranchを3秒ごとに表示する。localのsessionのみが対象。`tuios ls`のbranchはsession作成時の値なので使わない。 -- hangはtmuxの`tmux-agent-hang-watch.sh`相当。`working`のまま`AGENT_HANG_THRESHOLD`秒(既定120)hookのreportがないpaneを紫で示す。hookはtool呼び出しごとに`working`を再reportし`evidence_age_ms`を更新するため、これをheartbeatとして使う。画面出力は生存扱いにしない。常駐watcherは置かず、railの3秒周期で判定する。表示はoverviewのみで、TUIOS標準のrail glyph・Inbox・通知は`working`のまま(hook経由のagentの無応答検知はTUIOS 0.10.1未対応)。1つのtoolが長時間動く間やtool呼び出しのない長い思考中も同じくhangと推定される。 +- sidebarはTUIOS標準のsessions / agentsだけを使う。session一覧の重複を避けるためcustom sectionは置かない。session行の状態glyphとrepo / branchのgroupingは標準表示。 +- dockの`󰚩 N ●●`は`tui dock agents`の出力で、表示中sessionのagent数と状態色(黄=作業中、赤=要応答/エラー、緑=完了、紫=hang、灰=待機)を3秒ごとに表示する。agentがいなければcellを隠す。 +- hangはtmuxの`tmux-agent-hang-watch.sh`相当。`working`のまま`AGENT_HANG_THRESHOLD`秒(既定120)hookのreportがないpaneを紫で示す。hookはtool呼び出しごとに`working`を再reportし`evidence_age_ms`を更新するため、これをheartbeatとして使う。画面出力は生存扱いにしない。常駐watcherは置かず、dock cellの3秒周期で判定する。表示はdockのみで、TUIOS標準のrail glyph・Inbox・通知は`working`のまま(hook経由のagentの無応答検知はTUIOS 0.10.1未対応)。1つのtoolが長時間動く間やtool呼び出しのない長い思考中も同じくhangと推定される。 - dockの右側はtmuxの`status-right`相当。CPU・RAM(標準)、GPU、`/`の使用率、focus中paneのbranch、`月/日 時:分`の時計、ホスト名を並べる。GPU・ストレージ・branchは`tui dock gpu|storage|branch`の出力で、tmuxと同じ閾値色を使う。GPUはmacOSの`macmon`かLinuxの`nvidia-smi`があるときだけ表示する。dock commandにはfocus中paneのcwdが渡らないため、branchは`tuios list-agents -s $TUIOS_SESSION`のfocus中paneから引く。remote sessionのbranchはTUIOS 0.10.1未対応で表示しない。mode・prefix・copyの表示はdock標準のpillを使う。 ## AI表示 diff --git a/scripts/test-tui.py b/scripts/test-tui.py index 2fbb8efa..8310a74b 100644 --- a/scripts/test-tui.py +++ b/scripts/test-tui.py @@ -137,8 +137,9 @@ def test_theme_sets_the_picked_theme_on_this_client(self): def test_swap_offers_tiled_panes_of_the_active_workspace_by_title_index(self): windows = [ - {"window_id": "a", "index": 0, "title": "editor", "workspace": 1}, + {"window_id": "a", "index": 0, "title": "~/src", "display_name": "editor", "workspace": 1}, {"window_id": "b", "index": 1, "title": "shell", "workspace": 1}, + {"window_id": "m", "index": 4, "title": "hidden", "workspace": 1, "minimized": True}, {"window_id": "c", "index": 2, "title": "other", "workspace": 2}, {"window_id": "popup", "index": 3, "title": "Swap", "workspace": 1, "floating": True}, ] @@ -168,38 +169,24 @@ def test_dock_hides_gpu_without_a_reader_and_colours_storage_by_threshold(self): tui.dock("storage") self.assertEqual(out.getvalue(), " \033[38;2;224;175;104m92%\033[0m\n") - def test_rail_counts_agents_and_reads_live_branch_of_focused_pane(self): - with tempfile.TemporaryDirectory() as repo, tempfile.TemporaryDirectory() as plain: - subprocess.run(["git", "init", "-q", "-b", "live", repo], check=True) - panes = [ - {"session": "work", "focused": False, "cwd": plain, "state": "none", "harness_id": ""}, - {"session": "work", "focused": True, "cwd": repo, "state": "working", "harness_id": "claude-code"}, - {"session": "work", "focused": False, "cwd": repo, "state": "needs_input", "harness_id": "pi"}, - {"session": "idle", "focused": True, "cwd": plain, "state": "none", "harness_id": ""}, - ] - rows = [{"name": "work", "branch": "stale"}, {"name": "idle"}] - listing = subprocess.CompletedProcess([], 0, json.dumps({"agents": panes})) - with patch.object(tui, "sessions", return_value=rows), patch.object(tui, "run", return_value=listing), patch("sys.stdout", new_callable=io.StringIO) as out: - tui.rail() - self.assertEqual(out.getvalue().splitlines(), [ - "idle", - "work 2 \033[33m●\033[0m\033[31m●\033[0m", - " \033[90m└ live\033[0m", - ]) - - def test_rail_marks_working_agent_without_recent_report_as_hang(self): + def test_dock_agents_counts_session_agents_and_marks_hang(self): panes = [ - {"session": "work", "focused": True, "cwd": "", "state": "working", "harness_id": "claude-code", "evidence_age_ms": 120_000}, - {"session": "work", "focused": False, "cwd": "", "state": "working", "harness_id": "pi", "evidence_age_ms": 119_999}, - {"session": "work", "focused": False, "cwd": "", "state": "idle", "harness_id": "pi", "evidence_age_ms": 999_999}, - {"session": "work", "focused": False, "cwd": "", "state": "working", "harness_id": "pi", "evidence_age_ms": None}, + {"state": "working", "harness_id": "claude-code", "evidence_age_ms": 120_000}, + {"state": "working", "harness_id": "pi", "evidence_age_ms": 119_999}, + {"state": "needs_input", "harness_id": "pi", "evidence_age_ms": 999_999}, + {"state": "none", "harness_id": "", "evidence_age_ms": None}, ] listing = subprocess.CompletedProcess([], 0, json.dumps({"agents": panes})) - with patch.object(tui, "sessions", return_value=[{"name": "work"}]), patch.object(tui, "run", return_value=listing), patch("sys.stdout", new_callable=io.StringIO) as out: - tui.rail() - self.assertEqual(out.getvalue().splitlines(), [ - "work 4 \033[35m●\033[0m\033[33m●\033[0m\033[90m●\033[0m\033[33m●\033[0m", - ]) + with patch.dict(os.environ, TUIOS_SESSION="work"), patch.object(tui, "run", return_value=listing) as run, patch("sys.stdout", new_callable=io.StringIO) as out: + tui.dock("agents") + run.assert_called_once_with("tuios", "list-agents", "--all", "-s", "work", "--json", capture_output=True, text=True) + self.assertEqual(out.getvalue(), "󰚩 3 \033[35m●\033[0m\033[33m●\033[0m\033[31m●\033[0m\n") + + def test_dock_agents_hides_cell_without_agents(self): + listing = subprocess.CompletedProcess([], 0, json.dumps({"agents": [{"state": "none", "harness_id": ""}]})) + with patch.dict(os.environ, TUIOS_SESSION="work"), patch.object(tui, "run", return_value=listing), patch("sys.stdout", new_callable=io.StringIO) as out: + tui.dock("agents") + self.assertEqual(out.getvalue(), "") def test_rcon_registers_container_before_creating_the_session(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, XDG_STATE_HOME=temp), patch.object(tui, "switch_session") as switch: diff --git a/scripts/tui b/scripts/tui index 33c0128f..05d9e15e 100755 --- a/scripts/tui +++ b/scripts/tui @@ -169,21 +169,6 @@ def agent_state(pane): return pane["state"] -def rail(): - # Rows for the rail's custom section: agents per session, then the branch - # of its focused pane. `tuios ls` keeps the branch from session creation. - panes = json.loads(run("tuios", "list-agents", "--all", "--all-sessions", "--json", capture_output=True, text=True).stdout)["agents"] - for row in sorted(sessions(), key=lambda row: (row.get("space", ""), row["name"])): - own = sorted((p for p in panes if p["session"] == row["name"]), key=lambda p: not p["focused"]) - states = [agent_state(p) for p in own if p["harness_id"]] - dots = "".join(f"\033[{AGENT_COLORS.get(state, 90)}m●\033[0m" for state in states) - print(f"{row['name']} {len(states)} {dots}" if states else row["name"]) - cwd = next((p["cwd"] for p in own if p["cwd"]), None) - branch = cwd and subprocess.run(["git", "-C", cwd, "branch", "--show-current"], capture_output=True, text=True).stdout.strip() - if branch: - print(f" \033[90m└ {branch}\033[0m") - - def theme(): # SetTheme changes the client's theme for now; the config keeps its theme. names = run("tuios", "--list-themes", capture_output=True, text=True).stdout.split() @@ -199,8 +184,8 @@ def swap(): listing = run("tuios", "list-windows", "-s", os.environ["TUIOS_SESSION"], "--json", capture_output=True, text=True) windows = json.loads(listing.stdout)["windows"] workspace = next(w["workspace"] for w in windows if w["window_id"] == active) - chosen = choose([f"{w['window_id']}\t{w['index'] + 1}: {w['title']}" for w in windows - if w["workspace"] == workspace and not w.get("floating") and w["window_id"] != active], "swap") + chosen = choose([f"{w['window_id']}\t{w['index'] + 1}: {w.get('display_name') or w['title']}" for w in windows + if w["workspace"] == workspace and not w.get("floating") and not w.get("minimized") and w["window_id"] != active], "swap") if chosen: run(os.environ["HERDR_BIN_PATH"], "pane", "swap", "--source-pane", active, "--target-pane", chosen) @@ -231,6 +216,11 @@ def dock(item): elif item == "storage": usage = shutil.disk_usage("/") print(f" {level(round(usage.used * 100 / usage.total), ((95, '#f7768e'), (90, '#e0af68'), (80, '#bb9af7')))}") + elif item == "agents": + panes = json.loads(run("tuios", "list-agents", "--all", "-s", os.environ["TUIOS_SESSION"], "--json", capture_output=True, text=True).stdout)["agents"] + states = [agent_state(p) for p in panes if p["harness_id"]] + if states: + print(f"󰚩 {len(states)} " + "".join(f"\033[{AGENT_COLORS.get(state, 90)}m●\033[0m" for state in states)) elif item == "branch": # dock commandにはfocus中paneのcwdが渡らないため、sessionのpane一覧から引く。 panes = json.loads(run("tuios", "list-agents", "--all", "-s", os.environ["TUIOS_SESSION"], "--json", capture_output=True, text=True).stdout)["agents"] @@ -257,13 +247,13 @@ def dev(): def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command") - for name in ("pick", "space", "root", "dev", "rail", "theme", "swap"): + for name in ("pick", "space", "root", "dev", "theme", "swap"): sub.add_parser(name) nav = sub.add_parser("space-nav") nav.add_argument("direction", choices=("next", "prev")) nav.add_argument("client_pid", type=int) dock_item = sub.add_parser("dock") - dock_item.add_argument("item", choices=("gpu", "storage", "branch")) + dock_item.add_argument("item", choices=("gpu", "storage", "agents", "branch")) rcon_parser = sub.add_parser("rcon") rcon_parser.add_argument("target", nargs="?") attach = sub.add_parser("attach") @@ -286,8 +276,6 @@ def main(): navigate_space(args.direction, args.client_pid) elif args.command == "rcon": rcon(args.target) - elif args.command == "rail": - rail() elif args.command == "theme": theme() elif args.command == "swap":