feat(agents): recognize CLI coding agents + git branch in the sidebar (#85)
* feat(agents): recognize CLI coding agents + show git branch in the sidebar Observe (never wrap) third-party coding agents running in a pane — Claude Code, Codex, Gemini CLI, Aider, Amp, OpenCode and ~10 more — and enrich the UI around them, plus front each sidebar row with its git branch and diff. Detection & identity - Command-based detection over the foreground argv (launcher basename, and interpreter-wrapped `node …/cli.js` / `npx …` forms), with user rules via `agent_commands` in config. Brand avatars on the tab chip and sidebar row. Rich status channel - A per-pane state machine (idle / working / waiting-for-you / done) driven by agent-reported events over an OSC 777 sentinel channel (`tty7://cli-agent`, versioned JSON), sniffed daemon-side and streamed to the client (DaemonMsg::AgentStatus). - `tty7 agent-hook claude <event>` + a palette installer wire Claude Code's lifecycle hooks up; the hook writes the sentinel to the controlling tty (with an ancestor-tty fallback for detached hook processes). - Avatar status dot: working (blue) / waiting (amber) / done (green); an unread finished turn gets a crisp outer ring that clears on focus. Notifications, resume, context feed - "Needs your permission…" the moment an agent blocks; "finished after Ns" per turn, honoring the notify policy (rich turns suppress the coarse exit). - Session resume: restored panes re-launch their conversation (`claude --resume …`), gated by `restore_agent_sessions` (default on). - Palette commands send the current selection or the repo `git diff` to the running agent as a ready-made prompt. Sidebar git line - New `terminal::git_status`: off-thread `git` probe (branch, or short sha when detached; `git diff --numstat HEAD` line counts) with GIT_OPTIONAL_LOCKS=0, refreshed on cwd change or command finish, dropped on a stale cwd via a generation tag. - Each row is avatar + title + `⎇ branch +N −M` (green/red), sized to content; the redundant cwd/"Working…" lines and the aggregate rollup are gone — the status dot and branch line carry it. 672 tests pass. * fix(agents): repair CI and refresh the git line when an agent turn ends - The live PTY detection test used `sh -c 'exec -a codex cat'`, but `exec -a` is a bashism dash (Ubuntu's /bin/sh) rejects — spawn bash. - cargo fmt over cli_agent.rs / view.rs / app.rs. - An agent session is one long foreground command, so the back-to-prompt edge never refreshed the sidebar's branch/diff line while the agent worked — exactly when the working tree changes. poll_agent_status now reports a turn ending (transition into Done) and the poll reprobes git on that edge too. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
@@ -66,6 +66,19 @@ Download the build for your platform from
|
||||
- **⌘-click links** · desktop notifications
|
||||
- **Eight themes** · CJK / IME input
|
||||
|
||||
### CLI coding agents
|
||||
|
||||
tty7 recognizes third-party coding agents running in a pane (Claude Code,
|
||||
Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and enriches them — it
|
||||
never wraps or replaces the agent.
|
||||
|
||||
- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json`
|
||||
- **Live status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up
|
||||
- **Notifications that matter** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
|
||||
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes
|
||||
- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default)
|
||||
- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt
|
||||
|
||||
### SSH connection manager
|
||||
|
||||
A native Rust SSH stack (russh) is the **only** path — profiles, credentials,
|
||||
|
||||
@@ -62,6 +62,17 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。
|
||||
- **⌘ 点击打开链接** · 桌面通知
|
||||
- **8 套主题** · CJK / 输入法输入
|
||||
|
||||
### CLI coding agent
|
||||
|
||||
tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、Aider、Amp、OpenCode 等约 17 个)并为其增强体验 —— 只观察、只加分,绝不包裹或替代 agent 本身。
|
||||
|
||||
- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射
|
||||
- **实时状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code
|
||||
- **真正有用的通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
|
||||
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新
|
||||
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …`;`restore_agent_sessions`,默认开启)
|
||||
- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
|
||||
|
||||
### SSH 连接管理器
|
||||
|
||||
**唯一**路径就是原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,绝不 shell 出 `ssh`。没有系统 ssh 兼容模式。
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_122279_13)">
|
||||
<path d="M11.9316 11.666L14.8984 22.7715L12.207 23.4902L10.0684 15.4902L4.18457 21.3477L2.21875 19.373L8.09473 13.5244L0.102539 11.3926L0.821289 8.7002L11.9316 11.666Z" fill="#FF0000"/>
|
||||
<path d="M16.2334 7.35645L19.2002 18.4619L16.5098 19.1807L13.957 9.63184L4.40527 7.08301L5.12305 4.3916L16.2334 7.35645Z" fill="#FF0000"/>
|
||||
<path d="M20.5547 3.04785L23.5215 14.1533L20.8311 14.8721L18.2783 5.32324L8.72656 2.77441L9.44434 0.0820312L20.5547 3.04785Z" fill="#FF0000"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_122279_13">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_122215_47)">
|
||||
<path d="M4.70529 15.9555L9.42272 13.3084L9.50165 13.0777L9.42272 12.9502H9.19201L8.40273 12.9016L5.70706 12.8287L3.36959 12.7316L1.10498 12.6102L0.534278 12.4887L0 11.7845L0.0546421 11.4323L0.534278 11.1106L1.22034 11.1713L2.73817 11.2745L5.01493 11.4323L6.66633 11.5295L9.11308 11.7845H9.50165L9.55629 11.6266L9.42272 11.5295L9.3195 11.4323L6.96383 9.83557L4.41386 8.14774L3.07817 7.17632L2.35568 6.68454L1.9914 6.22312L1.83354 5.21528L2.48925 4.49279L3.36959 4.5535L3.59423 4.61422L4.48672 5.30028L6.39312 6.77561L8.88237 8.60916L9.24665 8.91273L9.39236 8.80951L9.41057 8.73666L9.24665 8.46345L7.89274 6.0167L6.44776 3.52745L5.8042 2.49532L5.6342 1.87604C5.57349 1.62105 5.53099 1.40855 5.53099 1.14748L6.27776 0.133569L6.69062 0L7.68631 0.133569L8.10524 0.49785L8.72451 1.91247L9.72628 4.14065L11.2805 7.17025L11.7359 8.06881L11.9788 8.90058L12.0698 9.15558H12.2277V9.00987L12.3552 7.30382L12.592 5.20921L12.8227 2.51353L12.9016 1.75462L13.278 0.843916L14.0248 0.352138L14.6076 0.631419L15.0873 1.31748L15.0205 1.76069L14.7351 3.61245L14.1766 6.51455L13.8123 8.45737H14.0248L14.2676 8.21452L15.2512 6.90918L16.9026 4.84493L17.6312 4.0253L18.4812 3.12067L19.0276 2.6896H20.0597L20.8186 3.81887L20.4786 4.98457L19.4161 6.33241L18.5358 7.47382L17.273 9.17379L16.4837 10.5338L16.5565 10.6431L16.7448 10.6248L19.5983 10.0177L21.1404 9.73843L22.98 9.42272L23.8118 9.81128L23.9029 10.2059L23.575 11.0134L21.6079 11.4991L19.3008 11.9605L15.8644 12.7741L15.8219 12.8045L15.8705 12.8652L17.4187 13.0109L18.0804 13.0473H19.7015L22.7189 13.2719L23.5082 13.7941L23.9818 14.4316L23.9029 14.9173L22.6886 15.5366L21.0493 15.148L17.2244 14.2373L15.913 13.9094H15.7308V14.0187L16.8237 15.0873L18.8272 16.8965L21.3347 19.2279L21.4622 19.8047L21.1404 20.2601L20.8004 20.2115L18.5965 18.554L17.7465 17.8072L15.8219 16.1862H15.6944V16.3562L16.1376 17.0058L18.4812 20.5272L18.6026 21.6079L18.4326 21.96L17.8255 22.1725L17.1576 22.0511L15.7855 20.1265L14.3709 17.959L13.2294 16.0162L13.0898 16.0951L12.4159 23.3504L12.1002 23.7207L11.3716 24L10.7645 23.5386L10.4427 22.7918L10.7645 21.3165L11.153 19.3919L11.4688 17.8619L11.7541 15.9615L11.9241 15.3301L11.912 15.2876L11.7723 15.3058L10.3395 17.273L8.15988 20.2176L6.43562 22.0632L6.02277 22.2272L5.30635 21.8568L5.37314 21.195L5.77384 20.6061L8.15988 17.5705L9.59879 15.6883L10.5277 14.6016L10.5216 14.4437H10.467L4.12851 18.5601L2.99924 18.7058L2.51353 18.2504L2.57425 17.5037L2.80496 17.2608L4.71136 15.9494L4.70529 15.9555Z" fill="#FF0000"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_122215_47">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_25_3427)">
|
||||
<path d="M22.2819 9.82107C22.5503 9.01295 22.6431 8.15687 22.5542 7.31001C22.4652 6.46315 22.1966 5.64502 21.7662 4.91028C21.1282 3.7997 20.154 2.92043 18.984 2.39923C17.8141 1.87804 16.5088 1.74184 15.2564 2.01028C14.5449 1.21884 13.6377 0.628547 12.6259 0.298687C11.6141 -0.0311717 10.5333 -0.088985 9.49202 0.131054C8.45078 0.351093 7.48577 0.841237 6.69391 1.55226C5.90204 2.26328 5.31121 3.17015 4.98074 4.18178C4.14641 4.35286 3.3582 4.70006 2.6688 5.20017C1.97939 5.70027 1.40467 6.34177 0.983043 7.08178C0.338078 8.1905 0.0624244 9.47561 0.195934 10.7513C0.329444 12.027 0.865199 13.2272 1.72574 14.1784C1.45641 14.9861 1.36267 15.842 1.45079 16.6889C1.53892 17.5358 1.80688 18.3541 2.23674 19.0891C2.87558 20.2001 3.85066 21.0795 5.02145 21.6007C6.19224 22.1219 7.49828 22.2579 8.75134 21.9892C9.31661 22.6257 10.0112 23.1344 10.7887 23.4811C11.5661 23.8279 12.4087 24.0048 13.2599 24C14.5436 24.0012 15.7944 23.5944 16.8318 22.8385C17.8692 22.0825 18.6396 21.0165 19.0317 19.7942C19.866 19.6228 20.6541 19.2755 21.3434 18.7754C22.0328 18.2753 22.6076 17.6339 23.0294 16.8941C23.6668 15.787 23.9377 14.5067 23.8034 13.2363C23.6692 11.966 23.1366 10.7705 22.2819 9.82107ZM13.2599 22.4292C12.2087 22.4308 11.1903 22.0624 10.3835 21.3884L10.5254 21.308L15.3037 18.5498C15.4227 18.48 15.5214 18.3805 15.5903 18.2611C15.6591 18.1416 15.6957 18.0063 15.6964 17.8685V11.1316L17.7164 12.3002C17.7264 12.3052 17.7351 12.3126 17.7417 12.3216C17.7483 12.3307 17.7527 12.3411 17.7544 12.3522V17.9348C17.7519 19.126 17.2776 20.2677 16.4352 21.11C15.5929 21.9523 14.4512 22.4267 13.2599 22.4292ZM3.59924 18.3038C3.07202 17.3934 2.88272 16.3263 3.06464 15.2901L3.20664 15.3753L7.98964 18.1335C8.10799 18.2029 8.24273 18.2395 8.37994 18.2395C8.51716 18.2395 8.6519 18.2029 8.77024 18.1335L14.613 14.765V17.0974C14.6125 17.1095 14.6092 17.1213 14.6035 17.1319C14.5977 17.1426 14.5896 17.1518 14.5798 17.1589L9.74004 19.9502C8.70717 20.5452 7.48039 20.706 6.32905 20.3973C5.17772 20.0886 4.19593 19.3357 3.59924 18.3038ZM2.34084 7.89558C2.87173 6.97934 3.70968 6.2805 4.70634 5.92278V11.6C4.70454 11.7371 4.73959 11.8723 4.80782 11.9912C4.87605 12.1102 4.97496 12.2087 5.09424 12.2765L10.9086 15.6308L8.88854 16.7993C8.87761 16.8051 8.86542 16.8081 8.85304 16.8081C8.84067 16.8081 8.82848 16.8051 8.81754 16.7993L3.98724 14.0128C2.95631 13.4152 2.20419 12.4334 1.89561 11.2825C1.58703 10.1315 1.74712 8.90514 2.34084 7.87198V7.89558ZM18.9371 11.7514L13.1038 8.36398L15.1192 7.19998C15.1302 7.19417 15.1424 7.19113 15.1547 7.19113C15.1671 7.19113 15.1793 7.19417 15.1902 7.19998L20.0205 9.99127C20.7591 10.4174 21.3612 11.0449 21.7565 11.8004C22.1518 12.5559 22.3241 13.4083 22.2531 14.258C22.1822 15.1077 21.871 15.9197 21.3559 16.5992C20.8408 17.2787 20.143 17.7977 19.344 18.0955V12.4183C19.3399 12.2814 19.3001 12.1479 19.2288 12.0309C19.1574 11.914 19.057 11.8177 18.9371 11.7514ZM20.9478 8.72827L20.8058 8.64307L16.0323 5.86128C15.9133 5.7914 15.7777 5.75456 15.6396 5.75456C15.5016 5.75456 15.366 5.7914 15.2469 5.86128L9.40904 9.22967V6.89738C9.4078 6.88552 9.40978 6.87354 9.41478 6.86272C9.41978 6.85189 9.42761 6.84262 9.43744 6.83588L14.2677 4.04928C15.0081 3.62279 15.8545 3.41591 16.7081 3.45283C17.5616 3.48975 18.3871 3.76895 19.0878 4.25776C19.7885 4.74657 20.3356 5.42479 20.665 6.21311C20.9944 7.00142 21.0925 7.86723 20.9479 8.70927L20.9478 8.72827ZM8.30654 12.863L6.28654 11.6992C6.27644 11.6931 6.26779 11.6849 6.26123 11.6751C6.25466 11.6653 6.25033 11.6541 6.24854 11.6425V6.07418C6.24966 5.21995 6.49393 4.38369 6.9528 3.66317C7.41166 2.94266 8.06617 2.36766 8.83978 2.00541C9.6134 1.64317 10.4742 1.50864 11.3214 1.61755C12.1687 1.72647 12.9674 2.07434 13.6242 2.62048L13.4822 2.70098L8.70404 5.45898C8.58512 5.52872 8.48638 5.62821 8.41753 5.74766C8.34868 5.8671 8.31209 6.00241 8.31134 6.14028L8.30654 12.863ZM9.40414 10.4976L12.0061 8.99777L14.613 10.4976V13.497L12.0156 14.9967L9.40894 13.497L9.40414 10.4976Z" fill="#FF0000"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_25_3427">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_731_27445)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M111.688 83.9677C108.245 89.9488 88.2522 104.062 64 104.062C39.7478 104.062 19.7554 89.9488 16.3123 83.9677C16.0605 83.5303 15.9792 83.031 15.9792 82.5263L15.9792 71.8787C15.9792 71.4373 16.0474 70.9986 16.2107 70.5885C17.6996 66.8499 21.599 61.4192 26.6313 59.9621C27.2985 58.2494 28.2867 55.7456 29.2087 53.8974C29.0543 52.4833 29 51.0248 29 49.5512C29 44.2274 30.1285 39.5576 33.5288 36.0801C35.117 34.4559 37.0876 33.21 39.4246 32.2735C45.0217 27.7264 52.992 23.9011 63.9136 23.9011C74.8352 23.9011 82.9783 27.7264 88.5754 32.2735C90.9124 33.21 92.883 34.4559 94.4712 36.0801C97.8715 39.5576 99 44.2274 99 49.5512C99 51.0248 98.9457 52.4833 98.7913 53.8974C99.7133 55.7456 100.701 58.2494 101.369 59.9621C106.401 61.4192 110.3 66.8499 111.789 70.5885C111.953 70.9986 112.021 71.4373 112.021 71.8787L112.021 82.5263C112.021 83.031 111.939 83.5303 111.688 83.9677ZM67.253 48.2606C67.0828 46.9345 67.0018 45.747 66.9995 44.686L66.9995 44.6021C67.0048 41.5242 67.6776 39.5215 68.7524 38.2914C70.1172 36.7295 72.9379 35.5328 78.8829 36.1762C84.9059 36.828 88.2725 38.3229 90.1812 40.2749C92.0291 42.1648 92.9999 44.9922 92.9999 49.5512C92.9999 54.3951 92.3018 57.2568 90.767 58.9977C89.3075 60.6531 86.4335 62 80.1386 62C75.2994 62 72.5326 60.4261 70.7637 58.2493C68.8644 55.912 67.7955 52.4878 67.253 48.2606ZM60.747 48.2606C60.9172 46.9344 60.9982 45.747 61.0005 44.686L61.0005 44.6021C60.9952 41.5242 60.3224 39.5214 59.2476 38.2914C57.8828 36.7295 55.0621 35.5328 49.1171 36.1762C43.0941 36.828 39.7275 38.3229 37.8188 40.2749C35.9709 42.1647 35.0001 44.9922 35.0001 49.5512C35.0001 54.3951 35.6982 57.2568 37.233 58.9977C38.6925 60.6531 41.5665 62 47.8614 62C52.7006 62 55.4674 60.4261 57.2363 58.2493C59.1356 55.9119 60.2045 52.4878 60.747 48.2606ZM64.6889 59.9983C64.4592 59.9995 64.9185 59.9983 64.6889 59.9983C64.4594 59.9983 63.5408 59.9995 63.3111 59.9983C62.8877 60.7075 62.4168 61.3882 61.8926 62.0332C58.8139 65.8219 54.2182 68 47.8613 68C40.9615 68 35.9049 66.564 32.7324 62.9657C32.552 62.7611 32.3906 62.5469 32.3906 62.5469L32 62.9657L32 89.3023C37.7392 92.4213 50.0576 98.0184 64 98.0184C77.9424 98.0184 90.2608 92.4213 96 89.3023L96 62.9657L95.6094 62.5469C95.6094 62.5469 95.4772 62.728 95.2676 62.9657C92.0951 66.564 87.0385 68 80.1387 68C73.7818 68 69.1861 65.8219 66.1074 62.0332C65.5832 61.3882 65.1123 60.7075 64.6889 59.9983Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M74 73C76.2091 73 78 74.7909 78 77L78 85C78 87.2092 76.2091 89 74 89C71.7909 89 70 87.2092 70 85L70 77C70 74.7909 71.7909 73 74 73Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M54 73C56.2091 73 58 74.7909 58 77L58 85C58 87.2092 56.2091 89 54 89C51.7909 89 50 87.2092 50 85L50 77C50 74.7909 51.7909 73 54 73Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_731_27445">
|
||||
<rect width="96" height="96" fill="white" transform="translate(16 16)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 466.73 532.09" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" fill="#FF0000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.6435 22.6696V24.0261H4.40873V22.6696H19.6435ZM22.6696 19.6435V4.40873C22.6696 2.73747 21.3148 1.38265 19.6435 1.38264H4.40873C2.73747 1.38264 1.38265 2.73747 1.38264 4.40873V19.6435C1.38264 21.3148 2.73747 22.6696 4.40873 22.6696V24.0261C1.98828 24.0261 0.026123 22.064 0.026123 19.6435V4.40873C0.0261234 2.0072 1.95774 0.0568147 4.35207 0.0264797L4.40873 0.026123H19.6435L19.7002 0.0264797C22.0945 0.0568151 24.0261 2.0072 24.0261 4.40873V19.6435L24.0258 19.7002C23.9954 22.0945 22.045 24.0261 19.6435 24.0261V22.6696C21.3148 22.6696 22.6696 21.3148 22.6696 19.6435Z" fill="#FF0000"/>
|
||||
<path d="M16.8522 10.5131L7.25221 5.89569V8.60873L14.9739 12.3392L7.25221 16.0435V18.8348L16.8522 14.1913V10.5131Z" fill="#FF0000"/>
|
||||
<path d="M19.6435 22.6696V24.0261M19.6435 22.6696H4.40873M19.6435 22.6696C21.3148 22.6696 22.6696 21.3148 22.6696 19.6435V4.40873C22.6696 2.73747 21.3148 1.38265 19.6435 1.38264H4.40873C2.73747 1.38264 1.38265 2.73747 1.38264 4.40873V19.6435C1.38264 21.3148 2.73747 22.6696 4.40873 22.6696M19.6435 24.0261H4.40873M19.6435 24.0261C22.045 24.0261 23.9954 22.0945 24.0258 19.7002L24.0261 19.6435V4.40873C24.0261 2.0072 22.0945 0.0568151 19.7002 0.0264797L19.6435 0.026123H4.40873L4.35207 0.0264797C1.95774 0.0568147 0.0261234 2.0072 0.026123 4.40873V19.6435C0.026123 22.064 1.98828 24.0261 4.40873 24.0261M4.40873 24.0261V22.6696M7.25221 5.89569L16.8522 10.5131V14.1913L7.25221 18.8348V16.0435L14.9739 12.3392L7.25221 8.60873V5.89569Z" stroke="black" stroke-width="0.0521739"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 45 45" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_goose)">
|
||||
<path d="M43.2098 39.2492L39.8118 36.4522C37.9646 34.9318 36.3923 33.1051 35.1631 31.0529C33.4647 28.2172 31.1178 25.8243 28.3164 24.071L26.9482 23.2744C26.4794 22.9485 26.1525 22.4405 26.106 21.8675C26.0762 21.4981 26.1647 21.1678 26.3712 20.8771C27.0837 19.873 30.9617 15.6995 31.6365 15.1411C32.5056 14.4228 33.4739 13.8253 34.3724 13.1412C34.5001 13.0438 34.6283 12.9469 34.7545 12.8481C34.7589 12.8437 34.7653 12.8403 34.7692 12.8363C35.0574 12.6088 35.3295 12.3671 35.5458 12.0803C36.5993 10.8604 36.5783 9.82938 36.5783 9.82938C36.5783 9.82938 36.5793 9.8284 36.5798 9.82791C36.4677 9.5534 36.0645 8.69902 35.2449 8.29825C35.9696 8.28406 36.7872 8.55564 37.1582 8.91873C37.6039 8.21947 37.8922 7.76585 38.3404 7.00347C38.4471 6.82241 38.5038 6.48771 38.2998 6.30029C38.2998 6.30029 38.2988 6.30029 38.2978 6.30029C38.2978 6.30029 38.2978 6.29931 38.2978 6.29833C38.2939 6.29442 38.289 6.29148 38.2851 6.28757C38.2812 6.28365 38.2787 6.27925 38.2743 6.27485C38.2743 6.27485 38.2734 6.27485 38.2724 6.27485C38.2724 6.27485 38.2724 6.27387 38.2724 6.27289C38.2641 6.26457 38.2548 6.25821 38.246 6.25038C38.2406 6.245 38.2362 6.23864 38.2313 6.23276C38.2254 6.22787 38.219 6.22347 38.2137 6.21808C38.2053 6.20977 38.2 6.19949 38.1911 6.19166C38.1911 6.19166 38.1897 6.19117 38.1892 6.19166C38.1892 6.19166 38.1892 6.19068 38.1892 6.1897C38.1853 6.18579 38.1804 6.18285 38.1765 6.17894C38.1726 6.17502 38.1696 6.17013 38.1657 6.16621C38.1657 6.16621 38.1642 6.16572 38.1637 6.16621C38.1637 6.16621 38.1637 6.16524 38.1637 6.16426C37.9763 5.9602 37.6416 6.01697 37.4606 6.12364C36.4692 6.70693 35.5047 7.33475 34.6351 7.88525C34.6351 7.88525 33.6046 7.86372 32.3842 8.91775C32.097 9.13452 31.8552 9.4066 31.6282 9.69433C31.6238 9.69873 31.6199 9.7046 31.6164 9.70901C31.5171 9.83476 31.4202 9.96297 31.3233 10.0912C30.6387 10.9901 30.0417 11.958 29.3234 12.8271C28.7656 13.5023 24.5915 17.3798 23.5874 18.0923C23.2967 18.2988 22.9669 18.3879 22.597 18.3575C22.0245 18.3115 21.5161 17.9842 21.1902 17.5154L20.3935 16.1472C18.6402 13.3448 16.2474 10.9989 13.4117 9.30041C11.3594 8.0712 9.5332 6.49847 8.01234 4.65172L5.2153 1.25377C5.0773 1.08642 4.81306 1.11382 4.71422 1.30662C4.39615 1.92612 3.78888 3.23411 3.32353 4.99327C3.31227 5.03487 3.32206 5.07891 3.34946 5.11218C3.92786 5.81095 5.26717 7.35089 6.8624 8.65351C6.97348 8.74403 6.88246 8.92313 6.74447 8.88496C5.36846 8.51013 4.03062 7.90825 3.02944 7.39542C2.94772 7.35383 2.84887 7.4057 2.83713 7.49672C2.68152 8.74354 2.64139 10.1127 2.80581 11.5562C2.8107 11.6032 2.84055 11.6438 2.8841 11.6629C4.02572 12.1596 5.85143 12.8804 7.76816 13.3389C7.90713 13.3722 7.90273 13.5723 7.76229 13.5992C6.26982 13.8801 4.70688 13.9427 3.43656 13.9261C3.3475 13.9251 3.28193 14.0093 3.30591 14.0949C3.56085 14.9958 3.90632 15.9118 4.3619 16.8342C4.55029 17.2487 4.75532 17.6553 4.97504 18.0532C5.01271 18.1212 5.08562 18.1618 5.16343 18.1584C6.21207 18.107 7.47946 17.9964 8.75369 17.7713C8.97095 17.7331 9.08741 18.0189 8.90391 18.1417C8.03877 18.7201 7.11588 19.2183 6.25758 19.6283C6.14259 19.6836 6.1054 19.8304 6.18125 19.9327C6.69114 20.6236 7.25191 21.2774 7.86114 21.8866C7.86114 21.8866 10.8608 24.9763 10.9425 25.2185C12.6928 23.4261 15.5701 21.4394 18.7474 19.7047C14.4921 23.1672 12.1198 25.7235 10.9258 27.1758L10.0935 28.3439C9.66091 28.9507 9.2856 29.5951 8.97193 30.2709C7.92231 32.5292 6.19201 37.0966 6.19201 37.0966C6.0594 37.4543 6.16755 37.8072 6.39753 38.0371C6.40292 38.0425 6.4083 38.0479 6.41417 38.0528C6.41955 38.0582 6.42445 38.0641 6.42983 38.0694C6.66031 38.2999 7.01263 38.4076 7.37033 38.275C7.37033 38.275 11.9368 36.5447 14.1961 35.495C14.8719 35.1814 15.5168 34.8056 16.1231 34.3735L17.4095 33.457C18.0907 32.9715 19.0234 33.0489 19.615 33.6405L22.5804 36.6058C23.1896 37.2151 23.8433 37.7758 24.5343 38.2857C24.637 38.3611 24.7833 38.3244 24.8386 38.2094C25.2492 37.3516 25.7473 36.4277 26.3252 35.5631C26.4481 35.3796 26.7343 35.4965 26.6957 35.7133C26.4701 36.988 26.3605 38.2554 26.3086 39.3035C26.3047 39.3809 26.3458 39.4543 26.4138 39.4919C26.8112 39.7121 27.2178 39.9172 27.6327 40.1051C28.5551 40.5606 29.4717 40.9066 30.3721 41.1611C30.4577 41.185 30.5419 41.1195 30.5409 41.0304C30.5237 39.7596 30.5864 38.1967 30.8677 36.7047C30.8942 36.5638 31.0943 36.5593 31.1281 36.6988C31.5861 38.616 32.3069 40.4417 32.8041 41.5829C32.8231 41.6264 32.8633 41.6558 32.9107 41.6612C34.3543 41.8256 35.7234 41.7855 36.9703 41.6298C37.0618 41.6186 37.1136 41.5197 37.0715 41.4375C36.5587 40.4364 35.9568 39.0975 35.582 37.7225C35.5443 37.584 35.7229 37.4935 35.8135 37.6046C37.1161 39.1998 38.656 40.5391 39.3548 41.1175C39.3881 41.1449 39.4326 41.1542 39.4737 41.1434C41.2333 40.6786 42.5413 40.0713 43.1604 39.7528C43.3532 39.6539 43.3806 39.3897 43.2132 39.2517L43.2098 39.2492Z" fill="#FF0000"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_goose">
|
||||
<rect width="45" height="45" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.4 4.8H6.8V19.2H16.4V4.8ZM21.2 24H2V0H21.2V24Z" fill="#FF0000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 180 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,420 @@
|
||||
//! Agent-side hook integration: the emitter behind `tty7 agent-hook …` and the
|
||||
//! installer that wires it into Claude Code's `settings.json`.
|
||||
//!
|
||||
//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent
|
||||
//! itself to say what it's doing. For Claude Code that is its hooks system:
|
||||
//! each lifecycle hook runs `tty7 agent-hook claude <event>`, which reads the
|
||||
//! hook's JSON payload from stdin and writes one sentinel OSC 777 sequence to
|
||||
//! the controlling terminal (`/dev/tty`) — where tty7's daemon-side sniffer
|
||||
//! picks it up and folds it into the pane's session state. The same idea can
|
||||
//! ship as a Claude *plugin*; a hook + our own binary needs no plugin
|
||||
//! marketplace and no jq.
|
||||
//!
|
||||
//! Emission is gated on the `TTY7` environment variable (injected into every
|
||||
//! shell tty7 spawns), so hooks installed globally stay silent when Claude
|
||||
//! runs in another terminal.
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::cli_agent::AGENT_EVENT_SENTINEL;
|
||||
|
||||
/// The env var tty7 sets in every spawned shell; the hook emitter refuses to
|
||||
/// write escape sequences into terminals that aren't tty7.
|
||||
pub const TTY7_ENV_MARKER: &str = "TTY7";
|
||||
|
||||
/// Cap on how much hook stdin we'll read: real payloads are a few hundred
|
||||
/// bytes of JSON; anything huge is not for us.
|
||||
const MAX_STDIN: u64 = 64 * 1024;
|
||||
|
||||
/// Entry point for the `tty7 agent-hook <agent> <event>` subcommand: read the
|
||||
/// hook's JSON payload from stdin, build the sentinel event, and write it to
|
||||
/// the controlling terminal. Always exits quietly — a hook that fails must
|
||||
/// never break the agent's own flow (Claude Code surfaces nonzero exits).
|
||||
pub fn run_agent_hook(agent: &str, event: &str) {
|
||||
// Not inside tty7 (or a remote shell): stay silent, so globally-installed
|
||||
// hooks don't leak escape sequences into other terminals.
|
||||
if std::env::var_os(TTY7_ENV_MARKER).is_none() {
|
||||
return;
|
||||
}
|
||||
// Hook payload: Claude Code writes {"session_id": …, "message": …, …} and
|
||||
// closes stdin. Absent/malformed input still emits the bare event — the
|
||||
// state machine works without ids or messages.
|
||||
let mut input = String::new();
|
||||
let _ = std::io::stdin().take(MAX_STDIN).read_to_string(&mut input);
|
||||
write_to_controlling_tty(&build_hook_sequence(agent, event, &input));
|
||||
}
|
||||
|
||||
/// Build the sentinel OSC sequence for one hook invocation — the pure core of
|
||||
/// [`run_agent_hook`], separated so the wire bytes are testable without a PTY.
|
||||
/// Round-trips through [`crate::core::cli_agent::parse_agent_event`] on the
|
||||
/// daemon side.
|
||||
fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(stdin_json).unwrap_or(serde_json::json!({}));
|
||||
let mut body = serde_json::json!({
|
||||
"v": 1,
|
||||
"agent": agent,
|
||||
"event": event,
|
||||
});
|
||||
for key in ["session_id", "message"] {
|
||||
if let Some(v) = payload
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
body[key] = serde_json::Value::String(v.to_string());
|
||||
}
|
||||
}
|
||||
format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes()
|
||||
}
|
||||
|
||||
/// Write raw bytes to the pane's PTY so the daemon's sniffer reads them as pane
|
||||
/// output. Two routes, because agents run hooks differently:
|
||||
///
|
||||
/// 1. `/dev/tty` — the hook's own controlling terminal. Works when the agent
|
||||
/// runs the hook attached to its tty.
|
||||
/// 2. An ancestor's tty device — Claude Code runs hooks *detached* from the
|
||||
/// controlling terminal (they have no `/dev/tty`), but the agent process
|
||||
/// itself still owns the pane's PTY slave. So walk up the parent chain to
|
||||
/// the nearest process that has a real tty (that's the agent) and write its
|
||||
/// device (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux) directly. Writing
|
||||
/// the slave sends output to the master, exactly like `/dev/tty` would.
|
||||
#[cfg(unix)]
|
||||
fn write_to_controlling_tty(bytes: &[u8]) -> bool {
|
||||
if write_dev(std::path::Path::new("/dev/tty"), bytes) {
|
||||
return true;
|
||||
}
|
||||
if let Some(dev) = ancestor_tty_device() {
|
||||
return write_dev(&dev, bytes);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_dev(path: &std::path::Path, bytes: &[u8]) -> bool {
|
||||
use std::io::Write as _;
|
||||
match std::fs::OpenOptions::new().write(true).open(path) {
|
||||
Ok(mut tty) => tty.write_all(bytes).and_then(|_| tty.flush()).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The controlling-tty device of the nearest ancestor that has one — the agent
|
||||
/// process, when it ran us detached. Walks the parent chain via `ps` (the hook
|
||||
/// runs at most a few times per turn, so the process spawn is negligible and
|
||||
/// beats platform-specific sysctl/`/proc` FFI here).
|
||||
#[cfg(unix)]
|
||||
fn ancestor_tty_device() -> Option<std::path::PathBuf> {
|
||||
use std::process::Command;
|
||||
// SAFETY: getppid is always safe and never fails.
|
||||
let mut pid = unsafe { libc::getppid() };
|
||||
for _ in 0..8 {
|
||||
if pid <= 1 {
|
||||
break;
|
||||
}
|
||||
// `tty=` prints the terminal (`ttys004`, `pts/3`, or `??`/empty for none)
|
||||
// and `ppid=` the parent, both header-less so parsing is trivial.
|
||||
let out = Command::new("ps")
|
||||
.args(["-o", "tty=", "-o", "ppid=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
let line = String::from_utf8_lossy(&out.stdout);
|
||||
let mut fields = line.split_whitespace();
|
||||
let tty = fields.next().unwrap_or("");
|
||||
let ppid: i32 = fields.next().and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
if !tty.is_empty() && tty != "??" && tty != "?" {
|
||||
return Some(std::path::PathBuf::from(format!("/dev/{tty}")));
|
||||
}
|
||||
pid = ppid;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_to_controlling_tty(_bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude Code installer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Claude Code hook events we subscribe to, and the sentinel event each
|
||||
/// maps onto. `Notification` covers both "needs permission" and "waiting for
|
||||
/// input" — exactly the Waiting state.
|
||||
const CLAUDE_HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
("SessionStart", "session-start"),
|
||||
("UserPromptSubmit", "prompt-submit"),
|
||||
("Notification", "notification"),
|
||||
("Stop", "stop"),
|
||||
("SessionEnd", "session-end"),
|
||||
];
|
||||
|
||||
/// Substring that identifies a hook entry as ours, for idempotent
|
||||
/// install/upgrade (an entry containing it is replaced, never duplicated).
|
||||
const HOOK_MARKER: &str = "agent-hook claude";
|
||||
|
||||
/// Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`,
|
||||
/// defaulting to `~/.claude/settings.json`.
|
||||
fn claude_settings_path() -> Option<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
return Some(PathBuf::from(dir).join("settings.json"));
|
||||
}
|
||||
Some(home_dir()?.join(".claude").join("settings.json"))
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::env::var_os("USERPROFILE").map(PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hook command line written into Claude's settings — this binary, by
|
||||
/// absolute path, so it works regardless of PATH. Quoted because macOS app
|
||||
/// paths ("/Applications/…") can carry spaces.
|
||||
fn hook_command(event: &str) -> Option<String> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
Some(format!("\"{}\" agent-hook claude {event}", exe.display()))
|
||||
}
|
||||
|
||||
/// Install (or upgrade) the tty7 hooks in Claude Code's `settings.json`,
|
||||
/// preserving everything else in the file. Idempotent: entries carrying
|
||||
/// [`HOOK_MARKER`] are rewritten in place (e.g. after the binary moved);
|
||||
/// user-defined hooks on the same events are left untouched. Returns a short
|
||||
/// human-readable summary for the caller to toast.
|
||||
pub fn install_claude_hooks() -> anyhow::Result<String> {
|
||||
let path =
|
||||
claude_settings_path().ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?;
|
||||
|
||||
let mut root: serde_json::Value = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"{} is not valid JSON ({e}); not touching it",
|
||||
path.display()
|
||||
)
|
||||
})?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
|
||||
Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())),
|
||||
};
|
||||
if !root.is_object() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is not a JSON object; not touching it",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let hooks = root
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("hooks")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
if !hooks.is_object() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"\"hooks\" in {} is not an object; not touching it",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
for (claude_event, tty7_event) in CLAUDE_HOOK_EVENTS {
|
||||
let command = hook_command(tty7_event)
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
|
||||
let entries = hooks
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry(claude_event)
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
let Some(list) = entries.as_array_mut() else {
|
||||
continue; // malformed user config on this event; leave it alone
|
||||
};
|
||||
// Drop any previous tty7 entry (stale exe path), then append ours.
|
||||
list.retain(|matcher| !matcher_contains_marker(matcher));
|
||||
list.push(serde_json::json!({
|
||||
"hooks": [{ "type": "command", "command": command }]
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
crate::core::config::write_atomic(&path, serde_json::to_string_pretty(&root)?.as_bytes())?;
|
||||
Ok(format!(
|
||||
"Claude Code hooks installed in {} — restart running claude sessions to pick them up",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether the tty7 hooks are already present in Claude Code's settings (all
|
||||
/// five events carry a marker entry).
|
||||
pub fn claude_hooks_installed() -> bool {
|
||||
let Some(path) = claude_settings_path() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(root) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
return false;
|
||||
};
|
||||
CLAUDE_HOOK_EVENTS.iter().all(|(claude_event, _)| {
|
||||
root.get("hooks")
|
||||
.and_then(|h| h.get(claude_event))
|
||||
.and_then(|e| e.as_array())
|
||||
.is_some_and(|list| list.iter().any(matcher_contains_marker))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether one matcher entry (`{"matcher": …, "hooks": [{"command": …}]}`)
|
||||
/// carries a tty7 hook command.
|
||||
fn matcher_contains_marker(matcher: &serde_json::Value) -> bool {
|
||||
matcher
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_array())
|
||||
.is_some_and(|hooks| {
|
||||
hooks.iter().any(|h| {
|
||||
h.get("command")
|
||||
.and_then(|c| c.as_str())
|
||||
.is_some_and(|c| c.contains(HOOK_MARKER))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The emitter's bytes must parse back into the exact event the daemon's
|
||||
/// sniffer expects — the two ends of the protocol locked together.
|
||||
#[test]
|
||||
fn hook_sequence_round_trips_through_the_daemon_parser() {
|
||||
use crate::core::cli_agent::{AgentEventKind, CLIAgent, parse_agent_event};
|
||||
|
||||
let seq = build_hook_sequence(
|
||||
"claude",
|
||||
"notification",
|
||||
r#"{"session_id":"abc-123","message":"Claude needs your permission","cwd":"/w"}"#,
|
||||
);
|
||||
// Strip the OSC framing (`ESC ]` … `BEL`) to get the payload the
|
||||
// tokenizer would deliver.
|
||||
let payload = &seq[2..seq.len() - 1];
|
||||
let ev = parse_agent_event(payload).expect("daemon parses the emitted event");
|
||||
assert_eq!(ev.agent, Some(CLIAgent::Claude));
|
||||
assert_eq!(ev.kind, AgentEventKind::Notification);
|
||||
assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
|
||||
assert!(ev.message.as_deref().unwrap().contains("permission"));
|
||||
|
||||
// Garbage stdin still yields a well-formed bare event.
|
||||
let seq = build_hook_sequence("claude", "stop", "not json at all");
|
||||
let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses");
|
||||
assert_eq!(ev.kind, AgentEventKind::Stop);
|
||||
assert_eq!(ev.session_id, None);
|
||||
}
|
||||
|
||||
/// The controlling-tty fallback (`ancestor_tty_device`) is what makes the
|
||||
/// hook work at all: Claude Code runs hooks detached from the controlling
|
||||
/// terminal, so `/dev/tty` fails and we must reach the agent's tty via the
|
||||
/// parent chain (verified end-to-end against a real detached-hook PTY
|
||||
/// setup). The device path itself is environment-dependent, so this guards
|
||||
/// only the invariant that survives CI: the `ps`-walk never panics and only
|
||||
/// ever yields a `/dev/…` device (never a bare tty name we'd fail to open).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ancestor_tty_device_is_none_or_a_dev_path() {
|
||||
match ancestor_tty_device() {
|
||||
None => {}
|
||||
Some(dev) => assert!(
|
||||
dev.starts_with("/dev/"),
|
||||
"a resolved tty must be an openable device path, got {dev:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_detection_matches_our_entries_only() {
|
||||
let ours = serde_json::json!({
|
||||
"hooks": [{ "type": "command", "command": "\"/x/tty7\" agent-hook claude stop" }]
|
||||
});
|
||||
assert!(matcher_contains_marker(&ours));
|
||||
let theirs = serde_json::json!({
|
||||
"hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }]
|
||||
});
|
||||
assert!(!matcher_contains_marker(&theirs));
|
||||
assert!(!matcher_contains_marker(&serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_command_quotes_the_exe_path() {
|
||||
let cmd = hook_command("stop").expect("current_exe resolves in tests");
|
||||
assert!(cmd.starts_with('"'));
|
||||
assert!(cmd.ends_with("agent-hook claude stop"));
|
||||
}
|
||||
|
||||
/// Full install → verify → re-install round trip against a scratch
|
||||
/// settings file (`CLAUDE_CONFIG_DIR` is honored, so the test never
|
||||
/// touches the real `~/.claude`). Serialized with the env-var lock other
|
||||
/// env-mutating tests use… none exists for this var, so the test sets it
|
||||
/// once and relies on `cargo test` threads not racing the same var (only
|
||||
/// this test touches CLAUDE_CONFIG_DIR).
|
||||
#[test]
|
||||
fn install_is_idempotent_and_preserves_user_hooks() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let settings = dir.join("settings.json");
|
||||
// Pre-existing user config: a model pick and their own Stop hook.
|
||||
std::fs::write(
|
||||
&settings,
|
||||
serde_json::json!({
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"Stop": [{ "hooks": [{ "type": "command", "command": "afplay ding.aiff" }] }]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
// SAFETY: test-only env mutation; no other test reads this var.
|
||||
unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) };
|
||||
|
||||
assert!(!claude_hooks_installed());
|
||||
install_claude_hooks().expect("install succeeds");
|
||||
assert!(claude_hooks_installed());
|
||||
|
||||
// Install again: no duplicates.
|
||||
install_claude_hooks().expect("re-install succeeds");
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
// User settings and user hooks survive.
|
||||
assert_eq!(root["model"], "opus");
|
||||
let stop = root["hooks"]["Stop"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
stop.iter().filter(|m| matcher_contains_marker(m)).count(),
|
||||
1,
|
||||
"exactly one tty7 entry after two installs"
|
||||
);
|
||||
assert!(
|
||||
stop.iter()
|
||||
.any(|m| m.to_string().contains("afplay ding.aiff")),
|
||||
"the user's own Stop hook survives"
|
||||
);
|
||||
// All five events are wired.
|
||||
for (event, _) in CLAUDE_HOOK_EVENTS {
|
||||
assert!(
|
||||
root["hooks"][event]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(matcher_contains_marker),
|
||||
"{event} carries the tty7 hook"
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: restore for any later test relying on the default path.
|
||||
unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Prompt builders that feed terminal context *back into* a running CLI coding
|
||||
//! agent — the review-prompt / selection-range-prompt idea, sized to tty7:
|
||||
//! take what the user is looking at (a selection in some
|
||||
//! pane, the repo's `git diff`) and phrase it as one self-contained prompt to
|
||||
//! paste into the agent's PTY. Pure string builders, unit-tested; the UI layer
|
||||
//! owns finding the agent pane and writing the bytes.
|
||||
|
||||
/// Cap on embedded context (selection or diff) so a pathological selection or
|
||||
/// a giant diff can't flood the agent's input buffer. Anything longer is
|
||||
/// truncated with an explicit note — the agent can always ask for more.
|
||||
const MAX_CONTEXT_BYTES: usize = 24 * 1024;
|
||||
|
||||
/// Truncate `text` to [`MAX_CONTEXT_BYTES`] on a char boundary, appending a
|
||||
/// note when anything was cut.
|
||||
fn capped(text: &str) -> String {
|
||||
if text.len() <= MAX_CONTEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut end = MAX_CONTEXT_BYTES;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!(
|
||||
"{}\n[… truncated by tty7 — ask for the rest if needed]",
|
||||
&text[..end]
|
||||
)
|
||||
}
|
||||
|
||||
/// A prompt asking the agent to look at terminal output the user selected
|
||||
/// (a build error, a stack trace, a failing test). `cwd` locates the context.
|
||||
pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let selection = selection.trim_end();
|
||||
if selection.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut prompt = String::from(
|
||||
"Here is terminal output I selected in another pane; please take a look and help me address it.",
|
||||
);
|
||||
if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
|
||||
prompt.push_str(&format!(" It came from a shell running in `{cwd}`."));
|
||||
}
|
||||
prompt.push_str("\n\n```\n");
|
||||
prompt.push_str(&capped(selection));
|
||||
prompt.push_str("\n```");
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
/// A prompt asking the agent to review the working tree's diff. `diff` is the
|
||||
/// combined `git diff` (+ `git diff --cached`) output, embedded so the agent
|
||||
/// needn't re-run it; an empty diff yields `None` (nothing to review).
|
||||
pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let diff = diff.trim_end();
|
||||
if diff.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut prompt = String::from(
|
||||
"Please review the following uncommitted changes in this repository: point out bugs, \
|
||||
regressions, and anything that looks unintended. Keep it focused — this is a working diff, \
|
||||
not a style pass.",
|
||||
);
|
||||
if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
|
||||
prompt.push_str(&format!(" Repository: `{cwd}`."));
|
||||
}
|
||||
prompt.push_str("\n\n```diff\n");
|
||||
prompt.push_str(&capped(diff));
|
||||
prompt.push_str("\n```");
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
/// The bytes that deliver `prompt` into an agent's PTY: a bracketed paste (so
|
||||
/// multi-line prompts insert as one block instead of submitting line by line —
|
||||
/// every recognized agent's TUI enables bracketed paste), followed by CR to
|
||||
/// submit. ESC bytes inside the prompt are stripped, same as the clipboard
|
||||
/// paste path, so embedded content can't fake the paste terminator.
|
||||
pub fn submit_bytes(prompt: &str) -> Vec<u8> {
|
||||
let mut bytes = b"\x1b[200~".to_vec();
|
||||
bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
|
||||
bytes.extend_from_slice(b"\x1b[201~\r");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selection_prompt_embeds_the_text_and_cwd() {
|
||||
let p = build_selection_prompt("error[E0308]: mismatched types", Some("/work/tty7"))
|
||||
.expect("non-empty selection builds");
|
||||
assert!(p.contains("error[E0308]"));
|
||||
assert!(p.contains("/work/tty7"));
|
||||
assert!(p.contains("```"));
|
||||
// Empty / whitespace selections build nothing.
|
||||
assert_eq!(build_selection_prompt(" \n", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_prompt_embeds_the_diff() {
|
||||
let p = build_diff_review_prompt("--- a/x\n+++ b/x\n+added", None).unwrap();
|
||||
assert!(p.contains("```diff"));
|
||||
assert!(p.contains("+added"));
|
||||
assert_eq!(build_diff_review_prompt("", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_context_is_truncated_with_a_note() {
|
||||
let big = "x".repeat(MAX_CONTEXT_BYTES + 100);
|
||||
let p = build_selection_prompt(&big, None).unwrap();
|
||||
assert!(p.len() < big.len() + 500);
|
||||
assert!(p.contains("truncated by tty7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_bytes_bracket_and_sanitize() {
|
||||
let bytes = submit_bytes("fix this\nplease");
|
||||
assert!(bytes.starts_with(b"\x1b[200~"));
|
||||
assert!(bytes.ends_with(b"\x1b[201~\r"));
|
||||
// Embedded ESC can't terminate the paste early.
|
||||
let sneaky = submit_bytes("a\x1b[201~; rm -rf /\nb");
|
||||
let inner = &sneaky[6..sneaky.len() - 7];
|
||||
assert!(!inner.contains(&0x1b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
//! Third-party CLI coding-agent registry + detection.
|
||||
//!
|
||||
//! tty7 recognizes when a pane is running someone else's coding agent (Claude
|
||||
//! Code, Codex, Gemini CLI, …) so the tab chip can brand it and desktop
|
||||
//! notifications can say *which* agent finished or needs you. This is
|
||||
//! deliberately *not* tty7's own agent: it only observes and enriches whatever
|
||||
//! agent the user launched.
|
||||
//!
|
||||
//! Detection is command-based: the daemon already
|
||||
//! reads the foreground process's `argv` for SSH-context sniffing, so we reuse
|
||||
//! that to match the invoked command against a known agent. Matching is a pure
|
||||
//! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core`
|
||||
//! (framework-light, unit-tested) and called daemon-side, with the resulting
|
||||
//! `Option<CLIAgent>` streamed to the client for the UI.
|
||||
//!
|
||||
//! The enum is serialized across the daemon↔client protocol, so its variants
|
||||
//! are the wire contract; add new agents at the end.
|
||||
//!
|
||||
//! Beyond identity, this module also defines the *rich status* layer (a
|
||||
//! second detection tier): agents whose hooks/plugins emit tty7's OSC 777
|
||||
//! sentinel events ([`AGENT_EVENT_SENTINEL`]) get a per-session state machine
|
||||
//! ([`AgentSessionState`]: idle / working / waiting-for-you / done) plus the
|
||||
//! native session id used for resume-after-restart. Everything here is pure
|
||||
//! and unit-tested; the daemon sniffs the events and streams state changes to
|
||||
//! the client.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A recognized third-party CLI coding agent. Ordering is the wire contract
|
||||
/// (serialized in [`crate::daemon::protocol`]); append, never reorder.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CLIAgent {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
Aider,
|
||||
Amp,
|
||||
OpenCode,
|
||||
Copilot,
|
||||
Cursor,
|
||||
Goose,
|
||||
Droid,
|
||||
Pi,
|
||||
Auggie,
|
||||
Hermes,
|
||||
Vibe,
|
||||
Antigravity,
|
||||
Grok,
|
||||
Qwen,
|
||||
}
|
||||
|
||||
impl CLIAgent {
|
||||
/// Every known agent, for iteration in detection and tests.
|
||||
pub const ALL: [CLIAgent; 17] = [
|
||||
CLIAgent::Claude,
|
||||
CLIAgent::Codex,
|
||||
CLIAgent::Gemini,
|
||||
CLIAgent::Aider,
|
||||
CLIAgent::Amp,
|
||||
CLIAgent::OpenCode,
|
||||
CLIAgent::Copilot,
|
||||
CLIAgent::Cursor,
|
||||
CLIAgent::Goose,
|
||||
CLIAgent::Droid,
|
||||
CLIAgent::Pi,
|
||||
CLIAgent::Auggie,
|
||||
CLIAgent::Hermes,
|
||||
CLIAgent::Vibe,
|
||||
CLIAgent::Antigravity,
|
||||
CLIAgent::Grok,
|
||||
CLIAgent::Qwen,
|
||||
];
|
||||
|
||||
/// The command names that identify this agent — the launcher binary plus any
|
||||
/// npm/pip package-dir aliases that show up in an interpreter-wrapped `argv`
|
||||
/// (e.g. `node …/@anthropic-ai/claude-code/cli.js`, where the launcher is
|
||||
/// `node` and only the `claude-code` path segment names the agent). All
|
||||
/// lowercase; matched against extension-stripped path segments.
|
||||
fn aliases(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
CLIAgent::Claude => &["claude", "claude-code"],
|
||||
CLIAgent::Codex => &["codex", "codex-cli"],
|
||||
CLIAgent::Gemini => &["gemini", "gemini-cli"],
|
||||
CLIAgent::Aider => &["aider", "aider-chat"],
|
||||
CLIAgent::Amp => &["amp"],
|
||||
CLIAgent::OpenCode => &["opencode"],
|
||||
CLIAgent::Copilot => &["copilot"],
|
||||
CLIAgent::Cursor => &["cursor-agent"],
|
||||
CLIAgent::Goose => &["goose"],
|
||||
CLIAgent::Droid => &["droid"],
|
||||
CLIAgent::Pi => &["pi"],
|
||||
CLIAgent::Auggie => &["auggie"],
|
||||
CLIAgent::Hermes => &["hermes"],
|
||||
CLIAgent::Vibe => &["vibe", "vibe-acp"],
|
||||
CLIAgent::Antigravity => &["agy", "antigravity"],
|
||||
CLIAgent::Grok => &["grok"],
|
||||
CLIAgent::Qwen => &["qwen", "qwen-code"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable machine name (lowercase), used as the `agent` field of the OSC
|
||||
/// event protocol and as the value side of user-defined detection rules in
|
||||
/// `config.json` (`agent_commands: {"my-wrapper": "claude"}`).
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Claude => "claude",
|
||||
CLIAgent::Codex => "codex",
|
||||
CLIAgent::Gemini => "gemini",
|
||||
CLIAgent::Aider => "aider",
|
||||
CLIAgent::Amp => "amp",
|
||||
CLIAgent::OpenCode => "opencode",
|
||||
CLIAgent::Copilot => "copilot",
|
||||
CLIAgent::Cursor => "cursor",
|
||||
CLIAgent::Goose => "goose",
|
||||
CLIAgent::Droid => "droid",
|
||||
CLIAgent::Pi => "pi",
|
||||
CLIAgent::Auggie => "auggie",
|
||||
CLIAgent::Hermes => "hermes",
|
||||
CLIAgent::Vibe => "vibe",
|
||||
CLIAgent::Antigravity => "antigravity",
|
||||
CLIAgent::Grok => "grok",
|
||||
CLIAgent::Qwen => "qwen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Look an agent up by its [`slug`](Self::slug) (case-insensitive).
|
||||
pub fn from_slug(name: &str) -> Option<CLIAgent> {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
CLIAgent::ALL.into_iter().find(|a| a.slug() == name)
|
||||
}
|
||||
|
||||
/// Human-readable name for tab chips, notifications, and menus.
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Claude => "Claude Code",
|
||||
CLIAgent::Codex => "Codex",
|
||||
CLIAgent::Gemini => "Gemini",
|
||||
CLIAgent::Aider => "Aider",
|
||||
CLIAgent::Amp => "Amp",
|
||||
CLIAgent::OpenCode => "OpenCode",
|
||||
CLIAgent::Copilot => "Copilot",
|
||||
CLIAgent::Cursor => "Cursor",
|
||||
CLIAgent::Goose => "Goose",
|
||||
CLIAgent::Droid => "Droid",
|
||||
CLIAgent::Pi => "Pi",
|
||||
CLIAgent::Auggie => "Auggie",
|
||||
CLIAgent::Hermes => "Hermes",
|
||||
CLIAgent::Vibe => "Vibe",
|
||||
CLIAgent::Antigravity => "Antigravity",
|
||||
CLIAgent::Grok => "Grok",
|
||||
CLIAgent::Qwen => "Qwen Code",
|
||||
}
|
||||
}
|
||||
|
||||
/// The shell command that resumes a previous session of this agent by its
|
||||
/// native session id, or `None` for agents without a known resume flag.
|
||||
/// The id is what the agent reported in its `session-start` event (see
|
||||
/// [`AgentEvent`]); commands mirror cmux's per-agent resume table.
|
||||
pub fn resume_command(self, session_id: &str) -> Option<String> {
|
||||
// Ids come from the agent's own events, but they still land on a shell
|
||||
// command line — refuse anything that isn't a plain token so a
|
||||
// malicious/corrupt id can't smuggle shell syntax.
|
||||
if session_id.is_empty()
|
||||
|| !session_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match self {
|
||||
CLIAgent::Claude => Some(format!("claude --resume {session_id}")),
|
||||
CLIAgent::Codex => Some(format!("codex resume {session_id}")),
|
||||
CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")),
|
||||
CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")),
|
||||
CLIAgent::Amp => Some(format!("amp threads continue {session_id}")),
|
||||
CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
|
||||
/// on both light and dark themes rather than exact brand black/white — a pure
|
||||
/// black or white dot vanishes against one theme, so vendors whose mark is
|
||||
/// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead.
|
||||
pub fn accent_rgb(self) -> u32 {
|
||||
match self {
|
||||
CLIAgent::Claude => 0xD97757, // Claude terracotta
|
||||
CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this)
|
||||
CLIAgent::Gemini => 0x4285F4, // Google blue
|
||||
CLIAgent::Aider => 0x14B8A6, // teal
|
||||
CLIAgent::Amp => 0xF34E3F, // Amp red
|
||||
CLIAgent::OpenCode => 0x6E56CF, // violet
|
||||
CLIAgent::Copilot => 0x8957E5, // GitHub purple
|
||||
CLIAgent::Cursor => 0x9AA0A6, // Cursor is monochrome → neutral grey
|
||||
CLIAgent::Goose => 0x9A8CFF, // periwinkle
|
||||
CLIAgent::Droid => 0xF59E0B, // amber
|
||||
CLIAgent::Pi => 0x0EA5E9, // sky
|
||||
CLIAgent::Auggie => 0x16A34A, // Augment green
|
||||
CLIAgent::Hermes => 0x8B5CF6, // violet
|
||||
CLIAgent::Vibe => 0xFF7000, // Mistral orange
|
||||
CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's)
|
||||
CLIAgent::Grok => 0x64748B, // xAI is monochrome → slate
|
||||
CLIAgent::Qwen => 0x7C3AED, // Qwen purple
|
||||
}
|
||||
}
|
||||
|
||||
/// Asset path of this agent's brand glyph, resolved through the app's
|
||||
/// [`crate::ui::assets`] source and rendered as a white silhouette on the
|
||||
/// brand-colored avatar (gpui rasterizes SVGs to a tinted alpha mask, so the
|
||||
/// mark's own fill is irrelevant — geometry only). Vendors we ship a brand
|
||||
/// mark for point at `icons/agents/…`; the rest fall back to the generic
|
||||
/// gpui-component `bot` glyph so every recognized agent still gets an avatar.
|
||||
pub fn icon_path(self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Claude => "icons/agents/claude.svg",
|
||||
CLIAgent::Codex => "icons/agents/codex.svg",
|
||||
CLIAgent::Gemini => "icons/agents/gemini.svg",
|
||||
CLIAgent::Amp => "icons/agents/amp.svg",
|
||||
CLIAgent::OpenCode => "icons/agents/opencode.svg",
|
||||
CLIAgent::Copilot => "icons/agents/copilot.svg",
|
||||
CLIAgent::Cursor => "icons/agents/cursor.svg",
|
||||
CLIAgent::Goose => "icons/agents/goose.svg",
|
||||
CLIAgent::Droid => "icons/agents/droid.svg",
|
||||
// No brand mark bundled → generic robot glyph.
|
||||
CLIAgent::Aider
|
||||
| CLIAgent::Pi
|
||||
| CLIAgent::Auggie
|
||||
| CLIAgent::Hermes
|
||||
| CLIAgent::Vibe
|
||||
| CLIAgent::Antigravity
|
||||
| CLIAgent::Grok
|
||||
| CLIAgent::Qwen => "icons/bot.svg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a single extension-stripped, lowercased command token against the
|
||||
/// registry. `None` when nothing matches.
|
||||
fn match_token(token: &str) -> Option<CLIAgent> {
|
||||
CLIAgent::ALL
|
||||
.into_iter()
|
||||
.find(|a| a.aliases().contains(&token))
|
||||
}
|
||||
|
||||
/// Identify the coding agent a foreground `argv` is running, or `None`.
|
||||
///
|
||||
/// The strategy is command-name detection:
|
||||
/// 1. Strip any leading `VAR=value` environment assignments (`FOO=1 claude`).
|
||||
/// 2. If the launcher's own basename matches a known agent, that's it — the
|
||||
/// native-binary case (`claude`, `codex`, `gemini`, `aider`, …).
|
||||
/// 3. Otherwise, if the launcher is a script *interpreter* (`node`, `bun`,
|
||||
/// `python`, `npx`, …), scan the remaining path-like arguments for a
|
||||
/// segment that names an agent — the npm/pip-wrapped case
|
||||
/// (`node …/claude-code/cli.js`, `npx @anthropic-ai/claude-code`).
|
||||
///
|
||||
/// The interpreter gate is what keeps `cat codex.md` or `vim aider.py` from
|
||||
/// false-matching: a non-interpreter launcher only ever matches on its own
|
||||
/// name, never on its arguments.
|
||||
///
|
||||
/// The production caller (the daemon's foreground poll) goes through
|
||||
/// [`detect_from_argv_with`](Self::detect_from_argv_with) to honor
|
||||
/// user-defined rules; this rule-free form is the pure core the test suite
|
||||
/// exercises.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn detect_from_argv(argv: &[String]) -> Option<CLIAgent> {
|
||||
Self::detect_from_argv_with(argv, &HashMap::new())
|
||||
}
|
||||
|
||||
/// [`detect_from_argv`](Self::detect_from_argv) extended with user-defined
|
||||
/// rules (`config.json`'s `agent_commands`): a map from a command basename
|
||||
/// to an agent [`slug`](Self::slug), so a personal wrapper (`"cc":
|
||||
/// "claude"`) is branded like the agent it launches — a command allowlist
|
||||
/// keyed by exact basename instead of regex. Custom rules apply to the
|
||||
/// *launcher* only (never to
|
||||
/// interpreter arguments) and lose to a built-in match on the same name.
|
||||
pub fn detect_from_argv_with(
|
||||
argv: &[String],
|
||||
custom: &HashMap<String, String>,
|
||||
) -> Option<CLIAgent> {
|
||||
// 1. Skip leading environment assignments (`KEY=val`). A bare `env` prefix
|
||||
// (`env claude`) is treated as an interpreter below so its target is
|
||||
// scanned.
|
||||
let mut rest = argv
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.skip_while(|t| is_env_assignment(t));
|
||||
|
||||
let launcher = rest.next()?;
|
||||
let launcher_stem = base_stem(launcher);
|
||||
|
||||
// 2. Native binary: the launcher itself is the agent — by the built-in
|
||||
// registry first, then by a user-defined rule.
|
||||
if let Some(agent) = CLIAgent::match_token(launcher_stem) {
|
||||
return Some(agent);
|
||||
}
|
||||
if let Some(agent) = custom
|
||||
.get(&launcher_stem.to_ascii_lowercase())
|
||||
.and_then(|slug| CLIAgent::from_slug(slug))
|
||||
{
|
||||
return Some(agent);
|
||||
}
|
||||
|
||||
// 3. Interpreter wrapper: scan the script path / package arg it runs.
|
||||
if is_interpreter(launcher_stem) {
|
||||
for arg in rest {
|
||||
// Only inspect path-like / package-like tokens (the script it
|
||||
// runs), never bare flags or option values.
|
||||
if arg.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
for segment in arg.split('/') {
|
||||
if let Some(agent) =
|
||||
CLIAgent::match_token(&base_stem(segment).to_ascii_lowercase())
|
||||
{
|
||||
return Some(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY`
|
||||
/// must be a non-empty run of identifier chars before the first `=`.
|
||||
fn is_env_assignment(token: &str) -> bool {
|
||||
match token.split_once('=') {
|
||||
Some((key, _)) => {
|
||||
// A real env var starts with a letter/underscore and is otherwise
|
||||
// alphanumerics/underscores — this rejects things like `a=b` paths or
|
||||
// `--flag=val` that merely contain `=`.
|
||||
let mut bytes = key.bytes();
|
||||
bytes
|
||||
.next()
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|
||||
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The final path component with a leading dir and a trailing script extension
|
||||
/// stripped, lowercased-ready but case preserved (callers lowercase when they
|
||||
/// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`.
|
||||
fn base_stem(token: &str) -> &str {
|
||||
let name = Path::new(token)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(token);
|
||||
// Strip one known script extension; leave unknown suffixes intact so
|
||||
// `claude-code` stays whole.
|
||||
for ext in [".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh"] {
|
||||
if let Some(stem) = name.strip_suffix(ext) {
|
||||
return stem;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Whether a launcher basename is a script interpreter whose argument (rather
|
||||
/// than the launcher itself) names the real program — so agent detection should
|
||||
/// scan past it. Covers the common Node/Python/Ruby/`env`/`npx` wrappers agents
|
||||
/// ship as.
|
||||
fn is_interpreter(stem: &str) -> bool {
|
||||
matches!(
|
||||
stem.to_ascii_lowercase().as_str(),
|
||||
"node"
|
||||
| "nodejs"
|
||||
| "bun"
|
||||
| "deno"
|
||||
| "npx"
|
||||
| "pnpm"
|
||||
| "yarn"
|
||||
| "python"
|
||||
| "python3"
|
||||
| "ruby"
|
||||
| "uv"
|
||||
| "uvx"
|
||||
| "env"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rich session status — the OSC event protocol + per-pane state machine.
|
||||
//
|
||||
// Identity detection above answers "*which* agent runs here"; this layer
|
||||
// answers "what is it doing". Agent-side hooks (installed by
|
||||
// `core::agent_hooks`, or hand-wired for any agent) emit an OSC 777
|
||||
// notification whose title is the [`AGENT_EVENT_SENTINEL`] and whose body is a
|
||||
// small JSON event. The daemon sniffs those out of the PTY stream, folds them
|
||||
// through [`AgentSessionState::apply_event`], and streams the state to the
|
||||
// client (`DaemonMsg::AgentStatus`) for status dots, "needs your input"
|
||||
// notifications, and session resume. It's a self-describing sentinel channel
|
||||
// (OSC 777 + `tty7://cli-agent` sentinel + versioned JSON).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The OSC 777 notification title that marks a payload as a tty7 agent event
|
||||
/// rather than a user-facing notification:
|
||||
/// `ESC ] 777;notify;tty7://cli-agent;{json} BEL`.
|
||||
pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent";
|
||||
|
||||
/// What an agent session is doing right now, coarsely. `Waiting` is the state
|
||||
/// the whole feature exists for: the agent stopped mid-turn and needs the user
|
||||
/// (a permission prompt, a question) — the moment worth a notification and an
|
||||
/// amber dot.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AgentStatus {
|
||||
/// Session open, no turn in flight (freshly started, or the user hasn't
|
||||
/// prompted since the last turn ended and was seen).
|
||||
#[default]
|
||||
Idle,
|
||||
/// A turn is in flight (prompt submitted, tools running).
|
||||
Working,
|
||||
/// Stopped mid-turn on the user: permission request, question, or an
|
||||
/// opaque "the agent pinged you" notification.
|
||||
Waiting,
|
||||
/// The turn finished; the result is sitting there waiting to be read.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Per-pane agent session state, maintained daemon-side and mirrored to the
|
||||
/// client. Exists only while an agent is detected in the pane's foreground.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentSessionState {
|
||||
#[serde(default = "AgentSessionState::default_status")]
|
||||
pub status: AgentStatus,
|
||||
/// Human-readable context for `Waiting`/`Done` (e.g. "Claude needs your
|
||||
/// permission to use Bash"), straight from the event.
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
/// The agent's *native* session id (from its `session-start` event), the
|
||||
/// key its own `--resume` flag takes — persisted for restore.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Whether this state came from the rich sentinel channel (hooks
|
||||
/// installed) rather than the opaque OSC 9/777 fallback. Rich state drives
|
||||
/// turn-level notifications; fallback state only paints the dot (the
|
||||
/// agent's own notification text was already toasted by the client).
|
||||
#[serde(default)]
|
||||
pub rich: bool,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
/// The status dot color (0xRRGGBB) shared by the tab chip and the sidebar,
|
||||
/// or `None` for `Idle` (no dot — a resting agent is just its brand mark).
|
||||
pub fn dot_rgb(self) -> Option<u32> {
|
||||
match self {
|
||||
AgentStatus::Idle => None,
|
||||
AgentStatus::Working => Some(0x3B82F6), // blue: in flight
|
||||
AgentStatus::Waiting => Some(0xF59E0B), // amber: needs you
|
||||
AgentStatus::Done => Some(0x22C55E), // green: result ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentSessionState {
|
||||
fn default_status() -> AgentStatus {
|
||||
AgentStatus::Idle
|
||||
}
|
||||
|
||||
/// Fold one rich event into the state. Pure transition function — the
|
||||
/// daemon owns *when* to call it and who to tell.
|
||||
pub fn apply_event(&mut self, ev: &AgentEvent) {
|
||||
self.rich = true;
|
||||
if let Some(id) = &ev.session_id {
|
||||
self.session_id = Some(id.clone());
|
||||
}
|
||||
match ev.kind {
|
||||
AgentEventKind::SessionStart => {
|
||||
self.status = AgentStatus::Idle;
|
||||
self.message = None;
|
||||
}
|
||||
AgentEventKind::PromptSubmit => {
|
||||
self.status = AgentStatus::Working;
|
||||
self.message = None;
|
||||
}
|
||||
// Explicit blocks from agents that distinguish them (Codex/OpenCode
|
||||
// plugins): always the urgent "needs you" state.
|
||||
AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => {
|
||||
self.status = AgentStatus::Waiting;
|
||||
self.message = ev.message.clone();
|
||||
}
|
||||
// Claude Code overloads its single Notification hook: it fires
|
||||
// *mid-turn* for a permission/decision prompt (a genuine block worth
|
||||
// the amber "needs you" state), but ALSO fires *between* turns as an
|
||||
// idle "Claude is waiting for your input" reminder — which must not
|
||||
// masquerade as a block. Escalate only when a turn is actually in
|
||||
// flight; otherwise it's a passive nudge and the current state
|
||||
// (typically Done, freshly replied) stands. Keyed on turn phase, not
|
||||
// the message text, so it survives version/locale changes.
|
||||
AgentEventKind::Notification => {
|
||||
if self.status == AgentStatus::Working {
|
||||
self.status = AgentStatus::Waiting;
|
||||
self.message = ev.message.clone();
|
||||
}
|
||||
}
|
||||
AgentEventKind::Stop => {
|
||||
self.status = AgentStatus::Done;
|
||||
self.message = ev.message.clone();
|
||||
}
|
||||
// The agent session ended but its id stays: Claude & friends can
|
||||
// resume an *ended* session, which is exactly what restore does.
|
||||
AgentEventKind::SessionEnd => {
|
||||
self.status = AgentStatus::Idle;
|
||||
self.message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The event vocabulary of the sentinel protocol (`"event"` in the JSON).
|
||||
/// Deliberately a superset of what any one agent emits: Claude Code hooks map
|
||||
/// onto session-start / prompt-submit / notification / stop / session-end,
|
||||
/// while permission-request / question-asked are there for agents (Codex,
|
||||
/// OpenCode plugins) that can distinguish them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AgentEventKind {
|
||||
SessionStart,
|
||||
PromptSubmit,
|
||||
PermissionRequest,
|
||||
QuestionAsked,
|
||||
Notification,
|
||||
Stop,
|
||||
SessionEnd,
|
||||
}
|
||||
|
||||
/// One parsed sentinel event.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AgentEvent {
|
||||
/// Which agent sent it, when the payload names one we know. Lets the event
|
||||
/// brand a pane even where argv detection can't see the process (a wrapper
|
||||
/// we don't recognize).
|
||||
pub agent: Option<CLIAgent>,
|
||||
pub kind: AgentEventKind,
|
||||
pub session_id: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a complete OSC payload (identifier included, e.g.
|
||||
/// `777;notify;tty7://cli-agent;{"v":1,…}`) into an [`AgentEvent`]. `None` for
|
||||
/// anything that isn't a well-formed sentinel event — including unknown
|
||||
/// `event` values, so the protocol can grow without old daemons
|
||||
/// mis-classifying new events.
|
||||
pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
|
||||
let rest = payload.strip_prefix(b"777;notify;")?;
|
||||
let rest = rest.strip_prefix(AGENT_EVENT_SENTINEL.as_bytes())?;
|
||||
let json = rest.strip_prefix(b";")?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Wire {
|
||||
// Protocol version; v1 is all that exists. Kept for forward evolution.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
v: u32,
|
||||
#[serde(default)]
|
||||
agent: Option<String>,
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let w: Wire = serde_json::from_slice(json).ok()?;
|
||||
let kind = serde_json::from_value::<AgentEventKind>(serde_json::Value::String(w.event)).ok()?;
|
||||
let nonempty = |s: Option<String>| s.filter(|s| !s.trim().is_empty());
|
||||
Some(AgentEvent {
|
||||
agent: w.agent.as_deref().and_then(CLIAgent::from_slug),
|
||||
kind,
|
||||
session_id: nonempty(w.session_id),
|
||||
message: nonempty(w.message),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn argv(parts: &[&str]) -> Vec<String> {
|
||||
parts.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_native_binaries() {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["claude"])),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["/opt/homebrew/bin/codex", "--model", "o3"])),
|
||||
Some(CLIAgent::Codex)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["/usr/local/bin/gemini"])),
|
||||
Some(CLIAgent::Gemini)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["cursor-agent"])),
|
||||
Some(CLIAgent::Cursor)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_leading_env_assignments() {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["FOO=1", "BAR=baz", "claude"])),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_node_wrapped_claude_by_package_dir() {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&[
|
||||
"node",
|
||||
"/Users/x/.npm/_npx/node_modules/@anthropic-ai/claude-code/cli.js",
|
||||
])),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_npx_package_form() {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["npx", "@anthropic-ai/claude-code"])),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["npx", "@google/gemini-cli"])),
|
||||
Some(CLIAgent::Gemini)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_python_wrapped_aider() {
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&[
|
||||
"python3",
|
||||
"/usr/lib/python3.12/site-packages/aider/__main__.py",
|
||||
])),
|
||||
Some(CLIAgent::Aider)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interpreter_does_not_match_on_arguments() {
|
||||
// A file *named* like an agent, opened by an unrelated tool, must not
|
||||
// trip detection — only interpreters have their args scanned.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["cat", "codex.md"])),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["vim", "claude-code/notes.txt"])),
|
||||
None
|
||||
);
|
||||
assert_eq!(CLIAgent::detect_from_argv(&argv(&["less", "aider"])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_commands_are_none() {
|
||||
assert_eq!(CLIAgent::detect_from_argv(&argv(&["zsh"])), None);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["node", "server.js"])),
|
||||
None
|
||||
);
|
||||
assert_eq!(CLIAgent::detect_from_argv(&argv(&[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_agent_has_metadata() {
|
||||
for a in CLIAgent::ALL {
|
||||
assert!(!a.display_name().is_empty());
|
||||
assert!(!a.aliases().is_empty());
|
||||
assert!(a.accent_rgb() <= 0xFFFFFF);
|
||||
assert_eq!(CLIAgent::from_slug(a.slug()), Some(a));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_newer_agents_by_command() {
|
||||
for (cmd, agent) in [
|
||||
("auggie", CLIAgent::Auggie),
|
||||
("agy", CLIAgent::Antigravity),
|
||||
("vibe-acp", CLIAgent::Vibe),
|
||||
("grok", CLIAgent::Grok),
|
||||
("/usr/local/bin/qwen", CLIAgent::Qwen),
|
||||
("pi", CLIAgent::Pi),
|
||||
("hermes", CLIAgent::Hermes),
|
||||
] {
|
||||
assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_rules_map_wrappers_to_agents() {
|
||||
let custom: HashMap<String, String> = [("cc".to_string(), "claude".to_string())].into();
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&argv(&["/home/x/bin/cc", "-c"]), &custom),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
// A rule naming an unknown agent is ignored, not an error.
|
||||
let bogus: HashMap<String, String> = [("cc".to_string(), "hal9000".to_string())].into();
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&argv(&["cc"]), &bogus),
|
||||
None
|
||||
);
|
||||
// Custom rules never scan interpreter arguments.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&argv(&["node", "cc/cli.js"]), &custom),
|
||||
None
|
||||
);
|
||||
// Built-ins still win on their own names.
|
||||
let shadow: HashMap<String, String> = [("codex".to_string(), "claude".to_string())].into();
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv_with(&argv(&["codex"]), &shadow),
|
||||
Some(CLIAgent::Codex)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sentinel_events() {
|
||||
let ev = parse_agent_event(
|
||||
br#"777;notify;tty7://cli-agent;{"v":1,"agent":"claude","event":"permission-request","session_id":"abc-123","message":"Claude needs your permission to use Bash"}"#,
|
||||
)
|
||||
.expect("well-formed sentinel event");
|
||||
assert_eq!(ev.agent, Some(CLIAgent::Claude));
|
||||
assert_eq!(ev.kind, AgentEventKind::PermissionRequest);
|
||||
assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
|
||||
assert!(ev.message.as_deref().unwrap().contains("permission"));
|
||||
|
||||
// A plain OSC 777 notification is NOT an event.
|
||||
assert_eq!(parse_agent_event(b"777;notify;Build;done"), None);
|
||||
// Unknown event names are dropped (forward evolution).
|
||||
assert_eq!(
|
||||
parse_agent_event(br#"777;notify;tty7://cli-agent;{"event":"quantum-leap"}"#),
|
||||
None
|
||||
);
|
||||
// Malformed JSON is dropped.
|
||||
assert_eq!(
|
||||
parse_agent_event(b"777;notify;tty7://cli-agent;{oops"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_state_machine_follows_the_turn() {
|
||||
let mut s = AgentSessionState::default();
|
||||
assert_eq!(s.status, AgentStatus::Idle);
|
||||
|
||||
let ev = |kind, msg: Option<&str>, id: Option<&str>| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: id.map(String::from),
|
||||
message: msg.map(String::from),
|
||||
};
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1")));
|
||||
assert_eq!(s.status, AgentStatus::Idle);
|
||||
assert_eq!(s.session_id.as_deref(), Some("sid-1"));
|
||||
assert!(s.rich);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit, None, None));
|
||||
assert_eq!(s.status, AgentStatus::Working);
|
||||
|
||||
// A Notification arriving MID-TURN (while Working) is a real block —
|
||||
// a permission/decision prompt — so it escalates to Waiting.
|
||||
s.apply_event(&ev(
|
||||
AgentEventKind::Notification,
|
||||
Some("Claude needs your permission"),
|
||||
None,
|
||||
));
|
||||
assert_eq!(s.status, AgentStatus::Waiting);
|
||||
assert!(s.message.as_deref().unwrap().contains("permission"));
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::Stop, None, None));
|
||||
assert_eq!(s.status, AgentStatus::Done);
|
||||
|
||||
// A Notification arriving BETWEEN turns (while Done) is Claude Code's
|
||||
// idle "waiting for your input" nudge, NOT a block — it must not flip
|
||||
// the finished-and-green session to amber "needs you".
|
||||
s.apply_event(&ev(
|
||||
AgentEventKind::Notification,
|
||||
Some("Claude is waiting for your input"),
|
||||
None,
|
||||
));
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Done,
|
||||
"an idle notification between turns must not fabricate a block"
|
||||
);
|
||||
|
||||
// Session end goes idle but KEEPS the id — ended sessions resume.
|
||||
s.apply_event(&ev(AgentEventKind::SessionEnd, None, None));
|
||||
assert_eq!(s.status, AgentStatus::Idle);
|
||||
assert_eq!(s.session_id.as_deref(), Some("sid-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_commands_are_shell_safe() {
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.resume_command("abc-123").as_deref(),
|
||||
Some("claude --resume abc-123")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Codex.resume_command("th_read.9").as_deref(),
|
||||
Some("codex resume th_read.9")
|
||||
);
|
||||
// No resume flag known → None.
|
||||
assert_eq!(CLIAgent::Aider.resume_command("abc"), None);
|
||||
// An id carrying shell syntax is refused outright.
|
||||
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_metadata_is_consistent() {
|
||||
assert_eq!(AgentStatus::Idle.dot_rgb(), None);
|
||||
for st in [
|
||||
AgentStatus::Working,
|
||||
AgentStatus::Waiting,
|
||||
AgentStatus::Done,
|
||||
] {
|
||||
assert!(st.dot_rgb().is_some());
|
||||
}
|
||||
// Wire form is kebab-case (shared with the JSON protocol).
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentStatus::Waiting).unwrap(),
|
||||
"\"waiting\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,22 @@ pub struct Config {
|
||||
/// rows. Entries for deleted profiles are harmless (never surfaced).
|
||||
#[serde(default)]
|
||||
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
|
||||
|
||||
// ── CLI coding agents ────────────────────────────────────────────────────
|
||||
/// User-defined agent-detection rules: a command basename → an agent slug
|
||||
/// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get
|
||||
/// branded like the agent they launch. Complements the built-in registry in
|
||||
/// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon
|
||||
/// reads this once per process (restart the daemon to apply changes).
|
||||
#[serde(default)]
|
||||
pub agent_commands: HashMap<String, String>,
|
||||
/// On session restore, when a pane can't re-attach (the daemon lost it —
|
||||
/// reboot, daemon restart) but it was running a coding agent whose native
|
||||
/// session id we captured, type that agent's resume command into the fresh
|
||||
/// shell (`claude --resume <id>`, `codex resume <id>`, …) so the
|
||||
/// conversation continues where it left off. cmux-style; on by default.
|
||||
#[serde(default = "default_true")]
|
||||
pub restore_agent_sessions: bool,
|
||||
}
|
||||
|
||||
/// One saved profile's usage record for palette frecency (see
|
||||
@@ -426,6 +442,8 @@ impl Default for Config {
|
||||
verify_host_keys: true,
|
||||
ssh_warn_on_close: false,
|
||||
ssh_profile_frecency: HashMap::new(),
|
||||
agent_commands: HashMap::new(),
|
||||
restore_agent_sessions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,6 +667,22 @@ pub fn extra_env() -> HashMap<String, String> {
|
||||
Config::load().env
|
||||
}
|
||||
|
||||
/// User-defined agent-detection rules (`agent_commands`), keys lowercased,
|
||||
/// cached once per process. The daemon consults this from its 0.5 s foreground
|
||||
/// poll on every pane, so it must not re-read `config.json` each time; the
|
||||
/// trade-off is that rule edits apply on the next daemon start (the GUI's
|
||||
/// "Restart daemon" command counts).
|
||||
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
|
||||
static CACHE: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
Config::load()
|
||||
.agent_commands
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_ascii_lowercase(), v))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset.
|
||||
fn default_preset() -> String {
|
||||
"default".to_string()
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//! code.
|
||||
|
||||
pub mod actions;
|
||||
pub mod agent_hooks;
|
||||
pub mod agent_prompt;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
|
||||
@@ -179,6 +179,48 @@ impl OscTokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done`
|
||||
/// or `777;notify;Title;Body`) into a `(title, body)` desktop notification, or
|
||||
/// `None` if it isn't one. Shared by the client's notification toaster
|
||||
/// (`terminal::remote`) and the daemon's agent-status sniffer (`daemon::pane`),
|
||||
/// so ConEmu's OSC 9 subcommand quirks are handled in exactly one place.
|
||||
///
|
||||
/// tty7's own agent-event sentinel (`777;notify;tty7://cli-agent;{json}` — see
|
||||
/// [`crate::core::cli_agent::AGENT_EVENT_SENTINEL`]) parses as a notification
|
||||
/// *shape*, but it is machine-to-machine traffic: callers that surface toasts
|
||||
/// must check for it first (via [`crate::core::cli_agent::parse_agent_event`])
|
||||
/// rather than showing the raw JSON to the user.
|
||||
pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
// OSC 9 ; <text> — iTerm2 / growl style; title-less, body is the text.
|
||||
if let Some(rest) = payload.strip_prefix(b"9;") {
|
||||
// ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress,
|
||||
// `9;9;<cwd>`, …); those aren't notifications, so skip a `<digit>;`/`<digit>`
|
||||
// leading field. A real message rarely starts with a bare single digit.
|
||||
let first = rest.split(|&b| b == b';').next().unwrap_or(rest);
|
||||
if first.len() == 1 && first[0].is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
let body = String::from_utf8_lossy(rest).into_owned();
|
||||
return (!body.is_empty()).then_some((None, body));
|
||||
}
|
||||
// OSC 777 ; notify ; <title> ; <body> — urxvt style.
|
||||
if let Some(rest) = payload.strip_prefix(b"777;notify;") {
|
||||
let mut parts = rest.splitn(2, |&b| b == b';');
|
||||
let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned();
|
||||
let second = parts
|
||||
.next()
|
||||
.map(|b| String::from_utf8_lossy(b).into_owned());
|
||||
// With both fields present it's title + body; with only one it's a body-only
|
||||
// notification (some senders omit the title).
|
||||
let (title, body) = match second {
|
||||
Some(body) if !body.is_empty() => (Some(first), body),
|
||||
_ => (None, first),
|
||||
};
|
||||
return (!body.is_empty()).then_some((title, body));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -44,6 +44,16 @@ pub enum SessionPane {
|
||||
/// for sessions written before this field existed.
|
||||
#[serde(default)]
|
||||
ssh_spec: Option<Box<NativeSshSpec>>,
|
||||
/// The coding agent this leaf was running at save time, plus its native
|
||||
/// session id (from the agent's own `session-start` event). When the
|
||||
/// pane can't re-attach on restore, these drive the cmux-style resume:
|
||||
/// the fresh shell is handed the agent's resume command
|
||||
/// (`claude --resume <id>`, …) so the conversation continues. `None`
|
||||
/// for panes without an agent, agents without hooks, or old sessions.
|
||||
#[serde(default)]
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
#[serde(default)]
|
||||
agent_session_id: Option<String>,
|
||||
},
|
||||
/// A split of two subtrees along `axis`, with `a` taking `ratio` of space.
|
||||
Split {
|
||||
@@ -173,6 +183,8 @@ mod tests {
|
||||
cwd: Some(PathBuf::from("/work")),
|
||||
pane_id: Some(7),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
},
|
||||
},
|
||||
SessionTab {
|
||||
@@ -184,11 +196,15 @@ mod tests {
|
||||
cwd: None,
|
||||
pane_id: None,
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
}),
|
||||
b: Box::new(SessionPane::Leaf {
|
||||
cwd: Some(PathBuf::from("/tmp")),
|
||||
pane_id: Some(9),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -211,6 +227,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_agent_resume_fields_round_trip_and_default() {
|
||||
// Round trip: the agent + native session id survive serialization.
|
||||
let leaf = SessionPane::Leaf {
|
||||
cwd: None,
|
||||
pane_id: None,
|
||||
ssh_spec: None,
|
||||
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
|
||||
agent_session_id: Some("abc-123".into()),
|
||||
};
|
||||
let back: SessionPane =
|
||||
serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap();
|
||||
match back {
|
||||
SessionPane::Leaf {
|
||||
agent,
|
||||
agent_session_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude));
|
||||
assert_eq!(agent_session_id.as_deref(), Some("abc-123"));
|
||||
}
|
||||
_ => panic!("expected leaf"),
|
||||
}
|
||||
// A session written before these fields existed decodes with `None`s.
|
||||
let old: SessionPane =
|
||||
serde_json::from_str(r#"{"Leaf":{"cwd":"/x","pane_id":3}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
old,
|
||||
SessionPane::Leaf {
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_defaults_fill_missing_fields() {
|
||||
// An empty object → default (active 0, no tabs).
|
||||
@@ -242,6 +294,8 @@ mod tests {
|
||||
cwd: Some(PathBuf::from("/home/u")),
|
||||
pane_id: Some(1),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -221,6 +221,14 @@ fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<Pat
|
||||
// Advertise a widely-available terminfo + truecolor.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
// Mark the session as tty7's, for tooling that adapts to its host terminal
|
||||
// — most importantly the `tty7 agent-hook` emitter, which stays silent
|
||||
// without it so globally-installed agent hooks can't leak escape sequences
|
||||
// into other terminals (see `core::agent_hooks`).
|
||||
cmd.env(
|
||||
crate::core::agent_hooks::TTY7_ENV_MARKER,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
// User-configured environment variables, injected last so they can override
|
||||
// the inherited environment (but not TERM/COLORTERM above, which reflect our
|
||||
// emulator's real capabilities).
|
||||
@@ -353,6 +361,15 @@ struct PaneState {
|
||||
shell: ShellState,
|
||||
/// Trusted foreground remote context from the local process table.
|
||||
remote: Option<RemoteContext>,
|
||||
/// The third-party CLI coding agent running in the foreground, detected from
|
||||
/// the foreground `argv` (same process-table poll as `remote`). `None` when
|
||||
/// no known agent runs — see [`crate::core::cli_agent`].
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
/// The rich agent-session status (idle/working/waiting/done + native
|
||||
/// session id), folded from the sentinel OSC events the agent's hooks emit
|
||||
/// (with an opaque OSC 9/777 fallback). Cleared when the agent exits.
|
||||
/// See [`crate::core::cli_agent::AgentSessionState`].
|
||||
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
|
||||
/// Last geometry the PTY was sized to (spawn size, then each `resize`).
|
||||
/// Reported to a re-attaching client as `DaemonMsg::Size` so its replay of
|
||||
/// the ring runs at the geometry the ring was recorded under.
|
||||
@@ -373,6 +390,18 @@ enum PaneBackend {
|
||||
NativeSsh(NativeSshBackend),
|
||||
}
|
||||
|
||||
/// The reader thread's two off-hot-path foreground probes, bundled so
|
||||
/// [`DaemonPane::spawn_reader`] takes them as one argument. Both are
|
||||
/// process-table reads (foreground process-group leader → `argv`) run together
|
||||
/// on the reader's 0.5 s poll: `remote` classifies an SSH context, `agent`
|
||||
/// classifies a third-party coding agent. Boxed rather than generic because
|
||||
/// they're invoked at most twice a second — the indirection is free here and
|
||||
/// keeps the reader's signature readable.
|
||||
struct ForegroundProbes {
|
||||
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
|
||||
agent: Box<dyn Fn() -> Option<crate::core::cli_agent::CLIAgent> + Send>,
|
||||
}
|
||||
|
||||
/// The local-PTY backend: the same handles `DaemonPane` has always owned.
|
||||
struct PtyBackend {
|
||||
/// The PTY master. Kept for the pane's lifetime to `resize` it and to query the
|
||||
@@ -533,6 +562,8 @@ impl DaemonPane {
|
||||
cwd: spawn.initial_cwd,
|
||||
shell: ShellState::default(),
|
||||
remote: spawn.remote.clone(),
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
size,
|
||||
alive: true,
|
||||
}));
|
||||
@@ -579,13 +610,17 @@ impl DaemonPane {
|
||||
// keyboard — see `foreground_command_running` / issue #26.
|
||||
let fg_master = master.clone();
|
||||
let remote_master = master.clone();
|
||||
let agent_master = master.clone();
|
||||
let reader = Self::spawn_reader(
|
||||
state,
|
||||
shutting_down,
|
||||
gate,
|
||||
reader_handle,
|
||||
move || foreground_command_running(&fg_master, shell_pid),
|
||||
move || foreground_remote_context(&remote_master),
|
||||
ForegroundProbes {
|
||||
remote: Box::new(move || foreground_remote_context(&remote_master)),
|
||||
agent: Box::new(move || foreground_agent(&agent_master)),
|
||||
},
|
||||
death,
|
||||
);
|
||||
*pane.reader.lock().unwrap() = Some(reader);
|
||||
@@ -634,6 +669,10 @@ impl DaemonPane {
|
||||
cwd: None,
|
||||
shell: ShellState::default(),
|
||||
remote: Some(remote),
|
||||
// A native-SSH pane has no local process group, so foreground-argv
|
||||
// agent detection never runs for it.
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
size,
|
||||
alive: true,
|
||||
}));
|
||||
@@ -679,7 +718,10 @@ impl DaemonPane {
|
||||
gate,
|
||||
reader_handle,
|
||||
|| false,
|
||||
|| None,
|
||||
ForegroundProbes {
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
},
|
||||
death,
|
||||
);
|
||||
*pane.reader.lock().unwrap() = Some(reader);
|
||||
@@ -727,6 +769,10 @@ impl DaemonPane {
|
||||
/// `Prompt` on change. On EOF it reports the death through `death` — marking
|
||||
/// the pane not-alive and sending `Exited`, keeping the ring for a later
|
||||
/// attach, or handing an unattached pane to `on_dead` (see [`DeathReporter`]).
|
||||
///
|
||||
/// The two off-hot-path foreground probes (remote context + coding agent)
|
||||
/// travel together in [`ForegroundProbes`]: both are process-table reads run
|
||||
/// on the same 0.5 s poll, and bundling them keeps the signature at arity.
|
||||
fn spawn_reader(
|
||||
state: Arc<Mutex<PaneState>>,
|
||||
shutting_down: Arc<AtomicBool>,
|
||||
@@ -736,9 +782,13 @@ impl DaemonPane {
|
||||
// when a prompt mark arrives, to reject marks a foreground program emits —
|
||||
// see the call site and [`foreground_command_running`].
|
||||
foreground_running: impl Fn() -> bool + Send + 'static,
|
||||
foreground_remote: impl Fn() -> Option<RemoteContext> + Send + 'static,
|
||||
probes: ForegroundProbes,
|
||||
death: Arc<DeathReporter>,
|
||||
) -> JoinHandle<()> {
|
||||
let ForegroundProbes {
|
||||
remote: foreground_remote,
|
||||
agent: foreground_agent_fn,
|
||||
} = probes;
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-daemon-pane-reader".to_string())
|
||||
.spawn(move || {
|
||||
@@ -815,14 +865,19 @@ impl DaemonPane {
|
||||
}
|
||||
}
|
||||
|
||||
// SSH-context detection is a process-table query
|
||||
// (sysctl/procfs). Keep it out of the state lock and
|
||||
// off the per-chunk hot path; half-second freshness is
|
||||
// enough for link hover/click state while keeping PTY
|
||||
// drain latency predictable.
|
||||
let remote = if std::time::Instant::now() >= next_remote_check {
|
||||
// SSH-context + coding-agent detection are process-table
|
||||
// queries (sysctl/procfs). Keep them out of the state lock
|
||||
// and off the per-chunk hot path; half-second freshness is
|
||||
// enough for link hover/click state and the agent tab chip
|
||||
// while keeping PTY drain latency predictable. Both ride the
|
||||
// one poll gate so we read the foreground process at most
|
||||
// twice per interval.
|
||||
let poll_now = std::time::Instant::now() >= next_remote_check;
|
||||
if poll_now {
|
||||
next_remote_check =
|
||||
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
|
||||
}
|
||||
let remote = if poll_now {
|
||||
// A native-SSH pane already carries its own remote
|
||||
// context; process-table detection must not clobber
|
||||
// it (this pane *is* SSH). Only a plain PTY pane gets
|
||||
@@ -837,6 +892,7 @@ impl DaemonPane {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent = poll_now.then(&foreground_agent_fn);
|
||||
|
||||
let tr1 = trace.then(std::time::Instant::now);
|
||||
let mut st = state.lock().unwrap();
|
||||
@@ -854,6 +910,9 @@ impl DaemonPane {
|
||||
if let Some(remote) = remote {
|
||||
apply_remote_context(&mut st, remote);
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
apply_agent(&mut st, agent);
|
||||
}
|
||||
if let Some(tr1) = tr1 {
|
||||
tr_disp_t += tr1.elapsed();
|
||||
}
|
||||
@@ -1394,6 +1453,12 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 {
|
||||
if st.remote.is_some() {
|
||||
let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone()));
|
||||
}
|
||||
if st.agent.is_some() {
|
||||
let _ = subscriber.send(DaemonMsg::Agent(st.agent));
|
||||
}
|
||||
if st.agent_session.is_some() {
|
||||
let _ = subscriber.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
|
||||
}
|
||||
// A dead pane's reader thread — the one that reports the child's exit — is
|
||||
// long gone, so replay its exit too: without this an attach racing the
|
||||
// child's death (it exited between the client's `List` and its `Attach`)
|
||||
@@ -1427,6 +1492,61 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
});
|
||||
}
|
||||
}
|
||||
apply_agent_signals(st, signals.agent_events, signals.notification);
|
||||
}
|
||||
|
||||
/// Fold the chunk's agent signals into the pane's session state and push any
|
||||
/// resulting change. Called with the state lock held.
|
||||
///
|
||||
/// Two tiers: sentinel events (hooks installed) drive the full
|
||||
/// state machine and may even *identify* the agent where argv detection can't
|
||||
/// see through a wrapper; a plain OSC 9/777 notification is the no-hooks
|
||||
/// fallback — it only means "the agent pinged you", so it marks the session
|
||||
/// `Waiting` (non-rich) and never overrides live rich state.
|
||||
fn apply_agent_signals(
|
||||
st: &mut PaneState,
|
||||
events: Vec<crate::core::cli_agent::AgentEvent>,
|
||||
notification: Option<String>,
|
||||
) {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus};
|
||||
|
||||
if events.is_empty() && notification.is_none() {
|
||||
return;
|
||||
}
|
||||
let before = st.agent_session.clone();
|
||||
|
||||
for event in &events {
|
||||
// An event naming an agent brands the pane even when the process-table
|
||||
// poll can't (an unrecognized wrapper binary): identity via protocol.
|
||||
if st.agent.is_none() && event.agent.is_some() {
|
||||
st.agent = event.agent;
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::Agent(st.agent));
|
||||
}
|
||||
}
|
||||
st.agent_session
|
||||
.get_or_insert_with(AgentSessionState::default)
|
||||
.apply_event(event);
|
||||
}
|
||||
|
||||
// Opaque fallback: only meaningful when we know an agent runs here, and
|
||||
// never on top of rich state (the hooks channel owns it then).
|
||||
if let Some(body) = notification
|
||||
&& st.agent.is_some()
|
||||
&& !st.agent_session.as_ref().is_some_and(|s| s.rich)
|
||||
{
|
||||
let sess = st
|
||||
.agent_session
|
||||
.get_or_insert_with(AgentSessionState::default);
|
||||
sess.status = AgentStatus::Waiting;
|
||||
sess.message = Some(body);
|
||||
}
|
||||
|
||||
if st.agent_session != before
|
||||
&& let Some(sub) = &st.subscriber
|
||||
{
|
||||
let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) {
|
||||
@@ -1439,6 +1559,26 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) {
|
||||
st.remote = remote;
|
||||
}
|
||||
|
||||
fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgent>) {
|
||||
if st.agent == agent {
|
||||
return;
|
||||
}
|
||||
// The agent leaving the foreground ends its session: clear the rich state
|
||||
// (and tell the client) so a stale "waiting" dot can't outlive the process.
|
||||
// The poll can blip momentarily (an agent-spawned subcommand takes the
|
||||
// foreground group), but events re-establish state on the next signal.
|
||||
if agent.is_none() && st.agent_session.is_some() {
|
||||
st.agent_session = None;
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::AgentStatus(None));
|
||||
}
|
||||
}
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::Agent(agent));
|
||||
}
|
||||
st.agent = agent;
|
||||
}
|
||||
|
||||
/// Whether a foreground command — not the shell itself — currently owns the
|
||||
/// PTY. True while e.g. `ssh`, `vim`, or a nested shell runs; false when the
|
||||
/// shell sits idle at its own prompt (it is then the terminal's foreground
|
||||
@@ -1494,6 +1634,28 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
/// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the
|
||||
/// PTY foreground, from its `argv`. Same process-table read as
|
||||
/// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn foreground_agent(
|
||||
master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
|
||||
let argv = crate::daemon::remote::foreground_argv(pid)?;
|
||||
crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
&argv,
|
||||
crate::core::config::agent_commands_cached(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn foreground_agent(
|
||||
_master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OSC sniffer (cwd + prompt). The byte-level OSC framing lives in
|
||||
// `core::osc::OscTokenizer` (shared with the client's notification scanner);
|
||||
@@ -1515,6 +1677,14 @@ struct ShellState {
|
||||
struct SniffSignals {
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Option<ShellState>,
|
||||
/// Sentinel agent events completed in this chunk, in stream order — each
|
||||
/// one is a state-machine step, so unlike cwd/shell they must *all* apply
|
||||
/// (a `stop` directly after a `notification` still means "done").
|
||||
agent_events: Vec<crate::core::cli_agent::AgentEvent>,
|
||||
/// A plain (non-sentinel) OSC 9/777 desktop notification completed in this
|
||||
/// chunk — the opaque "the agent pinged you" fallback signal for panes
|
||||
/// whose agent has no hooks installed. Last body wins.
|
||||
notification: Option<String>,
|
||||
}
|
||||
|
||||
struct OscSniffer {
|
||||
@@ -1526,7 +1696,10 @@ struct OscSniffer {
|
||||
impl OscSniffer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tok: OscTokenizer::new(&[b"7", b"133"]),
|
||||
// 9 / 777 are the notification channels the agent-status layer
|
||||
// rides (sentinel events + opaque fallback); the client sniffs the
|
||||
// same two independently for its desktop toasts.
|
||||
tok: OscTokenizer::new(&[b"7", b"133", b"9", b"777"]),
|
||||
shell: ShellState::default(),
|
||||
}
|
||||
}
|
||||
@@ -1543,6 +1716,14 @@ impl OscSniffer {
|
||||
} else if let Some(rest) = payload.strip_prefix(b"133;") {
|
||||
handle_osc133(shell, rest);
|
||||
signals.shell = Some(shell.clone());
|
||||
} else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) {
|
||||
signals.agent_events.push(event);
|
||||
} else if let Some((title, body)) = crate::core::osc::parse_notification(payload) {
|
||||
// A sentinel-titled payload whose JSON failed to parse is
|
||||
// protocol traffic, not a user notification — drop it.
|
||||
if title.as_deref() != Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) {
|
||||
signals.notification = Some(body);
|
||||
}
|
||||
}
|
||||
});
|
||||
signals
|
||||
@@ -1702,6 +1883,57 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End-to-end check of the *live* agent-detection chain this feature rides
|
||||
/// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding
|
||||
/// agent (`exec -a codex …`), then follow the exact path `foreground_agent`
|
||||
/// uses — read the PTY's foreground process-group leader, read its `argv`
|
||||
/// from the process table, and run `detect_from_argv`. Guards against a
|
||||
/// regression in the platform `process_group_leader` / `foreground_argv`
|
||||
/// plumbing that the pure `detect_from_argv` unit tests can't see.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn live_pty_child_argv_detects_the_agent() {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
|
||||
let pty = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("openpty");
|
||||
// `exec -a codex` replaces the shell with `cat`, giving it argv[0]=codex
|
||||
// while it blocks on stdin — so it stays the PTY's foreground group long
|
||||
// enough to observe. `cat` (not `sleep`) keeps it alive until the master
|
||||
// is dropped and its stdin EOFs. Must be bash: `exec -a` is a bashism
|
||||
// that dash (Ubuntu's /bin/sh) rejects.
|
||||
let mut cmd = CommandBuilder::new("bash");
|
||||
cmd.args(["-c", "exec -a codex cat"]);
|
||||
let mut child = pty.slave.spawn_command(cmd).expect("spawn child");
|
||||
let master = Mutex::new(pty.master);
|
||||
|
||||
// Poll for the foreground group to become the child (not the transient
|
||||
// `sh`), then detect. Bounded so a stuck spawn fails the test rather than
|
||||
// hanging CI.
|
||||
let mut detected = None;
|
||||
for _ in 0..200 {
|
||||
if let Some(agent) = foreground_agent(&master) {
|
||||
detected = Some(agent);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(
|
||||
detected,
|
||||
Some(crate::core::cli_agent::CLIAgent::Codex),
|
||||
"a live PTY child with argv[0]=codex must be detected as Codex"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawn shell precedence: explicit override > configured > platform
|
||||
/// default (`None`). Locks the contract stated on [`choose_shell`].
|
||||
#[test]
|
||||
@@ -2220,6 +2452,8 @@ mod tests {
|
||||
cwd: None,
|
||||
shell: ShellState::default(),
|
||||
remote: None,
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
size: WinSize {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -2230,6 +2464,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full daemon-side rich-status path: sentinel OSC events sniffed out
|
||||
/// of the byte stream drive the pane's session state machine, identify the
|
||||
/// agent when argv detection hasn't, and stream every change to the
|
||||
/// subscriber — while a plain notification only fires the opaque fallback.
|
||||
#[test]
|
||||
fn sentinel_events_drive_agent_session_state() {
|
||||
use crate::core::cli_agent::{AgentStatus, CLIAgent};
|
||||
|
||||
let mut st = test_state(true);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
st.subscriber = Some(tx);
|
||||
|
||||
let mut sniffer = OscSniffer::new();
|
||||
let stream = concat!(
|
||||
"\x1b]777;notify;tty7://cli-agent;",
|
||||
r#"{"v":1,"agent":"claude","event":"session-start","session_id":"sid-9"}"#,
|
||||
"\x07",
|
||||
"\x1b]777;notify;tty7://cli-agent;",
|
||||
r#"{"v":1,"agent":"claude","event":"prompt-submit"}"#,
|
||||
"\x07",
|
||||
);
|
||||
apply_signals(&mut st, sniffer.feed(stream.as_bytes()));
|
||||
|
||||
// The event branded the pane (argv detection never ran here)…
|
||||
assert_eq!(st.agent, Some(CLIAgent::Claude));
|
||||
// …and the state machine folded both events: idle → working, id kept.
|
||||
let sess = st.agent_session.clone().expect("session state exists");
|
||||
assert_eq!(sess.status, AgentStatus::Working);
|
||||
assert_eq!(sess.session_id.as_deref(), Some("sid-9"));
|
||||
assert!(sess.rich);
|
||||
|
||||
// The subscriber saw the identity and the (final) status.
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(DaemonMsg::Agent(Some(CLIAgent::Claude)))
|
||||
));
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(DaemonMsg::AgentStatus(Some(s))) if s.status == AgentStatus::Working
|
||||
));
|
||||
|
||||
// A waiting event lands with its message.
|
||||
let waiting = concat!(
|
||||
"\x1b]777;notify;tty7://cli-agent;",
|
||||
r#"{"event":"notification","message":"Claude needs your permission to use Bash"}"#,
|
||||
"\x07",
|
||||
);
|
||||
apply_signals(&mut st, sniffer.feed(waiting.as_bytes()));
|
||||
assert_eq!(
|
||||
st.agent_session.as_ref().unwrap().status,
|
||||
AgentStatus::Waiting
|
||||
);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(DaemonMsg::AgentStatus(Some(s))) if s.message.as_deref().unwrap().contains("permission")
|
||||
));
|
||||
|
||||
// The agent leaving the foreground clears the session (and says so).
|
||||
apply_agent(&mut st, None);
|
||||
assert!(st.agent_session.is_none());
|
||||
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::AgentStatus(None))));
|
||||
assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
|
||||
}
|
||||
|
||||
/// The opaque fallback: with an agent detected but no hooks, a plain OSC 9
|
||||
/// notification marks the session waiting (non-rich); without an agent it
|
||||
/// does nothing; and it never clobbers live rich state.
|
||||
#[test]
|
||||
fn opaque_notifications_only_fall_back_when_no_rich_state() {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
|
||||
|
||||
// No agent → the notification is ignored (it's just a toast).
|
||||
let mut st = test_state(true);
|
||||
let mut sniffer = OscSniffer::new();
|
||||
apply_signals(&mut st, sniffer.feed(b"\x1b]9;Build finished\x07"));
|
||||
assert!(st.agent_session.is_none());
|
||||
|
||||
// Agent detected, no hooks → waiting, non-rich, body kept.
|
||||
st.agent = Some(CLIAgent::Codex);
|
||||
apply_signals(
|
||||
&mut st,
|
||||
sniffer.feed(b"\x1b]9;Codex wants to run tests\x07"),
|
||||
);
|
||||
let sess = st.agent_session.clone().unwrap();
|
||||
assert_eq!(sess.status, AgentStatus::Waiting);
|
||||
assert!(!sess.rich);
|
||||
|
||||
// Rich state present → the opaque ping is ignored.
|
||||
st.agent_session = Some(AgentSessionState {
|
||||
status: AgentStatus::Working,
|
||||
message: None,
|
||||
session_id: Some("sid".into()),
|
||||
rich: true,
|
||||
});
|
||||
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
|
||||
assert_eq!(
|
||||
st.agent_session.as_ref().unwrap().status,
|
||||
AgentStatus::Working
|
||||
);
|
||||
}
|
||||
|
||||
/// Attaching replays Size → Snapshot (→ Cwd) in order and installs the
|
||||
/// subscriber under a fresh epoch.
|
||||
#[test]
|
||||
@@ -2301,7 +2636,10 @@ mod tests {
|
||||
Arc::new(OutputGate::new()),
|
||||
Box::new(std::io::Cursor::new(b"tail".to_vec())),
|
||||
|| false, // no PTY here → treat the shell as foreground
|
||||
|| None,
|
||||
ForegroundProbes {
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
},
|
||||
Arc::new(DeathReporter::new(move || {
|
||||
dead_flag.store(true, Ordering::SeqCst)
|
||||
})),
|
||||
@@ -2335,7 +2673,10 @@ mod tests {
|
||||
Arc::new(OutputGate::new()),
|
||||
Box::new(std::io::Cursor::new(Vec::new())),
|
||||
|| false,
|
||||
|| None,
|
||||
ForegroundProbes {
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
},
|
||||
Arc::new(DeathReporter::new(move || dead_tx.send(()).unwrap())),
|
||||
);
|
||||
handle.join().unwrap();
|
||||
@@ -2358,7 +2699,10 @@ mod tests {
|
||||
Arc::new(OutputGate::new()),
|
||||
Box::new(std::io::Cursor::new(Vec::new())),
|
||||
|| false,
|
||||
|| None,
|
||||
ForegroundProbes {
|
||||
remote: Box::new(|| None),
|
||||
agent: Box::new(|| None),
|
||||
},
|
||||
Arc::new(DeathReporter::new(move || {
|
||||
dead_flag.store(true, Ordering::SeqCst)
|
||||
})),
|
||||
@@ -2414,6 +2758,25 @@ mod tests {
|
||||
assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once");
|
||||
}
|
||||
|
||||
/// Every spawned shell carries the `TTY7` marker, so the `tty7 agent-hook`
|
||||
/// emitter fires (it stays silent without it). This is the env side of the
|
||||
/// rich-status channel — a regression here silently breaks all hook-based
|
||||
/// agent status, which no other test would catch.
|
||||
#[test]
|
||||
fn spawned_shell_carries_the_tty7_marker() {
|
||||
let cmd = build_shell_command(None, &Some(PathBuf::from("/tmp")))
|
||||
.expect("build default shell command")
|
||||
.0;
|
||||
let tty7 = cmd
|
||||
.get_env(crate::core::agent_hooks::TTY7_ENV_MARKER)
|
||||
.and_then(|v| v.to_str());
|
||||
assert_eq!(
|
||||
tty7,
|
||||
Some(env!("CARGO_PKG_VERSION")),
|
||||
"the daemon must inject TTY7 into every spawned shell"
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_signals` writes sniffed cwd/shell state into the pane state.
|
||||
#[test]
|
||||
fn apply_signals_updates_state() {
|
||||
@@ -2424,7 +2787,7 @@ mod tests {
|
||||
&mut st,
|
||||
SniffSignals {
|
||||
cwd: Some(PathBuf::from("/tmp/x")),
|
||||
shell: None,
|
||||
..SniffSignals::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x")));
|
||||
@@ -2433,12 +2796,12 @@ mod tests {
|
||||
apply_signals(
|
||||
&mut st,
|
||||
SniffSignals {
|
||||
cwd: None,
|
||||
shell: Some(ShellState {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit_code: Some(0),
|
||||
}),
|
||||
..SniffSignals::default()
|
||||
},
|
||||
);
|
||||
assert!(st.shell.active && st.shell.at_prompt);
|
||||
|
||||
@@ -781,6 +781,17 @@ pub enum DaemonMsg {
|
||||
PaneList(Vec<PaneInfo>),
|
||||
/// The foreground remote context, or `None` when the pane is local / unknown.
|
||||
RemoteContext(Option<RemoteContext>),
|
||||
/// The third-party CLI coding agent currently running in the foreground
|
||||
/// (Claude Code, Codex, Gemini, …), or `None` when no known agent is running.
|
||||
/// Detected daemon-side from the foreground `argv` — see
|
||||
/// [`crate::core::cli_agent`].
|
||||
Agent(Option<crate::core::cli_agent::CLIAgent>),
|
||||
/// The rich per-session agent status (idle / working / waiting / done +
|
||||
/// native session id), sniffed daemon-side from the pane's OSC stream
|
||||
/// (tty7's sentinel events, with an opaque OSC 9/777 fallback) — see
|
||||
/// [`crate::core::cli_agent::AgentSessionState`]. `None` clears it (the
|
||||
/// agent exited).
|
||||
AgentStatus(Option<crate::core::cli_agent::AgentSessionState>),
|
||||
/// Reply to `EnsureLoopbackForward`.
|
||||
LoopbackForward(LoopbackForward),
|
||||
/// Reply to `ListLoopbackForwards` and `CloseLoopbackForward`.
|
||||
@@ -887,6 +898,10 @@ mod kind {
|
||||
// (15–19 reserved: WS3 auth extensions.)
|
||||
/// `ForwardList` — reply to the WS4 managed-forward messages.
|
||||
pub const FORWARD_LIST: u8 = 20;
|
||||
/// `Agent` — the foreground CLI coding agent detected on a pane (or its clear).
|
||||
pub const AGENT: u8 = 21;
|
||||
/// `AgentStatus` — the pane's rich agent-session status (or its clear).
|
||||
pub const AGENT_STATUS: u8 = 22;
|
||||
}
|
||||
|
||||
/// Write one framed message: `[u32 LE len][u8 kind][payload]`.
|
||||
@@ -1141,6 +1156,8 @@ impl DaemonMsg {
|
||||
DaemonMsg::RemoteContext(remote) => {
|
||||
write_frame(w, kind::REMOTE_CONTEXT, &to_json(remote)?)
|
||||
}
|
||||
DaemonMsg::Agent(agent) => write_frame(w, kind::AGENT, &to_json(agent)?),
|
||||
DaemonMsg::AgentStatus(state) => write_frame(w, kind::AGENT_STATUS, &to_json(state)?),
|
||||
DaemonMsg::LoopbackForward(forward) => {
|
||||
write_frame(w, kind::LOOPBACK_FORWARD, &to_json(forward)?)
|
||||
}
|
||||
@@ -1194,6 +1211,8 @@ impl DaemonMsg {
|
||||
},
|
||||
kind::PANE_LIST => DaemonMsg::PaneList(from_json(&payload)?),
|
||||
kind::REMOTE_CONTEXT => DaemonMsg::RemoteContext(from_json(&payload)?),
|
||||
kind::AGENT => DaemonMsg::Agent(from_json(&payload)?),
|
||||
kind::AGENT_STATUS => DaemonMsg::AgentStatus(from_json(&payload)?),
|
||||
kind::LOOPBACK_FORWARD => DaemonMsg::LoopbackForward(from_json(&payload)?),
|
||||
kind::LOOPBACK_FORWARD_LIST => DaemonMsg::LoopbackForwardList(from_json(&payload)?),
|
||||
kind::AUTH_PROMPT => {
|
||||
@@ -1467,6 +1486,16 @@ mod tests {
|
||||
target: "dev".into(),
|
||||
})),
|
||||
DaemonMsg::RemoteContext(None),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
|
||||
DaemonMsg::Agent(None),
|
||||
DaemonMsg::AgentStatus(Some(crate::core::cli_agent::AgentSessionState {
|
||||
status: crate::core::cli_agent::AgentStatus::Waiting,
|
||||
message: Some("Claude needs your permission to use Bash".into()),
|
||||
session_id: Some("abc-123".into()),
|
||||
rich: true,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
DaemonMsg::LoopbackForwardList(vec![LoopbackForwardInfo {
|
||||
id: LoopbackForwardId {
|
||||
|
||||
@@ -12,10 +12,10 @@ mod ui;
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::assets::Assets;
|
||||
use crate::ui::keymap;
|
||||
use gpui::*;
|
||||
use gpui_component::{ActiveTheme as _, Root, TitleBar};
|
||||
use gpui_component_assets::Assets;
|
||||
|
||||
/// Register the bundled Hack monospace faces with gpui's text system so the
|
||||
/// default `font_family` ("Hack") renders identically on every machine, with no
|
||||
@@ -245,6 +245,21 @@ fn enrich_path_from_login_shell() {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
|
||||
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
|
||||
// payload from stdin, writes one OSC sequence to the controlling terminal,
|
||||
// and exits — never touching config, the daemon, or the GUI. Checked first
|
||||
// so a hook can never accidentally boot a window.
|
||||
{
|
||||
let args: Vec<String> = std::env::args().skip(1).take(3).collect();
|
||||
if args.first().map(String::as_str) == Some("agent-hook") {
|
||||
if let [_, agent, event] = args.as_slice() {
|
||||
crate::core::agent_hooks::run_agent_hook(agent, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the config directory override (if any) up front, before any code
|
||||
// path touches config/session/history files (the daemon socket path resolves
|
||||
// under this dir too, so the order matters).
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A lightweight git snapshot for a pane's working directory — the current
|
||||
//! branch and the working-tree diff size — rendered as the sidebar row's third
|
||||
//! line (`⎇ feat/x +6 −5`): each session fronted with its branch and change
|
||||
//! count.
|
||||
//!
|
||||
//! Deliberately shell-out simple: one `git` invocation per field, run on a
|
||||
//! background thread by the caller (see [`crate::terminal::view`]) so the UI
|
||||
//! never blocks on a slow repo. Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status
|
||||
//! polling from ever taking `index.lock` and fighting a real git command the
|
||||
//! user is running. Returns `None` when the cwd isn't inside a git work tree,
|
||||
//! so the sidebar simply omits the line.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// A pane's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
|
||||
/// and untracked files don't contribute a line count.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GitStatus {
|
||||
/// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD
|
||||
/// is detached. Never empty.
|
||||
pub branch: String,
|
||||
/// Lines added across the working tree vs `HEAD`.
|
||||
pub added: u32,
|
||||
/// Lines removed across the working tree vs `HEAD`.
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// Compute the git snapshot for `cwd`, or `None` when it isn't a git work tree
|
||||
/// (or the path is gone). Blocking — call it on a background executor.
|
||||
pub fn compute(cwd: &Path) -> Option<GitStatus> {
|
||||
if !cwd.exists() {
|
||||
return None;
|
||||
}
|
||||
let branch = branch_name(cwd)?;
|
||||
let (added, removed) = diff_numstat(cwd).unwrap_or((0, 0));
|
||||
Some(GitStatus {
|
||||
branch,
|
||||
added,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
/// The current branch name, or a short sha for a detached HEAD. Doubles as the
|
||||
/// "is this a git repo" gate: both probes failing (not a work tree) yields
|
||||
/// `None`.
|
||||
fn branch_name(cwd: &Path) -> Option<String> {
|
||||
// On a branch — even before the first commit — `symbolic-ref` names it.
|
||||
if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
|
||||
let name = out.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
// Detached HEAD (or a rebase/bisect): fall back to the short commit sha.
|
||||
let sha = git(cwd, &["rev-parse", "--short", "HEAD"])?;
|
||||
let sha = sha.trim();
|
||||
(!sha.is_empty()).then(|| sha.to_string())
|
||||
}
|
||||
|
||||
/// Sum added/removed lines across the working tree vs `HEAD` from
|
||||
/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
|
||||
fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
|
||||
let out = git(cwd, &["diff", "--numstat", "HEAD"])?;
|
||||
let mut added = 0u32;
|
||||
let mut removed = 0u32;
|
||||
for line in out.lines() {
|
||||
let mut fields = line.split('\t');
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
added += n;
|
||||
}
|
||||
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
|
||||
removed += n;
|
||||
}
|
||||
}
|
||||
Some((added, removed))
|
||||
}
|
||||
|
||||
/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a
|
||||
/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
|
||||
/// truly read-only; stdin is nulled so a misconfigured git can't block on a
|
||||
/// prompt.
|
||||
fn git(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(cwd)
|
||||
.args(args)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tmp path that is not a git repo yields no status (and never panics).
|
||||
#[test]
|
||||
fn non_repo_is_none() {
|
||||
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
assert_eq!(compute(&dir), None);
|
||||
}
|
||||
|
||||
/// A path that doesn't exist is `None`, not a panic.
|
||||
#[test]
|
||||
fn missing_path_is_none() {
|
||||
assert_eq!(compute(Path::new("/no/such/tty7/path/here")), None);
|
||||
}
|
||||
|
||||
/// This repo (the crate root is inside the tty7 work tree) reports a branch,
|
||||
/// exercising the real `git` probe end-to-end.
|
||||
#[test]
|
||||
fn own_repo_has_a_branch() {
|
||||
let here = env!("CARGO_MANIFEST_DIR");
|
||||
if let Some(status) = compute(Path::new(here)) {
|
||||
assert!(!status.branch.is_empty());
|
||||
}
|
||||
// If the crate is built outside a work tree (e.g. a vendored tarball),
|
||||
// `None` is the correct answer and the assertion above is skipped.
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod element;
|
||||
pub mod fps;
|
||||
mod fuzzy;
|
||||
mod generator;
|
||||
pub(crate) mod git_status;
|
||||
mod highlight;
|
||||
mod history;
|
||||
mod hold;
|
||||
@@ -36,4 +37,5 @@ mod typeahead;
|
||||
pub mod view;
|
||||
|
||||
pub use remote::RemoteTerminal;
|
||||
pub(crate) use remote::notify_desktop;
|
||||
pub use size::TermSize;
|
||||
|
||||
@@ -35,6 +35,7 @@ use alacritty_terminal::vte::ansi;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
|
||||
use crate::core::osc::OscTokenizer;
|
||||
use crate::daemon::protocol::{
|
||||
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
|
||||
@@ -105,6 +106,8 @@ struct ReaderSignals {
|
||||
cwd: Arc<Mutex<Option<PathBuf>>>,
|
||||
shell: Arc<Mutex<ShellState>>,
|
||||
remote: Arc<Mutex<Option<RemoteContext>>>,
|
||||
agent: Arc<Mutex<Option<CLIAgent>>>,
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
child_exited: Arc<AtomicBool>,
|
||||
zle_reading: Arc<AtomicBool>,
|
||||
@@ -182,6 +185,15 @@ pub struct RemoteTerminal {
|
||||
/// arrives *after* an auto-supplied stored password means the server rejected
|
||||
/// it, so the sheet warns and offers to overwrite/clear the stale entry.
|
||||
auto_supplied_password: bool,
|
||||
/// The third-party CLI coding agent running in the pane's foreground, last
|
||||
/// reported by the daemon via `Agent` (detected from the foreground `argv`).
|
||||
/// `None` when no known agent runs. Drives the tab avatar's brand mark — see
|
||||
/// [`crate::core::cli_agent`].
|
||||
agent: Arc<Mutex<Option<CLIAgent>>>,
|
||||
/// The agent's rich session status (idle/working/waiting/done + native
|
||||
/// session id), last reported by the daemon via `AgentStatus`. Drives the
|
||||
/// status dot, "needs your input" notifications, and session resume.
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
reader_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -300,6 +312,8 @@ impl RemoteTerminal {
|
||||
let cwd: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
|
||||
let shell_state: Arc<Mutex<ShellState>> = Arc::new(Mutex::new(ShellState::default()));
|
||||
let remote_context: Arc<Mutex<Option<RemoteContext>>> = Arc::new(Mutex::new(None));
|
||||
let agent: Arc<Mutex<Option<CLIAgent>>> = Arc::new(Mutex::new(None));
|
||||
let agent_session: Arc<Mutex<Option<AgentSessionState>>> = Arc::new(Mutex::new(None));
|
||||
let exited_flag = Arc::new(AtomicBool::new(false));
|
||||
let child_exited = Arc::new(AtomicBool::new(false));
|
||||
let zle_reading = Arc::new(AtomicBool::new(false));
|
||||
@@ -315,6 +329,8 @@ impl RemoteTerminal {
|
||||
cwd: cwd.clone(),
|
||||
shell: shell_state.clone(),
|
||||
remote: remote_context.clone(),
|
||||
agent: agent.clone(),
|
||||
agent_session: agent_session.clone(),
|
||||
exited: exited_flag.clone(),
|
||||
child_exited: child_exited.clone(),
|
||||
zle_reading: zle_reading.clone(),
|
||||
@@ -341,6 +357,8 @@ impl RemoteTerminal {
|
||||
ssh_phase,
|
||||
ssh_endpoint: None,
|
||||
auto_supplied_password: false,
|
||||
agent,
|
||||
agent_session,
|
||||
reader_thread: Some(reader_thread),
|
||||
})
|
||||
}
|
||||
@@ -370,6 +388,8 @@ impl RemoteTerminal {
|
||||
cwd,
|
||||
shell,
|
||||
remote,
|
||||
agent,
|
||||
agent_session,
|
||||
exited: exited_flag,
|
||||
child_exited,
|
||||
zle_reading,
|
||||
@@ -625,6 +645,21 @@ impl RemoteTerminal {
|
||||
}
|
||||
proxy.send_event(AlacEvent::Wakeup);
|
||||
}
|
||||
DaemonMsg::Agent(a) => {
|
||||
flush_batch!();
|
||||
if let Ok(mut guard) = agent.lock() {
|
||||
*guard = a;
|
||||
}
|
||||
}
|
||||
DaemonMsg::AgentStatus(state) => {
|
||||
flush_batch!();
|
||||
if let Ok(mut guard) = agent_session.lock() {
|
||||
*guard = state;
|
||||
}
|
||||
// Status changes repaint the tab chip / sidebar
|
||||
// dot even when the pane printed nothing.
|
||||
proxy.send_event(AlacEvent::Wakeup);
|
||||
}
|
||||
DaemonMsg::Exited { .. } => {
|
||||
// Child gone: apply what it printed last, then
|
||||
// mark the emulator exited and flip the shared
|
||||
@@ -835,6 +870,20 @@ impl RemoteTerminal {
|
||||
/// Whether zle is reading the keyboard right now (live `133;B` seen, no
|
||||
/// later mark). See the field docs; this is the gate for writing the
|
||||
/// typeahead wipe without it echoing into the scrollback.
|
||||
/// The third-party CLI coding agent (Claude Code, Codex, …) running in the
|
||||
/// pane's foreground, as last reported by the daemon, or `None`. Cheap cache
|
||||
/// read — detection runs daemon-side. See [`crate::core::cli_agent`].
|
||||
pub fn foreground_agent(&self) -> Option<CLIAgent> {
|
||||
self.agent.lock().ok().and_then(|g| *g)
|
||||
}
|
||||
|
||||
/// The rich agent-session status (idle/working/waiting/done + native
|
||||
/// session id), as last reported by the daemon, or `None` when no agent
|
||||
/// session is live. Cheap cache read — sniffing runs daemon-side.
|
||||
pub fn agent_session(&self) -> Option<AgentSessionState> {
|
||||
self.agent_session.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
pub fn zle_reading(&self) -> bool {
|
||||
self.zle_reading.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -1312,7 +1361,7 @@ fn stale_mode_resets(mode: TermMode) -> Vec<u8> {
|
||||
///
|
||||
/// Note: `notify-rust`'s macOS backend uses the deprecated `NSUserNotification`,
|
||||
/// which is acceptable for a completion toast.
|
||||
pub(super) fn notify_desktop(title: Option<&str>, body: &str) {
|
||||
pub(crate) fn notify_desktop(title: Option<&str>, body: &str) {
|
||||
let summary = title.unwrap_or("tty7").to_string();
|
||||
let body = body.to_string();
|
||||
std::thread::spawn(move || {
|
||||
@@ -1377,36 +1426,22 @@ impl OscNotifyScanner {
|
||||
|
||||
/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` or
|
||||
/// `777;notify;Title;Body`) into a `(title, body)` notification, or `None` if it
|
||||
/// isn't a notification we surface.
|
||||
/// isn't a notification we surface. The parsing itself lives in
|
||||
/// [`crate::core::osc::parse_notification`] (shared with the daemon's agent
|
||||
/// sniffer); this wrapper additionally drops tty7's own agent-event sentinel —
|
||||
/// those payloads are machine-to-machine JSON for the daemon's state machine,
|
||||
/// and toasting them would show raw JSON to the user.
|
||||
fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
// OSC 9 ; <text> — iTerm2 / growl style; title-less, body is the text.
|
||||
if let Some(rest) = payload.strip_prefix(b"9;") {
|
||||
// ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress,
|
||||
// `9;9;<cwd>`, …); those aren't notifications, so skip a `<digit>;`/`<digit>`
|
||||
// leading field. A real message rarely starts with a bare single digit.
|
||||
let first = rest.split(|&b| b == b';').next().unwrap_or(rest);
|
||||
if first.len() == 1 && first[0].is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
let body = String::from_utf8_lossy(rest).into_owned();
|
||||
return (!body.is_empty()).then_some((None, body));
|
||||
if crate::core::cli_agent::parse_agent_event(payload).is_some() {
|
||||
return None;
|
||||
}
|
||||
// OSC 777 ; notify ; <title> ; <body> — urxvt style.
|
||||
if let Some(rest) = payload.strip_prefix(b"777;notify;") {
|
||||
let mut parts = rest.splitn(2, |&b| b == b';');
|
||||
let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned();
|
||||
let second = parts
|
||||
.next()
|
||||
.map(|b| String::from_utf8_lossy(b).into_owned());
|
||||
// With both fields present it's title + body; with only one it's a body-only
|
||||
// notification (some senders omit the title).
|
||||
let (title, body) = match second {
|
||||
Some(body) if !body.is_empty() => (Some(first), body),
|
||||
_ => (None, first),
|
||||
};
|
||||
return (!body.is_empty()).then_some((title, body));
|
||||
let (title, body) = crate::core::osc::parse_notification(payload)?;
|
||||
// A sentinel-titled payload whose JSON failed to parse is still not a
|
||||
// user-facing notification; never toast it.
|
||||
if title.as_deref() == Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
Some((title, body))
|
||||
}
|
||||
|
||||
/// Open a fresh connection to the daemon's listening endpoint. The endpoint is
|
||||
@@ -2123,6 +2158,81 @@ mod tests {
|
||||
assert!(at, "at_prompt should become true after the Prompt report");
|
||||
}
|
||||
|
||||
/// `foreground_agent` reflects the daemon's last `Agent` report — `None`
|
||||
/// before any report, the detected agent after one, and back to `None` when
|
||||
/// the agent exits (the daemon reports `Agent(None)`).
|
||||
#[test]
|
||||
fn foreground_agent_follows_daemon_agent_reports() {
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
|
||||
assert_eq!(term.foreground_agent(), None, "none before any report");
|
||||
|
||||
let poll = |want: Option<CLIAgent>| {
|
||||
for _ in 0..200 {
|
||||
if term.foreground_agent() == want {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
DaemonMsg::Agent(Some(CLIAgent::Claude))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
assert!(poll(Some(CLIAgent::Claude)), "agent report should surface");
|
||||
|
||||
DaemonMsg::Agent(None).encode(&mut daemon_side).unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
assert!(poll(None), "agent exit should clear it");
|
||||
}
|
||||
|
||||
/// `DaemonMsg::AgentStatus` frames must land in the client's session
|
||||
/// cache (and a `None` clear it) — the reader half of the rich-status
|
||||
/// channel the daemon's sniffer feeds.
|
||||
#[test]
|
||||
fn agent_session_follows_daemon_status_reports() {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus};
|
||||
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
assert_eq!(term.agent_session(), None, "none before any report");
|
||||
|
||||
let poll = |want: &dyn Fn(Option<AgentSessionState>) -> bool| {
|
||||
for _ in 0..200 {
|
||||
if want(term.agent_session()) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
DaemonMsg::AgentStatus(Some(AgentSessionState {
|
||||
status: AgentStatus::Waiting,
|
||||
message: Some("Claude needs your permission".into()),
|
||||
session_id: Some("sid-1".into()),
|
||||
rich: true,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
assert!(
|
||||
poll(&|s| s.is_some_and(|s| s.status == AgentStatus::Waiting
|
||||
&& s.session_id.as_deref() == Some("sid-1")
|
||||
&& s.rich)),
|
||||
"status report should surface with message + session id"
|
||||
);
|
||||
|
||||
DaemonMsg::AgentStatus(None)
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
assert!(poll(&|s| s.is_none()), "a None report clears the session");
|
||||
}
|
||||
|
||||
/// The typeahead wipe (^U) may only be written once zle actually reads the
|
||||
/// keyboard; the client learns that from a *live* `133;B` (prompt end) in
|
||||
/// the output stream. `133;D` (command done, but precmd hooks still running
|
||||
|
||||
@@ -224,6 +224,42 @@ pub struct TerminalView {
|
||||
/// long-running commands completed while the window is in the background.
|
||||
running_since: Option<std::time::Instant>,
|
||||
running_title: String,
|
||||
/// The coding agent (if any) detected during the current foreground-command
|
||||
/// episode, captured so its completion notification can be branded ("Claude
|
||||
/// Code finished" rather than a generic "command finished"). Set the moment
|
||||
/// the daemon reports an agent while a command runs; cleared when it ends.
|
||||
running_agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
/// The rich agent status last seen by the poll, so transitions (working →
|
||||
/// waiting, working → done) fire exactly one notification each and repaint
|
||||
/// the status dot.
|
||||
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
|
||||
/// When the current rich turn entered `Working`, for the "finished after
|
||||
/// Ns" copy on its `Done` notification.
|
||||
agent_turn_started: Option<std::time::Instant>,
|
||||
/// Whether this pane's agent ever reported over the rich sentinel channel.
|
||||
/// While true, the coarse process-exit "agent finished" notification is
|
||||
/// suppressed — the turn-level `stop` events already said it better.
|
||||
agent_was_rich: bool,
|
||||
/// Whether the agent's last finished turn (the green `Done` dot) is *unread*
|
||||
/// — a turn ended that the user hasn't looked at since. Set when a new turn
|
||||
/// finishes while this pane is unfocused; cleared the moment the pane gains
|
||||
/// focus (you're looking at it). The tab avatar only paints the Done dot
|
||||
/// while this is true, so a result you've already seen stops nagging. Blue
|
||||
/// (working) / amber (waiting) are unaffected — they track live state.
|
||||
agent_result_unread: bool,
|
||||
/// The pane's last-computed git snapshot (branch + working-tree diff size),
|
||||
/// shown as the sidebar row's third line. Computed off-thread by
|
||||
/// [`refresh_git_status`](Self::refresh_git_status) on a cwd change or a
|
||||
/// command finishing; `None` outside a git work tree (or before the first
|
||||
/// probe lands).
|
||||
git_status: Option<crate::terminal::git_status::GitStatus>,
|
||||
/// The cwd `git_status` was last computed (or scheduled) for, so the poll
|
||||
/// loop only reprobes when the working directory actually changes.
|
||||
git_status_cwd: Option<std::path::PathBuf>,
|
||||
/// Monotonic tag bumped on every git reprobe; a background result is dropped
|
||||
/// unless it still matches, so a slow probe from a since-changed cwd can't
|
||||
/// overwrite a fresher one (same guard as `completion_generation`).
|
||||
git_status_gen: u64,
|
||||
/// The inline command line editor. Live only while the shell sits idle
|
||||
/// at its prompt (`input_active`): there the terminal keeps keyboard focus and
|
||||
/// we run our own line editor (so we own Tab / ↑ / ↓ for completion and
|
||||
@@ -454,6 +490,15 @@ fn notify_command_finished(label: &str, elapsed: std::time::Duration) {
|
||||
super::remote::notify_desktop(Some("tty7"), &body);
|
||||
}
|
||||
|
||||
/// Post a branded "the agent finished" notification — the coding-agent form of
|
||||
/// [`notify_command_finished`], titled with the agent so it's obvious *which*
|
||||
/// session came back.
|
||||
fn notify_agent_finished(agent: crate::core::cli_agent::CLIAgent, elapsed: std::time::Duration) {
|
||||
let secs = elapsed.as_secs();
|
||||
let body = format!("Finished after {secs}s");
|
||||
super::remote::notify_desktop(Some(agent.display_name()), &body);
|
||||
}
|
||||
|
||||
/// Ring the OS system bell for the `Audible` bell mode. Returns `true` if a
|
||||
/// sound was actually requested, `false` on platforms without a portable beep
|
||||
/// (the caller then falls back to the visual flash so the bell is never silent).
|
||||
@@ -784,6 +829,9 @@ impl TerminalView {
|
||||
cx.on_focus_in(&focus_handle, window, |view, _window, cx| {
|
||||
view.focused = true;
|
||||
view.cursor_visible = true;
|
||||
// Looking at the pane marks its finished turn read, so the tab
|
||||
// avatar's green Done dot clears.
|
||||
view.agent_result_unread = false;
|
||||
view.report_focus_change(true);
|
||||
cx.notify();
|
||||
}),
|
||||
@@ -897,6 +945,14 @@ impl TerminalView {
|
||||
last_at_prompt: false,
|
||||
running_since: None,
|
||||
running_title: String::new(),
|
||||
running_agent: None,
|
||||
last_agent_status: None,
|
||||
agent_turn_started: None,
|
||||
agent_was_rich: false,
|
||||
agent_result_unread: false,
|
||||
git_status: None,
|
||||
git_status_cwd: None,
|
||||
git_status_gen: 0,
|
||||
cmd: CmdEditor::new(),
|
||||
typeahead: Typeahead::new(),
|
||||
hold: GapHold::new(),
|
||||
@@ -952,6 +1008,66 @@ impl TerminalView {
|
||||
self.terminal.remote_context()
|
||||
}
|
||||
|
||||
/// The coding agent running in this pane's foreground, or `None` when none
|
||||
/// is. Identity comes from the daemon's foreground-`argv` detection (plus
|
||||
/// the sentinel event channel, which can brand wrappers argv can't see
|
||||
/// through). The tab avatar brands the pane with it. See
|
||||
/// [`crate::core::cli_agent`].
|
||||
pub fn agent(&self) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
self.terminal.foreground_agent()
|
||||
}
|
||||
|
||||
/// The agent's rich session status (idle / working / waiting / done +
|
||||
/// native session id), when the pane's agent reports events over the
|
||||
/// sentinel OSC channel (or the opaque notification fallback). Drives the
|
||||
/// avatar's status dot, "needs your input" notifications, and resume. An
|
||||
/// output-idle *guess* is deliberately still absent — agents are quietest
|
||||
/// while thinking, so only agent-reported state is trusted.
|
||||
pub fn agent_session(&self) -> Option<crate::core::cli_agent::AgentSessionState> {
|
||||
self.terminal.agent_session()
|
||||
}
|
||||
|
||||
/// Whether this pane's finished turn (the green `Done` dot) is unread — a
|
||||
/// turn ended that the user hasn't looked at since (see
|
||||
/// [`agent_result_unread`](Self::agent_result_unread) field). Drives the
|
||||
/// avatar dot's unread halo; the dot itself shows for any `Done`.
|
||||
pub fn agent_result_unread(&self) -> bool {
|
||||
self.agent_result_unread
|
||||
}
|
||||
|
||||
/// The pane's last-computed git snapshot (branch + working-tree diff), for
|
||||
/// the sidebar row's branch line. `None` outside a git work tree or before
|
||||
/// the first background probe lands.
|
||||
pub fn git_status(&self) -> Option<crate::terminal::git_status::GitStatus> {
|
||||
self.git_status.clone()
|
||||
}
|
||||
|
||||
/// The current grid selection as text, if any non-blank one exists — the
|
||||
/// source for "Agent: Send Selection".
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.terminal
|
||||
.term
|
||||
.lock()
|
||||
.selection_to_string()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Deliver a built prompt into this pane's PTY as a bracketed paste + CR —
|
||||
/// the submit path for the agent context-feed commands. See
|
||||
/// [`crate::core::agent_prompt::submit_bytes`].
|
||||
pub fn send_agent_prompt(&self, prompt: &str) {
|
||||
self.terminal
|
||||
.write(crate::core::agent_prompt::submit_bytes(prompt));
|
||||
}
|
||||
|
||||
/// Type one command line + Enter into the pane's PTY, as if the user had.
|
||||
/// Used by session restore to hand a fresh shell an agent resume command;
|
||||
/// the bytes queue in the PTY until the (possibly still-starting) shell
|
||||
/// reads them.
|
||||
pub fn run_command_line(&self, cmd: &str) {
|
||||
self.terminal.write(format!("{cmd}\r").into_bytes());
|
||||
}
|
||||
|
||||
/// The shell this pane was explicitly spawned with (new-tab dropdown pick),
|
||||
/// so splits can inherit it. `None` → the default shell.
|
||||
pub fn shell_spec(&self) -> Option<ShellSpec> {
|
||||
@@ -2268,34 +2384,193 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// Whether the configured notification policy allows a post right now:
|
||||
// never / only-when-unfocused / always. Shared by the command-finished,
|
||||
// agent-finished, and agent-waiting notifications.
|
||||
let notify_allowed = match cx.global::<Config>().notify_on_command_finish {
|
||||
NotifyMode::Never => false,
|
||||
NotifyMode::Unfocused => !window.is_window_active(),
|
||||
NotifyMode::Always => true,
|
||||
};
|
||||
|
||||
// "Command finished" notification: a foreground command (not at prompt)
|
||||
// that ran long and finished while the window was in the background.
|
||||
// that ran long and finished while the window was in the background. When
|
||||
// the command was a recognized coding agent, brand the notification with
|
||||
// the agent instead of the generic "command finished" copy.
|
||||
let running = !at_prompt;
|
||||
// While a command runs, latch the agent the daemon reports for it — the
|
||||
// detection poll can land a beat after the command starts, so capture it
|
||||
// whenever it appears rather than only at the start edge.
|
||||
if running && self.running_agent.is_none() {
|
||||
self.running_agent = self.terminal.foreground_agent();
|
||||
}
|
||||
// A command finishing (back-to-prompt edge) may have edited files or
|
||||
// switched branch, so reprobe git after it — captured before the match
|
||||
// below clears `running_since`.
|
||||
let cmd_finished = self.running_since.is_some() && !running;
|
||||
match (self.running_since, running) {
|
||||
(None, true) => {
|
||||
self.running_since = Some(std::time::Instant::now());
|
||||
self.running_title = self.title.clone();
|
||||
self.running_agent = self.terminal.foreground_agent();
|
||||
}
|
||||
(Some(start), false) => {
|
||||
let elapsed = start.elapsed();
|
||||
let title = std::mem::take(&mut self.running_title);
|
||||
let agent = self.running_agent.take();
|
||||
self.running_since = None;
|
||||
// Gate on the configured policy: never / only-when-unfocused /
|
||||
// always. The configured long-command floor still applies
|
||||
// regardless.
|
||||
let cfg = cx.global::<Config>();
|
||||
let notify = match cfg.notify_on_command_finish {
|
||||
NotifyMode::Never => false,
|
||||
NotifyMode::Unfocused => !window.is_window_active(),
|
||||
NotifyMode::Always => true,
|
||||
};
|
||||
let threshold = std::time::Duration::from_secs(cfg.notify_threshold_secs);
|
||||
if elapsed >= threshold && notify {
|
||||
notify_command_finished(&title, elapsed);
|
||||
if notify_allowed {
|
||||
match agent {
|
||||
// A rich-channel agent already announced each turn's
|
||||
// end (`stop` events below); a second "finished" on
|
||||
// process exit would be noise.
|
||||
Some(_) if self.agent_was_rich => {}
|
||||
// An agent session ends the moment it finishes — no
|
||||
// duration floor: "Claude Code finished" is worth saying
|
||||
// even for a quick turn you stepped away from.
|
||||
Some(agent) => notify_agent_finished(agent, elapsed),
|
||||
None => {
|
||||
let threshold = std::time::Duration::from_secs(
|
||||
cx.global::<Config>().notify_threshold_secs,
|
||||
);
|
||||
if elapsed >= threshold {
|
||||
notify_command_finished(&title, elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let turn_finished = self.poll_agent_status(notify_allowed, cx);
|
||||
|
||||
// Refresh the sidebar's git branch/diff line when the working directory
|
||||
// changed (a `cd`), a command just finished, or an agent turn ended —
|
||||
// an agent's session is one long foreground command, so its edits would
|
||||
// otherwise stay invisible until it exits. All rare edges, so the
|
||||
// off-thread `git` shell-out runs seldom, not every 300ms tick.
|
||||
let cwd_now = self.cwd();
|
||||
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
|
||||
self.refresh_git_status(cwd_now, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off an off-thread git probe for `cwd` and fold the result back on
|
||||
/// the main thread, tagged with a generation so a stale probe (cwd changed
|
||||
/// meanwhile) is dropped. Clears the status when there's no cwd (e.g. a
|
||||
/// native-SSH pane pre-OSC-7, where a local `git` would be meaningless).
|
||||
fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
|
||||
self.git_status_cwd = cwd.clone();
|
||||
let Some(cwd) = cwd else {
|
||||
if self.git_status.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.git_status_gen += 1;
|
||||
let generation = self.git_status_gen;
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.spawn(async move { crate::terminal::git_status::compute(&cwd) })
|
||||
.await;
|
||||
let _ = this.update(cx, |view, cx| {
|
||||
// Drop a probe whose cwd has since been superseded.
|
||||
if view.git_status_gen != generation {
|
||||
return;
|
||||
}
|
||||
if view.git_status != result {
|
||||
view.git_status = result;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Fold the pane's rich agent status into turn-level notifications and the
|
||||
/// status dot. Runs on the same cadence as the notification poll above.
|
||||
///
|
||||
/// Only *transitions* act: entering `Waiting` says the agent needs you
|
||||
/// (the reason attached), and a `Working → Done` edge says the turn
|
||||
/// finished — with its duration when we saw it start. Non-rich (fallback)
|
||||
/// state paints the dot but stays silent: the agent's own OSC notification
|
||||
/// was already toasted by the reader thread, and echoing it would double
|
||||
/// up. Attach replays land as a bare status with no observed transition
|
||||
/// history, so a restored `Done` never re-notifies.
|
||||
///
|
||||
/// Returns whether a turn just ended (a transition *into* `Done`) — the
|
||||
/// caller uses it to reprobe git: an agent's whole session is one long
|
||||
/// foreground command, so the back-to-prompt edge that normally refreshes
|
||||
/// the branch/diff line never fires while it works.
|
||||
fn poll_agent_status(&mut self, notify_allowed: bool, cx: &mut Context<Self>) -> bool {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
|
||||
let session = self.terminal.agent_session();
|
||||
if session.as_ref().is_some_and(|s| s.rich) {
|
||||
self.agent_was_rich = true;
|
||||
}
|
||||
if self.terminal.foreground_agent().is_none() && session.is_none() {
|
||||
self.agent_was_rich = false;
|
||||
}
|
||||
|
||||
let status = session.as_ref().map(|s| s.status);
|
||||
if status == self.last_agent_status {
|
||||
return false;
|
||||
}
|
||||
let prev = std::mem::replace(&mut self.last_agent_status, status);
|
||||
let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done);
|
||||
|
||||
// Read/unread for the green Done dot: a turn just finished is "unread"
|
||||
// only if you weren't looking (focused pane = you watched it finish, so
|
||||
// it's already read). Any non-Done status has no result to be unread.
|
||||
match status {
|
||||
Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => {
|
||||
self.agent_result_unread = !self.focused;
|
||||
}
|
||||
Some(AgentStatus::Done) => {}
|
||||
_ => self.agent_result_unread = false,
|
||||
}
|
||||
|
||||
let rich = session.as_ref().is_some_and(|s| s.rich);
|
||||
let agent_name = self
|
||||
.terminal
|
||||
.foreground_agent()
|
||||
.map(|a| a.display_name())
|
||||
.unwrap_or("Agent");
|
||||
match status {
|
||||
Some(AgentStatus::Working) => {
|
||||
self.agent_turn_started = Some(std::time::Instant::now());
|
||||
}
|
||||
Some(AgentStatus::Waiting) if rich && notify_allowed => {
|
||||
let body = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.message.clone())
|
||||
.unwrap_or_else(|| "Waiting for your input".to_string());
|
||||
super::remote::notify_desktop(Some(agent_name), &body);
|
||||
}
|
||||
// Done only counts off an *observed* turn (working/waiting seen
|
||||
// live), so an attach replay of old state stays quiet.
|
||||
Some(AgentStatus::Done)
|
||||
if rich
|
||||
&& notify_allowed
|
||||
&& matches!(
|
||||
prev,
|
||||
Some(AgentStatus::Working) | Some(AgentStatus::Waiting)
|
||||
) =>
|
||||
{
|
||||
let body = match self.agent_turn_started.take() {
|
||||
Some(start) => format!("Finished after {}s", start.elapsed().as_secs()),
|
||||
None => "Turn finished".to_string(),
|
||||
};
|
||||
super::remote::notify_desktop(Some(agent_name), &body);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Status changed: repaint so the avatar dot / sidebar line track it.
|
||||
cx.notify();
|
||||
turn_finished
|
||||
}
|
||||
|
||||
/// True when the shell sits idle at its prompt: the PTY's foreground process
|
||||
|
||||
@@ -130,6 +130,75 @@ impl Tab {
|
||||
};
|
||||
leaf.map(|l| l.read(cx).title.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The git snapshot (branch + working-tree diff) of the tab's label-driving
|
||||
/// terminal — the focused leaf with a `window`, else the first — for the
|
||||
/// sidebar row's branch line (the branch and change count shown under the
|
||||
/// title). `None` when that leaf isn't inside a git work tree, or before
|
||||
/// its first probe lands.
|
||||
pub(crate) fn git_status(
|
||||
&self,
|
||||
window: Option<&Window>,
|
||||
cx: &App,
|
||||
) -> Option<crate::terminal::git_status::GitStatus> {
|
||||
let leaf = match window {
|
||||
Some(window) => self.pane.focused_or_first(window, cx),
|
||||
None => self.pane.first_leaf(),
|
||||
}?;
|
||||
leaf.read(cx).git_status()
|
||||
}
|
||||
|
||||
/// The coding agent running in this tab, or `None`. Any leaf counts (a
|
||||
/// split with a shell on the left and Claude on the right is an agent
|
||||
/// tab); the first agent leaf in tree order wins. Drives the tab avatar's
|
||||
/// brand mark.
|
||||
pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
self.pane
|
||||
.leaves()
|
||||
.into_iter()
|
||||
.find_map(|l| l.read(cx).agent())
|
||||
}
|
||||
|
||||
/// The tab's most urgent agent status across its leaves — waiting beats
|
||||
/// working beats done beats idle — or `None` when no leaf runs an agent.
|
||||
/// The green `Done` state always shows (a finished turn stays visible until
|
||||
/// the next one); [`agent_result_unread`](Self::agent_result_unread) then
|
||||
/// says whether to emphasize it as unread. Drives the avatar dot and the
|
||||
/// sidebar counts.
|
||||
pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
let urgency = |s: AgentStatus| match s {
|
||||
AgentStatus::Waiting => 3,
|
||||
AgentStatus::Working => 2,
|
||||
AgentStatus::Done => 1,
|
||||
AgentStatus::Idle => 0,
|
||||
};
|
||||
self.pane
|
||||
.leaves()
|
||||
.into_iter()
|
||||
.filter(|l| l.read(cx).agent().is_some())
|
||||
.map(|l| {
|
||||
l.read(cx)
|
||||
.agent_session()
|
||||
.map(|s| s.status)
|
||||
.unwrap_or(AgentStatus::Idle)
|
||||
})
|
||||
.max_by_key(|s| urgency(*s))
|
||||
}
|
||||
|
||||
/// Whether the tab's shown status is an *unread* finished turn — a `Done`
|
||||
/// the user hasn't looked at since. Drives the avatar dot's unread halo:
|
||||
/// the green dot stays either way, the halo just says "new, come look."
|
||||
/// Only meaningful when the shown status is `Done`.
|
||||
pub(crate) fn agent_result_unread(&self, cx: &App) -> bool {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
self.agent_status(cx) == Some(AgentStatus::Done)
|
||||
&& self.pane.leaves().into_iter().any(|l| {
|
||||
let v = l.read(cx);
|
||||
v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
|
||||
&& v.agent_result_unread()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// In-progress inline rename of a tab (double-click a tab label). Holds the
|
||||
@@ -2021,6 +2090,25 @@ impl Tty7App {
|
||||
}
|
||||
SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx),
|
||||
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
|
||||
SendSelectionToAgent => self.send_selection_to_agent(window, cx),
|
||||
SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
|
||||
InstallClaudeHooks => {
|
||||
// Synchronous file edit; report the outcome as a toast either
|
||||
// way. Installing over an existing install just refreshes the
|
||||
// entries (e.g. after the tty7 binary moved) — say so.
|
||||
let refreshed = crate::core::agent_hooks::claude_hooks_installed();
|
||||
match crate::core::agent_hooks::install_claude_hooks() {
|
||||
Ok(summary) if refreshed => crate::terminal::notify_desktop(
|
||||
Some("tty7"),
|
||||
&format!("Refreshed: {summary}"),
|
||||
),
|
||||
Ok(summary) => crate::terminal::notify_desktop(Some("tty7"), &summary),
|
||||
Err(e) => crate::terminal::notify_desktop(
|
||||
Some("tty7"),
|
||||
&format!("Claude Code hook install failed: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
// Handled inside `PaletteView` (opens a sub-list); these never emit a
|
||||
// `Confirm` for this variant, so they never reach here.
|
||||
OpenThemePicker | OpenSshConnectInput => {}
|
||||
@@ -2028,6 +2116,108 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Agent context feed (palette: "Agent: …") -------------------------
|
||||
|
||||
/// The pane the agent-feed commands deliver to: the first leaf running a
|
||||
/// recognized coding agent, preferring the active tab, then any tab. `None`
|
||||
/// when no agent runs anywhere.
|
||||
fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
|
||||
let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some();
|
||||
if let Some(tab) = self.tabs.get(self.active)
|
||||
&& let Some(leaf) = tab.pane.leaves().into_iter().find(runs_agent)
|
||||
{
|
||||
return Some(leaf);
|
||||
}
|
||||
self.tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != self.active)
|
||||
.flat_map(|(_, t)| t.pane.leaves())
|
||||
.find(runs_agent)
|
||||
}
|
||||
|
||||
/// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to
|
||||
/// the front so the user sees the turn start. Toasts when no agent runs.
|
||||
fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(target) = self.agent_target_leaf(cx) else {
|
||||
crate::terminal::notify_desktop(
|
||||
Some("tty7"),
|
||||
"No running coding agent found — start one (claude, codex, …) in a pane first.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
target.read(cx).send_agent_prompt(prompt);
|
||||
if let Some(i) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.pane.leaves().contains(&target))
|
||||
{
|
||||
self.activate(i, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Agent: Send Selection" — the focused pane's selection, phrased as a
|
||||
/// prompt, into the running agent's pane (the context-feed idea).
|
||||
fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let source = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx));
|
||||
let (selection, cwd) = match &source {
|
||||
Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()),
|
||||
None => (None, None),
|
||||
};
|
||||
let Some(selection) = selection else {
|
||||
crate::terminal::notify_desktop(
|
||||
Some("tty7"),
|
||||
"Nothing selected — select some terminal output first.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
|
||||
if let Some(prompt) =
|
||||
crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref())
|
||||
{
|
||||
self.deliver_agent_prompt(&prompt, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Agent: Send Git Diff for Review" — the focused pane's repo diff
|
||||
/// (unstaged + staged), phrased as a review prompt, into the agent's pane.
|
||||
fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let cwd = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.and_then(|view| view.read(cx).cwd());
|
||||
let Some(cwd) = cwd else {
|
||||
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
|
||||
return;
|
||||
};
|
||||
// Unstaged + staged, concatenated — "everything not yet committed",
|
||||
// which is what a review pass wants. Both invocations are quick; the
|
||||
// prompt builder caps runaway diffs.
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
|
||||
let cwd_s = cwd.to_string_lossy().into_owned();
|
||||
match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
|
||||
Some(prompt) => self.deliver_agent_prompt(&prompt, window, cx),
|
||||
None => crate::terminal::notify_desktop(
|
||||
Some("tty7"),
|
||||
&format!("No uncommitted changes in {cwd_s} (or not a git repository)."),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Settings tab (Cmd+,) -------------------------------------------
|
||||
|
||||
/// Toggle the settings overlay (Cmd+,). If it's already open, close it;
|
||||
@@ -3309,6 +3499,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
// reconnected on restore (FR-E4/C2); `None` for local panes. A
|
||||
// live pane reattaches by `pane_id` and never needs this.
|
||||
ssh_spec: view.ssh_spec(),
|
||||
// The running agent + its native session id (when its hooks
|
||||
// reported one), so a pane the daemon loses can resume the
|
||||
// agent conversation instead of just reopening a shell.
|
||||
agent: view.agent(),
|
||||
agent_session_id: view.agent_session().and_then(|s| s.session_id),
|
||||
}
|
||||
}
|
||||
Pane::Split {
|
||||
@@ -3328,6 +3523,8 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
cwd: None,
|
||||
pane_id: None,
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3390,6 +3587,8 @@ fn session_to_pane(
|
||||
cwd,
|
||||
pane_id,
|
||||
ssh_spec,
|
||||
agent,
|
||||
agent_session_id,
|
||||
} => {
|
||||
// Only restore the pane id when the daemon confirms it's still live;
|
||||
// a stale id (daemon restarted, pane killed) falls back to a spawn.
|
||||
@@ -3412,6 +3611,19 @@ fn session_to_pane(
|
||||
// A shell pick isn't persisted in the session, so a stale pane that
|
||||
// must respawn comes back on the default shell.
|
||||
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
|
||||
// A pane that could NOT re-attach lost its running agent with the
|
||||
// daemon; when we captured that agent's native session id, hand
|
||||
// the fresh shell its resume command so the conversation picks up
|
||||
// where it left off (cmux's auto-resume, config-gated). The bytes
|
||||
// sit in the PTY input queue until the shell reads its first
|
||||
// command — same mechanism as tmux send-keys at spawn.
|
||||
if restore.is_none()
|
||||
&& cx.global::<Config>().restore_agent_sessions
|
||||
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
|
||||
&& let Some(cmd) = agent.resume_command(id)
|
||||
{
|
||||
view.read(cx).run_command_line(&cmd);
|
||||
}
|
||||
Pane::leaf(view)
|
||||
}
|
||||
SessionPane::Split { axis, ratio, a, b } => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! The app's [`gpui::AssetSource`]: tty7's own bundled icons layered over
|
||||
//! gpui-component's icon set.
|
||||
//!
|
||||
//! gpui-component ships the generic UI glyphs (close, chevrons, `bot`, …) via
|
||||
//! [`gpui_component_assets::Assets`]. tty7 adds a small set of third-party
|
||||
//! coding-agent brand marks (`icons/agents/*.svg`) for the tab avatars — see
|
||||
//! [`crate::core::cli_agent::CLIAgent::icon_path`]. Rather than fork the
|
||||
//! upstream asset crate to carry app-specific brand art, this source resolves
|
||||
//! tty7's icons first and delegates everything else downstream, so both sets
|
||||
//! load through the single `AssetSource` gpui allows.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
|
||||
/// tty7's asset source. Registered once in `main` via `with_assets`.
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
|
||||
if let Some(bytes) = agent_icon(path) {
|
||||
return Ok(Some(Cow::Borrowed(bytes)));
|
||||
}
|
||||
gpui_component_assets::Assets.load(path)
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
// Only gpui-component enumerates its icons; tty7's brand marks are
|
||||
// referenced by explicit path, never listed, so the downstream set is
|
||||
// the whole answer.
|
||||
gpui_component_assets::Assets.list(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a bundled agent brand mark, or `None` if `path` isn't one of
|
||||
/// ours. Kept as an explicit match (rather than `rust-embed`) because the set is
|
||||
/// tiny and fixed, and `include_bytes!` needs no extra build dependency.
|
||||
fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
let bytes: &'static [u8] = match path {
|
||||
// Flush `>_` prompt glyph for the plain-shell tab avatar (Lucide's
|
||||
// unboxed `terminal`, which gpui-component doesn't bundle — it only
|
||||
// ships the boxed `square-terminal`).
|
||||
"icons/terminal.svg" => include_bytes!("../../assets/icons/terminal.svg"),
|
||||
// Lucide's `git-branch`, for the sidebar row's branch line (gpui-component
|
||||
// doesn't bundle a git glyph).
|
||||
"icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"),
|
||||
"icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"),
|
||||
"icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"),
|
||||
"icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"),
|
||||
"icons/agents/amp.svg" => include_bytes!("../../assets/icons/agents/amp.svg"),
|
||||
"icons/agents/opencode.svg" => include_bytes!("../../assets/icons/agents/opencode.svg"),
|
||||
"icons/agents/copilot.svg" => include_bytes!("../../assets/icons/agents/copilot.svg"),
|
||||
"icons/agents/cursor.svg" => include_bytes!("../../assets/icons/agents/cursor.svg"),
|
||||
"icons/agents/goose.svg" => include_bytes!("../../assets/icons/agents/goose.svg"),
|
||||
"icons/agents/droid.svg" => include_bytes!("../../assets/icons/agents/droid.svg"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes)
|
||||
}
|
||||
@@ -185,6 +185,8 @@ mod tests {
|
||||
cwd: cwd.map(PathBuf::from),
|
||||
pane_id: None,
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! depends back on `ui`.
|
||||
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod forwards;
|
||||
pub mod hints;
|
||||
pub mod home;
|
||||
|
||||
@@ -60,6 +60,15 @@ pub enum CommandKind {
|
||||
ToggleSftp,
|
||||
/// Reconnect a dead native-SSH pane in place (WS6, FR-E4).
|
||||
RestartSshSession,
|
||||
/// Send the focused pane's selection to a running CLI coding agent's pane
|
||||
/// as a prompt (build error → agent, the review-feed idea).
|
||||
SendSelectionToAgent,
|
||||
/// Send the repo's uncommitted `git diff` (from the focused pane's cwd) to
|
||||
/// a running CLI coding agent's pane as a review prompt.
|
||||
SendGitDiffToAgent,
|
||||
/// Wire tty7's Claude Code hooks into `~/.claude/settings.json` so panes
|
||||
/// running `claude` report rich status (working / needs input / done).
|
||||
InstallClaudeHooks,
|
||||
/// Opens the theme sub-list (a nested palette). Handled in `PaletteView`.
|
||||
OpenThemePicker,
|
||||
/// Opens a typed SSH connection sub-list. Handled in `PaletteView`.
|
||||
@@ -131,7 +140,10 @@ impl CommandKind {
|
||||
RestartDaemon => "RestartDaemon",
|
||||
ToggleSftp => "ToggleSftp",
|
||||
RestartSshSession => "RestartSshSession",
|
||||
FindInTerminal
|
||||
SendSelectionToAgent
|
||||
| SendGitDiffToAgent
|
||||
| InstallClaudeHooks
|
||||
| FindInTerminal
|
||||
| OpenThemePicker
|
||||
| OpenSshConnectInput
|
||||
| OpenSshConnect(_)
|
||||
@@ -214,6 +226,12 @@ impl Command {
|
||||
Command::new("Open Settings", OpenSettings),
|
||||
Command::new("Reset Font Size", ResetFontSize),
|
||||
Command::new("Restart Daemon…", RestartDaemon),
|
||||
Command::new("Agent: Send Selection", SendSelectionToAgent)
|
||||
.with_subtitle("selection → running coding agent"),
|
||||
Command::new("Agent: Send Git Diff for Review", SendGitDiffToAgent)
|
||||
.with_subtitle("git diff → running coding agent"),
|
||||
Command::new("Agent: Install Claude Code Hooks", InstallClaudeHooks)
|
||||
.with_subtitle("rich status + resume for claude"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -71,13 +71,66 @@ impl Tty7App {
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.p_1p5()
|
||||
// Tight row-to-row spacing so the tabs read as one list, not a set
|
||||
// of far-apart cards (each row already has its own inner padding).
|
||||
.gap_0p5();
|
||||
|
||||
for (i, tab) in self.tabs.iter().enumerate() {
|
||||
let is_active = i == active;
|
||||
let label = self.tab_label(tab, i, Some(window), cx);
|
||||
// SSH status dot (PRD FR-E2).
|
||||
// No status/cwd text line under the title: the avatar's status dot
|
||||
// already carries working/waiting/done, and the title + git branch
|
||||
// line carry the location — a "Working…" or cwd line would just be
|
||||
// noise. The row is title + (optional) branch line, nothing else.
|
||||
// Leading avatar inputs: the SSH connection-status colour (PRD
|
||||
// FR-E2) and the coding agent running in the tab, if any — the
|
||||
// avatar brands the row by whichever applies.
|
||||
let ssh_dot = self.tab_ssh_dot(tab, cx);
|
||||
let agent = tab.agent(cx);
|
||||
let agent_status = tab.agent_status(cx);
|
||||
let agent_unread = tab.agent_result_unread(cx);
|
||||
// Third line (when the pane's cwd is inside a git work tree): the
|
||||
// branch, then the working-tree diff as green `+N` / red `−N`
|
||||
// badges — a per-session branch row. Built here so the row can
|
||||
// grow to fit it (a non-repo pane keeps the compact two-line row).
|
||||
let git_line = tab.git_status(Some(window), cx).map(|g| {
|
||||
let mut line = h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
gpui::svg()
|
||||
.path("icons/git-branch.svg")
|
||||
.flex_shrink_0()
|
||||
.size(px(11.))
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
// Branch name flexes and truncates; the badges stay pinned
|
||||
// to the right (a long branch ellipsizes, the counts don't).
|
||||
.child(div().flex_1().min_w_0().truncate().child(g.branch.clone()));
|
||||
// Diff counts in plain (not bold) coloured text — a quiet
|
||||
// green/red readout, not a loud badge, so a big `+1590` doesn't
|
||||
// dominate the row.
|
||||
if g.added > 0 {
|
||||
line = line.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().success)
|
||||
.child(format!("+{}", g.added)),
|
||||
);
|
||||
}
|
||||
if g.removed > 0 {
|
||||
line = line.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(format!("−{}", g.removed)),
|
||||
);
|
||||
}
|
||||
line
|
||||
});
|
||||
// Filter by the search box; matching is on the visible label. The row
|
||||
// keeps its real index `i`, so activate/close/move still hit the right
|
||||
// tab even when the list is narrowed.
|
||||
@@ -105,17 +158,25 @@ impl Tty7App {
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(Input::new(&input).appearance(false))
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
None => v_flex()
|
||||
.id(("sidebar-label", i))
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
// Ellipsis-truncate so a long label degrades gracefully in the
|
||||
// fixed-width rail rather than hard-clipping mid-glyph.
|
||||
.truncate()
|
||||
.text_sm()
|
||||
// Active row carries a hair more weight, matching the chip.
|
||||
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
|
||||
.child(label)
|
||||
// A touch of air between the title and branch lines.
|
||||
.gap(px(2.5))
|
||||
// Title line — ellipsis-truncate so a long label degrades
|
||||
// gracefully in the fixed-width rail rather than hard-clipping.
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
// Active row carries a hair more weight, matching the chip.
|
||||
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
|
||||
.child(label),
|
||||
)
|
||||
// Branch + diff line, when the pane sits in a git repo.
|
||||
.children(git_line)
|
||||
// Single click activates; double click starts a rename.
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
@@ -148,11 +209,17 @@ impl Tty7App {
|
||||
// hover without touching siblings (same trick as the chip).
|
||||
.group(SharedString::from(format!("tab-row-{i}")))
|
||||
.w_full()
|
||||
.h(px(34.))
|
||||
// Size to content with a small, uniform vertical padding rather
|
||||
// than forcing a fixed height: a one-line shell tab is a short
|
||||
// row, a two/three-line agent tab is a taller one. The *padding*
|
||||
// is what stays consistent, so rows read as harmonious even
|
||||
// though a single-line tab no longer gets padded out into a big
|
||||
// half-empty box.
|
||||
.py_1p5()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_1p5()
|
||||
.pl_3()
|
||||
.gap_2()
|
||||
.pl_2()
|
||||
.pr_1p5()
|
||||
.rounded_lg()
|
||||
// Sidebar-surface token scheme (gpui-component's Sidebar
|
||||
@@ -182,10 +249,8 @@ impl Tty7App {
|
||||
this.activate(i, window, cx);
|
||||
}),
|
||||
)
|
||||
// Leading SSH status dot when this tab hosts an SSH session.
|
||||
.when_some(ssh_dot, |c, color| {
|
||||
c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color))
|
||||
})
|
||||
// Leading avatar: agent brand mark, SSH status, or shell glyph.
|
||||
.child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx))
|
||||
.child(label_region)
|
||||
// Trailing slot: while the shortcut hints are armed it shows the
|
||||
// row's ⌘N switch digit; otherwise the close affordance — always
|
||||
|
||||
@@ -28,7 +28,7 @@ const KEEP_SEGMENTS: usize = 3;
|
||||
/// Abbreviate a leading `$HOME` to `~` (an integrated shell usually already
|
||||
/// does this, but absolute paths from other shells won't be). Borrows when
|
||||
/// there's nothing to rewrite.
|
||||
fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
use std::borrow::Cow;
|
||||
if path.starts_with('~') {
|
||||
return Cow::Borrowed(path);
|
||||
@@ -147,6 +147,127 @@ impl Render for DragTab {
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The status dot pinned to an agent avatar's bottom-right corner: a solid
|
||||
/// `rgb` disc with a surface-colored separator ring so it reads as sitting
|
||||
/// on the badge. When `unread` (a finished turn you haven't looked at), the
|
||||
/// dot gains a crisp outer ring of the same hue — the dot's separator ring
|
||||
/// becomes the gap, so it reads as a clean target (core · gap · ring), a
|
||||
/// sharper "come look" than a soft halo. `size` is the avatar edge.
|
||||
fn status_dot(rgb: u32, unread: bool, size: f32, cx: &App) -> gpui::AnyElement {
|
||||
let d = (size * 0.42).max(7.);
|
||||
let bg = cx.theme().background;
|
||||
// The read dot: a solid disc with a 2px surface-colored separator ring,
|
||||
// so its *green core* is `d - 4`. The unread variant keeps that exact
|
||||
// core and only wraps it in a hairline gap + ring — so switching read↔
|
||||
// unread never changes the dot's apparent size, just adds a thin target
|
||||
// rim. (The earlier version accidentally grew the core, which read as a
|
||||
// much bigger dot.)
|
||||
if unread {
|
||||
let core = (d - 4.0).max(3.0); // identical green core to the read dot
|
||||
let gap = 0.9; // hairline surface-colored gap
|
||||
let ring = (d * 0.10).max(0.85); // ~0.9px same-hue outer rim
|
||||
let inner = core + gap * 2.0; // green core + gap
|
||||
let outer = inner + ring * 2.0; // + outer ring
|
||||
let inner_dot = div()
|
||||
.size(px(inner))
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(bg)
|
||||
.bg(gpui::rgb(rgb));
|
||||
// Center the outer disc on the read dot's center (same corner point).
|
||||
div()
|
||||
.absolute()
|
||||
.right(px(-(outer - d) / 2.0 - d * 0.22))
|
||||
.bottom(px(-(outer - d) / 2.0 - d * 0.22))
|
||||
.size(px(outer))
|
||||
.rounded_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(gpui::rgb(rgb))
|
||||
.child(inner_dot)
|
||||
.into_any_element()
|
||||
} else {
|
||||
div()
|
||||
.absolute()
|
||||
.right(px(-(d * 0.22)))
|
||||
.bottom(px(-(d * 0.22)))
|
||||
.size(px(d))
|
||||
.rounded_full()
|
||||
.border_2()
|
||||
.border_color(bg)
|
||||
.bg(gpui::rgb(rgb))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading avatar for a tab row/chip: a rounded badge that brands the
|
||||
/// tab by what's running in it — each session fronted with an icon. A
|
||||
/// recognized coding agent gets its brand mark — a white silhouette
|
||||
/// (gpui tints SVGs as an alpha mask) on the vendor accent; an SSH pane gets
|
||||
/// a terminal glyph ringed in its connection-status colour; a plain shell
|
||||
/// gets a neutral terminal glyph. An agent's live status rides the corner as
|
||||
/// a [`status_dot`](Self::status_dot). `size` is the badge's edge in px.
|
||||
pub(crate) fn tab_avatar(
|
||||
&self,
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
status: Option<crate::core::cli_agent::AgentStatus>,
|
||||
unread: bool,
|
||||
ssh: Option<gpui::Hsla>,
|
||||
size: f32,
|
||||
cx: &App,
|
||||
) -> gpui::AnyElement {
|
||||
let base = div()
|
||||
.flex_shrink_0()
|
||||
.size(px(size))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center();
|
||||
// A circle for every kind — the brand mark / glyph sits
|
||||
// small and centred with generous padding rather than filling the badge.
|
||||
match agent {
|
||||
Some(agent) => {
|
||||
// The agent's live status as a small dot pinned to the badge's
|
||||
// bottom-right corner (blue working / amber waiting / green
|
||||
// done), ringed in the surface color so it reads as sitting on
|
||||
// the badge rather than clipped by it. Idle (or unknown) draws
|
||||
// no dot — a resting agent is just its brand mark. An *unread*
|
||||
// finished turn adds a translucent same-hue halo around the dot
|
||||
// — a soft "come look" that clears (back to a plain dot) once
|
||||
// you view the pane, without ever hiding the done state.
|
||||
let dot = status
|
||||
.and_then(|s| s.dot_rgb())
|
||||
.map(|rgb| Self::status_dot(rgb, unread, size, cx));
|
||||
base.relative()
|
||||
.rounded_full()
|
||||
.bg(gpui::rgb(agent.accent_rgb()))
|
||||
.child(
|
||||
gpui::svg()
|
||||
.path(agent.icon_path())
|
||||
.size(px(size * 0.54))
|
||||
.text_color(gpui::white()),
|
||||
)
|
||||
.when_some(dot, |b, dot| b.child(dot))
|
||||
.into_any_element()
|
||||
}
|
||||
None => base
|
||||
.rounded_full()
|
||||
// A clearly-visible neutral disc (a neutral grey shell badge), not a
|
||||
// near-transparent tint — so the avatar column reads as a column.
|
||||
.bg(cx.theme().muted)
|
||||
.when_some(ssh, |d, c| d.border_2().border_color(c))
|
||||
.child(
|
||||
// A flush `>_` prompt (not the boxed `square-terminal`) so it
|
||||
// fills the badge at the same visual weight as a brand mark.
|
||||
gpui::svg()
|
||||
.path("icons/terminal.svg")
|
||||
.size(px(size * 0.56))
|
||||
.text_color(cx.theme().foreground.opacity(0.65)),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display label for a tab: the user-set name if present, otherwise the
|
||||
/// focused terminal's title (shortened), falling back to
|
||||
/// "Session N" when there's no title yet. Pass `window` so the label tracks
|
||||
@@ -308,6 +429,12 @@ impl Tty7App {
|
||||
let label = self.tab_label(tab, i, Some(window), cx);
|
||||
// SSH status dot (PRD FR-E2): coloured by the pane's connection phase.
|
||||
let ssh_dot = self.tab_ssh_dot(tab, cx);
|
||||
// A coding agent running in this tab (Claude Code, Codex, …) fronts
|
||||
// its chip with the vendor brand mark so it's recognizable at a
|
||||
// glance across a crowded strip.
|
||||
let agent = tab.agent(cx);
|
||||
let agent_status = tab.agent_status(cx);
|
||||
let agent_unread = tab.agent_result_unread(cx);
|
||||
|
||||
// Inline rename input for this tab, if it's the one being renamed.
|
||||
let rename_input = self
|
||||
@@ -436,9 +563,21 @@ impl Tty7App {
|
||||
.when_some(ssh_dot, |c, color| {
|
||||
c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color))
|
||||
})
|
||||
// Clickable / editable label region. No leading context glyph —
|
||||
// the label carries the whole chip, so a row of tabs reads as
|
||||
// plain text rather than icon-per-chip busy.
|
||||
// Leading agent brand avatar, when a coding agent runs in this
|
||||
// tab — the vendor mark on its accent. Only agents get an avatar
|
||||
// here: ordinary shells stay text-only so the strip reads as
|
||||
// tabs, not icon-per-chip busy.
|
||||
.when_some(agent, |chip, agent| {
|
||||
chip.child(self.tab_avatar(
|
||||
Some(agent),
|
||||
agent_status,
|
||||
agent_unread,
|
||||
None,
|
||||
18.,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
// Clickable / editable label region.
|
||||
.child(label_region)
|
||||
// Trailing slot: normally the close affordance — always shown on
|
||||
// the active tab; on the others it stays out of the way
|
||||
|
||||