Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions common/tuios/.config/tuios/config.d/dotfiles.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions docs/tools/tuios.md
Original file line number Diff line number Diff line change
@@ -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は引き続き利用できる。

Expand Down Expand Up @@ -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へ登録、作成、解除 |
Expand Down Expand Up @@ -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表示
Expand Down
47 changes: 17 additions & 30 deletions scripts/test-tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
]
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 9 additions & 21 deletions scripts/tui
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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"]
Expand All @@ -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")
Expand All @@ -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":
Expand Down
Loading