SLOPSHOPPER

session-hud

One HUD above the prompt: rate-limit usage, Codeup MRs, the session's Feishu docs and Yunxiao deploys; each module switchable, foldable

newbandguardcommandpromptprocess
v0.4.3MITupdated 2026-10-16jasoncai0/claude-session-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ session-hud: **session-hud**(已展开) ⎿ session-hud: - 用量 `usage`:开 ⎿ session-hud: - Codeup MR `mr`:开 ⎿ session-hud: - 飞书文档 `docs`:开 ⎿ session-hud: - 云效发布 `deploy`:开 ⎿ session-hud: 配置:`collapsed`=false · `usageReset`=true · `mrMaxRows`=3 · `mrRepo`=true · `mrRepoWidth`=18 · `mrSourceWidth`= ▾ 5h ██░░░░ 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▾ 5h ██░░░░ 31% ⟨Claude Code's own drawing⟩
README

session-hud

version Claude Code license

A Claude Code mod that keeps one HUD above the prompt — rate-limit usage, the Codeup MRs of the branches you are on, the Feishu docs the session touched and the Yunxiao (AppStack) releases it triggered — in the Claude Desktop Code tab and the terminal.

Claude Code 的一个 mod:在输入框上方常驻一块 HUD,把当前会话相关的四类信息放在一处:

模块显示什么
用量 usage5h / 7d 限额进度条与重置倒计时
Codeup MR mr会话涉及仓库当前分支的合并请求,可展开合入、落后、源分支删除、冲突、评审等检查
飞书文档 docs本会话创建或提到的飞书 / Lark 文档,点标题直达
云效发布 deploy本会话触发(或跟踪)的 AppStack 发布,显示阶段链接和运行到哪一步

Claude Desktop 的 Code 标签页和终端都能显示(基于 statusLine 的 HUD 在 Desktop 里不显示)。它合并并取代了 usage-hud 与 codeup-mr。

目录

预览

示例数据均为虚构。

展开:

▾ 5h ███░░░ 55% ↻1h41m   7d █░░░░░ 21% ↻2d11h
  ⑂ !128   待合并  backend/demo-api    feature/user…-permission → release/online   +422 -10  ✓ 检查 ⌄  ×
  ⑂ !129   评审中  backend/demo-api    feature/user…-permission → release/prod     +422 -10  ○ 检查 ⌄  ×
  ▤ 文档   创建    用户资料权限设计方案                                                   ×
  ◈ 知识库 提及    Q4 迭代计划                                                           ×
  ⌄ 展开另 2 篇文档
  ⇪ prod   运行中  demo-api            release/online             ◐ 灰度人工卡点 (5/9)  ◐ 检查 ⌄  ×

点 MR 的 检查 ⌄,原地展开检查项(同时从 Codeup 重新拉取):

  ⑂ !128   待合并  backend/demo-api    feature/user…-permission → release/online   +422 -10  ○ 检查 ⌃  ×
             ○ 待合入 3 个提交
             ○ 落后 release/online 9 个提交
             ✓ 源分支存在
             ✓ 合并冲突检查
             ○ 评审 1/2

点左上角 ▾ 折叠成一行摘要:

▸ 5h 55% 7d 21% · ⑂!128 ○ ⑂!129 ○ · ▤ 文档 4 (创建 1) · ⇪demo-api·prod ◐

快速开始

  1. 在终端的 Claude Code 会话里安装(Desktop 的 Code 标签页不支持 /plugin),提示添加 marketplace 时输入 y,范围选 user:
   /plugin install session-hud --marketplace jasoncai0/claude-session-hud
  1. 如果装过 usage-hud / codeup-mr,先卸载,否则会显示两遍(云效 token 读取方式不变,无需重新配置):
   claude plugin uninstall usage-hud@claude-usage-hud
   claude plugin uninstall codeup-mr@claude-codeup-mr
  1. 新开一个会话(终端或 Desktop 均可),输入 /hud 查看各模块状态。

也可以不进 Claude Code,直接在 shell 里安装:

claude plugin marketplace add jasoncai0/claude-session-hud
claude plugin install session-hud@claude-session-hud --scope user

更新(只对之后新开的会话生效):

claude plugin marketplace update claude-session-hud
claude plugin update session-hud@claude-session-hud

特性

  • 一行一条、列宽固定:每行固定一行高;仓库、分支、文档标题超长时以 … 截断(分支名从中间截,保留首尾),鼠标悬停显示全文。MR、文档、发布三类行共用同一套列,上下对齐。
  • 窄屏自适应:区域比配置的列宽窄时,名称列按比例收缩(先收掉仓库列),右侧的「检查」「×」始终可见。
  • 超出行数原地展开:超过最多行数时,最后一行是 ⌄ 展开另 N … 按钮,点开就地显示全部,⌃ 收起 复原。
  • 模块独立启停:/hud mr off 即可关掉某个模块;关掉的模块不注册任何工作,不轮询,也不调接口。
  • 细粒度配置:20 个配置项,涵盖显隐、列宽、最多行数、轮询间隔等,Desktop 里用 /hud set 修改。
  • 可折叠:一键折叠成一行摘要;默认是否折叠也可配置。
  • 与其他插件共存:HUD 叠在其他插件的输入框上方内容之上,不替换它们;出现问卷时自动让位。

模块

usage 用量

  • 每个限额窗口一组:5h / 7d(经网关时还有 额度),6 格进度条,< 70% 绿、70–90% 黄、≥ 90% 红
  • ↻1h41m 为距离重置的倒计时;重置时间已过(会话空闲后读数过期)的窗口自动隐藏
  • 数据随 Claude Code 的 session.measure 推送,不轮询、不额外消耗 token

mr Codeup 合并请求

涉及哪些仓库:当前工作目录所在仓库,加上会话中编辑过文件、或通过 cd <dir> / git -C <dir> 操作过的仓库(绝对路径或 ~/),最多 5 个。只识别 origin 指向 codeup.aliyun.com 的仓库(ssh / https 均可)。

显示哪些 MR:以当前分支为源分支的 MR。有未关闭的就显示未关闭的(同一分支合往多个目标分支时各一行,最多 3 条);全部已关闭时显示最近一条已合并 / 已关闭的。

列说明
⑂MR 图标,颜色随状态;终端里也可点击跳转
!128MR 编号,点击打开 MR
状态草稿 / 评审中 / 待合并 / 已合并 / 已关闭
仓库group/repo,可用 mrRepo 关闭
源 → 目标分支名,超长时中间截断,悬停看全
+422 -10MR 的增删行数(同 git diff --shortstat target...source),分支有新提交时才重新计算
检查 ⌄展开 / 收起检查项,按钮上的符号取最差一项:✕ > ○ > ✓
×在本会话中隐藏这条 MR

检查项(点「检查」时会立即从 Codeup 重新拉取):

检查未关闭的 MR已合并的 MR已关闭的 MR
合入○ 待合入 N 个提交✓ 已合入 目标分支✕ 未合入(MR 已关闭)
同步○ 落后 目标分支 N 个提交 / ✓ 已同步——
源分支✓ 源分支存在 / ✕ 源分支已删除○ 源分支未删除 / ✓ 源分支已删除○ 源分支未删除 / ✓ 源分支已删除
合并规则Codeup 的合并要求,如「合并冲突检查」同左同左
冲突✕ 存在冲突——
评审评审 1/2:全部通过为 ✓,有人拒绝为 ✕,否则 ○同左同左
评论○ 未解决评论 N同左同左

刷新时机:会话开始、每轮对话结束、每 mrPollSeconds 秒,以及点「检查」或执行 /mr 时。

docs 飞书文档

创建的文档(绿色「创建」)来自:

  • Bash 里的 lark-cli … +create / +import / +copy / +duplicate(可带全局参数,如 lark-cli --as user docs +create)。取输出里新出现的文档链接,失败的调用不计。
  • 名称含 lark / feishu 且含 create / import / copy 的 MCP 工具。

提及的文档(灰色「提及」)来自:

  • 你发送的消息
  • Claude 每轮的最终回答
  • lark-cli / Lark MCP 调用参数里的文档链接(搜索类命令输出里的大量链接不计入)

识别的链接:*.feishu.cn、*.larksuite.com、*.larkoffice.com、*.feishu.net 下的 docx / doc(s) / wiki / sheets / base / bitable / mindnote(s) / slides / file。/wiki/settings/… 这类非文档页面不会误识别。

每行:类型图标 · 类型(文档 / 知识库 / 表格 / 多维表 / 脑图 / 幻灯片 / 文件)· 创建 / 提及 · 标题(点击打开,悬停看全) · ×。类型和「创建 / 提及」分别对齐 MR 行的编号列与状态列。

标题从哪来:每轮结束后在后台调用 lark-cli drive +inspect 查询,wiki 节点会解析到实际文档。查到标题或确认无权限后不再查;超时等临时失败会在之后的轮次重试,最多 3 次(/docs 也会触发重试)。查到之前先显示链接文字(如 设计方案 中的「设计方案」),否则显示「类型 + token 前缀」。

每个会话保留全部「创建」的文档和最近 30 篇「提及」的文档;docsMentioned 设为 off 后只显示创建的。

deploy 云效发布

记录哪些发布:本会话里在 Bash 中成功触发的 AppStack 研发阶段。

  • 发布脚本:命令中含 deploy.sh <服务> <环境>,且输出包含 Triggered. 和 AppStack 阶段链接(--dry-run 不输出 Triggered.,因此不计)。输出中的以下行会被读取,用来填环境、阶段名、分支和发布说明:
   Env     : prod  (stage: 生产 / workflow: 标准发布流程)
   Branch  : release/online
   Feature : 本次发布说明
  Triggered. View: https://devops.aliyun.com/appstack/app/<应用>/workflow/<流程 SN>/stage/<阶段 SN>/current
  • 直接调用 CLI:aliyun devops app-stack-execute-change-request-release-stage --app-name … --release-workflow-sn … --release-stage-sn …,未失败且不是 --cli-dry-run 时计入。

跟踪已有发布(不会发布任何东西):在消息里贴 AppStack 阶段链接,或执行 /deploys <阶段链接>,即跟踪该阶段最新一次执行。适合查看在网页上或别的会话里触发的发布。环境由阶段名推出(生产阶段 → prod),分支取自这次运行。

再次触发或跟踪同一个阶段时,这一行会重新开始。每个会话保留最近 10 次发布。

列说明
⇪颜色随运行状态
环境prod / pre / test,对齐 MR 的编号列
状态查询中 / 运行中 / 待确认 / 成功 / 失败 / 已取消,对齐 MR 的状态列
应用AppStack 应用名,点击打开该阶段的 AppStack 页面
分支本次发布的分支,超长时中间截断
进度停在或正在执行的任务,以及已完成数 / 总任务数,如 ✕ 灰度人工卡点 (5/9);全部完成时显示最后一个任务 ✓
检查 ⌄展开各阶段下每个任务的状态,并立即重新查询(已结束的运行也会查)
×在本会话中隐藏这次发布

状态从哪来:先查该阶段最新一次执行(开始时间不早于触发时间,允许 2 分钟时钟误差),找到后记下执行序号,再查这次执行的流水线运行(executions/{序号}:getPipelineRun),得到各阶段、各任务的状态。

刷新时机:触发后 5 秒查第一次;之后每 deployPollSeconds 秒只查尚未结束的运行,结束(成功 / 失败 / 已取消)后不再轮询;点「检查」或执行 /deploys 时立即查询。

交互

操作效果
点左上角 ▾ / ▸折叠 / 展开(仅本会话)
悬停被截断的文字显示全文
点 MR 的编号打开 MR
点文档标题打开文档
点发布行的应用名打开 AppStack 阶段页
点「检查」展开 MR 检查项或发布任务,并重新拉取
点 ⌄ 展开另 N … / ⌃ 收起超出最多行数的 MR / 文档 / 发布原地展开或收起
点 ×本会话内隐藏该行

命令

命令作用
/hud查看各模块开关、全部配置与帮助
`/hud usage\mr\docs\deploy on\off`启停模块(写入插件配置,立即生效,新会话沿用)
/hud fold / /hud unfold折叠 / 展开本会话的 HUD
/hud set <配置项> <值>修改任一配置,如 /hud set mrSourceWidth 30、/hud set collapsed on
/mr以 markdown 链接列出本会话所有 MR
/docs以 markdown 链接列出本会话所有文档(顺带重试未取到的标题)
/deploys刷新并列出本会话的所有发布:状态、执行序号、AppStack 链接、当前任务
/deploys <阶段链接>跟踪该 AppStack 阶段的最新一次执行(不发布)

配置

Desktop 的 Code 标签页没有 /config,用 /hud set 修改;终端里这些字段也会出现在 /config 中。布尔值写 on / off;数字超出范围时取边界值。

配置项默认说明
usage / mr / docs / deployon模块开关
collapsedoff新会话默认折叠
usageReseton显示重置倒计时
mrMaxRows3MR 最多行数,超出时可原地展开(1–10)
mrRepoon显示仓库列
mrRepoWidth18仓库列宽,单位为字符格(6–60)
mrSourceWidth24源分支列宽(6–80)
mrTargetWidth16目标分支列宽(6–60)
mrStatson显示增删行数
mrCheckson显示「检查」按钮
mrPollSeconds60MR 轮询间隔,单位为秒(15–3600)
docsMaxRows3文档最多行数,超出时可原地展开(1–10)
docsTitleWidth40文档标题列宽(10–120)
docsMentionedon显示提及的文档;off 时只显示创建的
deployMaxRows3发布最多行数,超出时可原地展开(1–10)
deployPollSeconds30未结束发布的轮询间隔,单位为秒(10–600)
token—云效个人访问令牌(敏感字段,存于系统安全存储);只能在 /plugin configure 或 /config 中设置

依赖

  • Claude Code ≥ 2.1.29x(mods API),Desktop Code 标签页或终端均可
  • mr / deploy 模块需要云效个人访问令牌(mr 需要 Codeup 读权限,deploy 需要 AppStack 读权限),按以下顺序查找:
  • 插件配置 token
  • 环境变量 ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN
  • ~/.zshrc、~/.zshenv、~/.zprofile、~/.bashrc、~/.profile 中的 export ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN=...(Desktop 不继承 shell 环境变量,所以会读这些文件)
  • deploy 模块还需要云效组织 ID,按以下顺序查找:环境变量 ALIBABA_CLOUD_YUNXIAO_ORGANIZATION_ID,shell 配置文件中的同名 export 行,最后取会话所在 Codeup 仓库 origin 中的组织 ID
  • docs 模块显示链接无需额外依赖;显示标题需要已登录的 lark-cli,会在 PATH、/opt/homebrew/bin、/usr/local/bin 中查找

网络与隐私

  • Codeup / AppStack:只调用 openapi-rdc.aliyuncs.com 上的只读接口。Codeup 部分包括仓库、MR 列表、MR 详情、对比、分支;AppStack 部分包括阶段列表、阶段执行列表、流水线运行。插件不会触发、取消或推进任何发布。token 只放在请求头里。
  • 飞书:插件本身不发网络请求,标题查询通过本机的 lark-cli 完成,使用你自己的登录态。
  • 不上报任何数据。状态(MR、文档、发布、折叠展开)只保存在当前会话里。

排错

现象原因与处理
看不到 HUD当前会话启动于安装之前,新开一个会话;再用 /hud 确认模块已开启
每行出现两遍旧的 usage-hud / codeup-mr 没卸载,见快速开始第 2 步
显示「未找到云效 token」按依赖配置 token
没有 MR 行仓库 origin 不在 Codeup 上;当前分支没有 MR;或该 MR 不在仓库最新的 100 条之内
文档只显示「文档 xxxxxxxx…」未安装或未登录 lark-cli(终端执行 lark-cli auth login),或无该文档权限;临时失败会自动重试,也可执行 /docs 立即重试
发布行一直是「等待运行开始」该阶段还没有新的执行(触发被排队或失败),点「检查」重查;或触发时间与执行开始时间相差超过 2 分钟
发布行显示「查询失败」令牌没有 AppStack 读权限,或组织 ID 不对,见依赖
用量行不显示还没有新的限额读数,发一条消息后就会出现

插件内部出错时会记录一行 session-hud <模块>: <原因>,可在 claude --debug 的日志里查到。

已知限制

  • Codeup 的 MR 列表接口不支持按源分支过滤,因此每个仓库只扫描最新的 100 条 MR
  • 不显示 Codeup 提交上的流水线(CI)状态:只有流水线把状态回写到提交上时,接口才会提供
  • Desktop 中 MR 图标是 Svg,宿主不允许把 Svg 放进链接,所以要点编号跳转
  • 发布模块只跟踪 AppStack 研发阶段;不经 AppStack 的纯 Flow 流水线发布暂不显示
  • 发布模块只读:人工卡点需要到 AppStack 页面推进
  • 旧版 codeup-mr 0.2 / 0.3 写进会话标题的 MR 徽标,本插件不再清理

开发

.claude-plugin/plugin.json   清单、userConfig(全部配置项)
types/index.d.ts             $.state 契约
hooks/register.tsx           入口:唯一调用 $ 的文件(引擎要求),事件分发、数据拉取、绘制组装
hooks/settings.ts            配置解析与默认值
hooks/hud.tsx                折叠条与 /hud 命令解析
hooks/ui.tsx                 共用列宽、定宽截断单元格(悬停显示全文)、原地展开
hooks/text.ts                按显示宽度截断(中文按 2 格计)
hooks/usage/                 用量格式化与绘制
hooks/mr/                    Codeup 解析、检查项、列宽自适应、绘制
hooks/docs/                  链接识别、去重合并、lark-cli 标题查询、绘制
hooks/deploy/                发布触发识别、执行匹配、运行状态映射、绘制
claude plugin validate .
claude plugin test .

本地调试用 claude --plugin-dir <本仓库目录> 启动,或把目录加入 CLAUDE_CODE_PLUGIN_DIRS,保存后自动热重载。测试数据全部为虚构:不要在测试、示例或提交信息里写入真实的组织 ID、仓库、应用、文档链接或 token。

发布流程:

  1. 在 .claude-plugin/plugin.json 中提升 version
  2. 推送到 main
  3. 使用方执行快速开始中「更新」的两条命令

更新记录

  • 0.4.3:README 重写(目录、快速开始、虚构示例数据),测试数据脱敏
  • 0.4.2:超出最多行数时改为原地「⌄ 展开另 N … / ⌃ 收起」按钮(MR、文档、发布通用);文档标题查询遇到超时等临时失败会重试
  • 0.4.1:发布模块支持跟踪已有发布:在消息里贴 AppStack 阶段链接或执行 /deploys <链接>
  • 0.4.0:新增云效发布模块:AppStack 阶段链接、运行到哪个任务、未结束时自动轮询、「检查」展开全部任务并立即刷新;新增 /deploys
  • 0.3.0:「检查」新增合入状态、落后目标分支的提交数、源分支是否已删除;点「检查」时立即重新拉取
  • 0.2.0:去掉行尾的「打开」链接,MR 通过编号跳转、文档通过标题跳转;文档行与 MR 行列对齐
  • 0.1.0:合并 usage-hud 与 codeup-mr,新增飞书文档模块、定宽单行布局、模块独立启停、细粒度配置与折叠

许可证

MIT

Source 17 files
hooks/register.tsx 498 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DeployRun, DocLink, DocOrigin, MrView, RepoView, SessionView, Usage } from '../types'
5import { addRun, envOfStage, findStageTargets, isSettled, pickExecution, runOf, triggerOf } from './deploy/parse'
6import type { ApiPipelineRun } from './deploy/parse'
7import { deployList, deploySection } from './deploy/view'
8import { findDocs, isCreation, isLarkTool, mergeDocs } from './docs/links'
9import type { FoundDoc } from './docs/links'
10import { LARK_CLI, inspectArgv, isMissingProgram, lookupOf } from './docs/lookup'
11import type { Lookup } from './docs/lookup'
12import { docList, docsLayout, docsSection, visibleDocs } from './docs/view'
13import { band, parseHud, status } from './hud'
14import { API_BASE, diffStat, exportFromProfile, isOpen, parseRemote, pickMrs, repoLabel, stateOf, toView, tokenFromProfile } from './mr/codeup'
15import type { ApiChangeRequest, ApiCompare, ApiDetail, Repo } from './mr/codeup'
16import { dirsOf, mrList, rememberDirs } from './mr/session'
17import { mrLayout, mrSection } from './mr/view'
18import { MODULE_IDS, PLUGIN, settingsOf } from './settings'
19import type { Settings } from './settings'
20import { isBlank } from './tree'
21import type { Fold, Section } from './ui'
22import { usageSection } from './usage/view'
23
24const usage = atom({ plugin: 'session-hud', key: 'usage' } as const, null)
25const mrView = atom({ plugin: 'session-hud', key: 'mrView' } as const, null)
26const mrDirs = atom({ plugin: 'session-hud', key: 'mrDirs' } as const, [])
27const mrExpanded = atom({ plugin: 'session-hud', key: 'mrExpanded' } as const, null)
28const mrDismissed = atom({ plugin: 'session-hud', key: 'mrDismissed' } as const, [])
29const docs = atom({ plugin: 'session-hud', key: 'docs' } as const, [])
30const docsDismissed = atom({ plugin: 'session-hud', key: 'docsDismissed' } as const, [])
31const collapsed = atom({ plugin: 'session-hud', key: 'collapsed' } as const, null)
32const deploys = atom({ plugin: 'session-hud', key: 'deploys' } as const, [])
33const deployExpanded = atom({ plugin: 'session-hud', key: 'deployExpanded' } as const, null)
34const deployDismissed = atom({ plugin: 'session-hud', key: 'deployDismissed' } as const, [])
35const expandedLists = atom({ plugin: 'session-hud', key: 'expandedLists' } as const, [])
36
37const PAGE_SIZE = 100
38const HTTP_NOT_FOUND = 404
39const MAX_REPOS = 5
40const GIT_TIMEOUT_MS = 5_000
41const LARK_TIMEOUT_MS = 8_000
42/** Exit status of a command that was not found. */
43const NOT_FOUND = 127
44/** Title lookups per turn: each is one lark-cli call. */
45const LOOKUPS_PER_TURN = 5
46/** Retryable lookup failures per document before its title is given up. */
47const MAX_LOOKUP_TRIES = 3
48const PROFILES = ['.zshrc', '.zshenv', '.zprofile', '.bashrc', '.profile']
49const NO_TOKEN = '未找到云效 token(设置 ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN)'
50const NO_ORG = '未找到云效组织 ID(设置 ALIBABA_CLOUD_YUNXIAO_ORGANIZATION_ID)'
51const ORG_VAR = 'ALIBABA_CLOUD_YUNXIAO_ORGANIZATION_ID'
52const APPSTACK_BASE = 'https://openapi-rdc.aliyuncs.com/oapi/v1/appstack/organizations'
53/** A just-triggered stage needs a moment before its execution is listed. */
54const DEPLOY_FIRST_CHECK_MS = 5_000
55
56const listOf = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
57
58// ponytail: module-level caches; reset on reload, which is fine
59const repoIds = new Map<string, number>()
60const roots = new Map<string, string>()
61/** +/- per `${repo}!${id}@${head sha}`: the diff is fetched again only when the branch moves. */
62const stats = new Map<string, { additions: number; deletions: number }>()
63let tokenCache: { value: string | undefined } | null = null
64let orgCache: { value: string | undefined } | null = null
65/** Release-stage names per `${app}/${workflowSn}`, read once: they name a tracked run's env. */
66const stageNames = new Map<string, Map<string, string>>()
67/** Run keys being fetched, so a press during a poll does not fetch twice. */
68const deployFetching = new Set<string>()
69/** The lark-cli that ran; null once none was found. */
70let larkCli: string | null | undefined
71/** Tokens whose title lookup is running, so overlapping turns (subagents, fast prompts) never look one up twice. */
72const lookingUp = new Set<string>()
73
74/** Runs one module's step: a failure neither stops the other modules nor the event, and is logged. */
75async function guard($: EngineInterface, id: string, step: () => Promise<unknown>): Promise<void> {
76  try {
77    await step()
78  } catch (err) {
79    $.ui.log(`${PLUGIN} ${id}: ${err instanceof Error ? err.message : String(err)}`)
80  }
81}
82
83// ── usage ───────────────────────────────────────────────────────────────────────────────────────────────────────────
84
85async function refreshUsage($: EngineInterface) {
86  const u = await $.session.usage()
87  const next: Usage = { rateLimits: u.rateLimits }
88  await update($, usage, () => next)
89}
90
91// ── Codeup MR ───────────────────────────────────────────────────────────────────────────────────────────────────────
92
93async function git($: EngineInterface, dir: string, ...args: string[]): Promise<string> {
94  const { exitCode, stdout } = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => ({ exitCode: 1, stdout: '' }))
95  return exitCode === 0 ? stdout.trim() : ''
96}
97
98async function resolveToken($: EngineInterface, configured: string | undefined): Promise<string | undefined> {
99  if (configured) return configured
100  const fromEnv = await $.env.get('ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN')
101  if (fromEnv) return fromEnv
102  const home = await $.env.get('HOME')
103  for (const name of PROFILES) {
104    const token = tokenFromProfile(await $.fs.read(`${home}/${name}`).catch(() => ''))
105    if (token) return token
106  }
107  return undefined
108}
109
110async function getJson<T>($: EngineInterface, token: string, url: string, what: string): Promise<T> {
111  const res = await $.http.fetch(url, { headers: { 'x-yunxiao-token': token } })
112  if (!res.ok) throw new Error(`${what} ${res.status}: ${res.text.slice(0, 120)}`)
113  return JSON.parse(res.text) as T
114}
115
116const api = <T,>($: EngineInterface, token: string, path: string): Promise<T> => getJson<T>($, token, `${API_BASE}/${path}`, 'Codeup')
117
118async function cachedToken($: EngineInterface, settings: Settings): Promise<string | undefined> {
119  tokenCache ??= { value: await resolveToken($, settings.token) }
120  return tokenCache.value
121}
122
123/** Whether the branch still exists on Codeup: 404 is deleted; any other failure is unknown, so no check is shown. */
124async function isBranchAlive($: EngineInterface, token: string, base: string, branch: string): Promise<boolean | undefined> {
125  const res = await $.http.fetch(`${API_BASE}/${base}/branches/${encodeURIComponent(branch)}`, { headers: { 'x-yunxiao-token': token } }).catch(() => null)
126  if (!res) return undefined
127  return res.ok ? true : res.status === HTTP_NOT_FOUND ? false : undefined
128}
129
130async function fetchMrs($: EngineInterface, token: string, repo: Repo, branch: string, head: string): Promise<MrView[]> {
131  let repoId = repoIds.get(repo.path)
132  if (repoId === undefined) {
133    repoId = (await api<{ id: number }>($, token, `${repo.org}/repositories/${encodeURIComponent(repo.path)}`)).id
134    repoIds.set(repo.path, repoId)
135  }
136  const base = `${repo.org}/repositories/${repoId}`
137  // ponytail: Codeup ignores sourceBranch filtering, so scan the newest 100 MRs; page further if old branches matter
138  const list = await api<ApiChangeRequest[]>($, token, `${repo.org}/changeRequests?projectIds=${repoId}&state=all&perPage=${PAGE_SIZE}`)
139  const picked = pickMrs(list, branch)
140  // One branch lookup per repo: every picked MR has this branch as its source.
141  const isAlive = picked.length ? await isBranchAlive($, token, base, branch) : undefined
142  // ponytail: the stats key ignores the target branch moving; add its sha if merged-in target commits must show
143  return Promise.all(picked.map(async cr => {
144    const key = `${repo.path}!${cr.localId}@${head}`
145    const [detail, stat] = await Promise.all([
146      api<ApiDetail>($, token, `${base}/changeRequests/${cr.localId}`).catch(() => undefined),
147      !isOpen(cr) ? undefined : stats.get(key) ?? api<ApiCompare>($, token, `${base}/compares?from=${encodeURIComponent(cr.targetBranch)}&to=${encodeURIComponent(cr.sourceBranch)}`)
148        .then(diffStat)
149        .then(s => (stats.set(key, s), s))
150        .catch(() => undefined),
151    ])
152    return toView(cr, repo.path, detail, stat, isAlive)
153  }))
154}
155
156/** Git roots of the session's cwd and of every directory it touched, cwd first, deduped. */
157async function sessionRoots($: EngineInterface): Promise<string[]> {
158  const touched = listOf<string>(await read($, mrDirs))
159  const candidates = [await $.session.cwd(), ...[...touched].reverse()]
160  const found: string[] = []
161  for (const dir of candidates) {
162    if (!roots.has(dir)) roots.set(dir, await git($, dir, 'rev-parse', '--show-toplevel'))
163    const root = roots.get(dir)
164    if (root && !found.includes(root)) found.push(root)
165    if (found.length >= MAX_REPOS) break
166  }
167  return found
168}
169
170async function repoView($: EngineInterface, token: string, root: string): Promise<RepoView | null> {
171  const [remote, branch, upstream, local] = await Promise.all([
172    git($, root, 'remote', 'get-url', 'origin'),
173    git($, root, 'branch', '--show-current'),
174    git($, root, 'rev-parse', '@{u}'),
175    git($, root, 'rev-parse', 'HEAD'),
176  ])
177  const repo = parseRemote(remote)
178  if (!repo || !branch) return null
179  const name = repoLabel(repo.path)
180  return fetchMrs($, token, repo, branch, upstream || local)
181    .then(mrs => ({ root, repo: name, branch, mrs }))
182    .catch((err: Error) => ({ root, repo: name, branch, mrs: [], error: err.message }))
183}
184
185async function refreshMrs($: EngineInterface, settings: Settings) {
186  const found = await sessionRoots($)
187  if (!found.length) {
188    await update($, mrView, () => null)
189    return
190  }
191  const token = await cachedToken($, settings)
192  if (!token) {
193    await update($, mrView, (): SessionView => ({ repos: [], error: NO_TOKEN }))
194    return
195  }
196  const repos = (await Promise.all(found.map(root => repoView($, token, root)))).filter((r): r is RepoView => r !== null)
197  await update($, mrView, (): SessionView => ({ repos }))
198}
199
200async function rememberMrDirs($: EngineInterface, tool: string, input: Readonly<Record<string, unknown>>) {
201  const added = dirsOf(tool, input, (await $.env.get('HOME')) ?? '')
202  if (added.length) await update($, mrDirs, known => rememberDirs(listOf<string>(known), added))
203}
204
205// ── Feishu docs ─────────────────────────────────────────────────────────────────────────────────────────────────────
206
207async function rememberDocs($: EngineInterface, found: readonly FoundDoc[], origin: DocOrigin, settings: Settings) {
208  if (!found.length || (origin === 'mentioned' && !settings.docsMentioned)) return
209  const now = await $.clock.now()
210  await update($, docs, list => mergeDocs(listOf<DocLink>(list), found, origin, now))
211}
212
213/** A Lark call's input names the docs it works on (mentions); only a create call's output counts, as what it made. */
214async function rememberToolDocs($: EngineInterface, tool: string, input: Readonly<Record<string, unknown>>, output: string, settings: Settings) {
215  if (!isLarkTool(tool, input)) return
216  const mentioned = findDocs(JSON.stringify(input))
217  const asked = new Set(mentioned.map(d => d.token))
218  const made = isCreation(tool, input) ? findDocs(output).filter(d => !asked.has(d.token)) : []
219  // Mentions first, so what the call made lands newest: a +copy lists the copy above its source.
220  await rememberDocs($, mentioned, 'mentioned', settings)
221  await rememberDocs($, made, 'created', settings)
222}
223
224/**
225 * The document's title through lark-cli. Only a missing program moves on to the next install folder, and only when
226 * none of them exists are lookups given up for the session; a timeout or other failure is retried on a later turn.
227 */
228async function lookupTitle($: EngineInterface, url: string): Promise<Lookup> {
229  const candidates = larkCli === undefined ? LARK_CLI : larkCli ? [larkCli] : []
230  for (const bin of candidates) {
231    const run = await $.process.run(inspectArgv(bin, url), { timeoutMs: LARK_TIMEOUT_MS }).then(
232      r => (r.exitCode === NOT_FOUND ? null : r),
233      (err: unknown) => (isMissingProgram(err) ? null : { failed: err instanceof Error ? err.message : String(err) }),
234    )
235    if (run === null) continue
236    if (!('failed' in run)) larkCli = bin
237    return lookupOf(run)
238  }
239  if (candidates.length) larkCli = null
240  return { final: '未找到 lark-cli' }
241}
242
243/** Looks up the titles still missing, a few per turn; a document is retried a few times, then given up. */
244async function lookupTitles($: EngineInterface) {
245  const pending = listOf<DocLink>(await read($, docs))
246    .filter(d => !d.title && !d.isLookedUp && (d.lookupTries ?? 0) < MAX_LOOKUP_TRIES && !lookingUp.has(d.token))
247    .slice(0, LOOKUPS_PER_TURN)
248  if (!pending.length) return
249  pending.forEach(d => lookingUp.add(d.token))
250  const results = new Map(await Promise.all(pending.map(async d => [d.token, await lookupTitle($, d.url)] as const)))
251  pending.forEach(d => lookingUp.delete(d.token))
252  results.forEach((r, token) => {
253    if ('retry' in r) $.ui.log(`${PLUGIN} docs: 标题查询失败(将重试)${token.slice(0, 8)}…: ${r.retry}`)
254  })
255  await update($, docs, list => listOf<DocLink>(list).map(d => {
256    const r = results.get(d.token)
257    if (!r) return d
258    if ('title' in r) return { ...d, title: r.title, isLookedUp: true }
259    if ('final' in r) return { ...d, isLookedUp: true }
260    return { ...d, lookupTries: (d.lookupTries ?? 0) + 1 }
261  }))
262}
263
264// ── Yunxiao deploys ─────────────────────────────────────────────────────────────────────────────────────────────
265
266/** The Yunxiao organization: $ALIBABA_CLOUD_YUNXIAO_ORGANIZATION_ID, its export line in a shell profile, else the org of the session's Codeup repo. */
267async function resolveOrg($: EngineInterface): Promise<string | undefined> {
268  const fromEnv = await $.env.get('ALIBABA_CLOUD_YUNXIAO_ORGANIZATION_ID')
269  if (fromEnv) return fromEnv
270  const home = await $.env.get('HOME')
271  for (const name of PROFILES) {
272    const org = exportFromProfile(await $.fs.read(`${home}/${name}`).catch(() => ''), ORG_VAR)
273    if (org) return org
274  }
275  const root = (await sessionRoots($))[0]
276  return root ? parseRemote(await git($, root, 'remote', 'get-url', 'origin'))?.org : undefined
277}
278
279/** The stage's name and env, for a run whose trigger did not print them (a tracked link, a direct API call). */
280async function stageOf($: EngineInterface, token: string, org: string, r: DeployRun): Promise<Partial<DeployRun>> {
281  if (r.stageName) return {}
282  const key = `${r.app}/${r.workflowSn}`
283  if (!stageNames.has(key)) {
284    const briefs = await getJson<{ sn?: unknown; name?: unknown }[]>($, token, `${APPSTACK_BASE}/${org}/apps/${encodeURIComponent(r.app)}/releaseWorkflow/${r.workflowSn}/releaseStageBriefs`, 'AppStack')
285    stageNames.set(key, new Map((Array.isArray(briefs) ? briefs : []).flatMap(b => (typeof b.sn === 'string' && typeof b.name === 'string' ? [[b.sn, b.name] as const] : []))))
286  }
287  const name = stageNames.get(key)?.get(r.stageSn)
288  return name ? { stageName: name, ...(r.env ? {} : { env: envOfStage(name) }) } : {}
289}
290
291/** The run's execution (found once, then pinned) and its stages' jobs as Yunxiao has them now. */
292async function fetchRun($: EngineInterface, token: string, org: string, r: DeployRun): Promise<Partial<DeployRun>> {
293  const base = `${APPSTACK_BASE}/${org}/apps/${encodeURIComponent(r.app)}/releaseWorkflows/${r.workflowSn}/releaseStages/${r.stageSn}/executions`
294  const stage = await stageOf($, token, org, r).catch(() => ({}))
295  const latest = r.number === undefined ? (await getJson<{ data?: { number?: unknown; startTime?: unknown }[] }>($, token, `${base}?perPage=1&page=1`, 'AppStack')).data?.[0] : undefined
296  const number = r.number ?? pickExecution(latest, r.isTracked ? 0 : r.triggeredAt)
297  if (number === undefined) return stage
298  const { branch, ...run } = runOf(await getJson<ApiPipelineRun>($, token, `${base}/${number}:getPipelineRun`, 'AppStack'))
299  return { ...stage, number, ...run, ...(r.branch || !branch ? {} : { branch }) }
300}
301
302/** Refreshes `key`'s run, or every run still going; a failure is kept on its run and shown in its row. */
303async function refreshDeploys($: EngineInterface, settings: Settings, key?: string) {
304  const due = listOf<DeployRun>(await read($, deploys)).filter(r => (key ? r.key === key : !isSettled(r)) && !deployFetching.has(r.key))
305  if (!due.length) return
306  const token = await cachedToken($, settings)
307  orgCache ??= { value: await resolveOrg($) }
308  const org = orgCache.value
309  due.forEach(r => deployFetching.add(r.key))
310  const patches = await Promise.all(due.map(async r => {
311    if (!token || !org) return [r.key, { error: token ? NO_ORG : NO_TOKEN }] as const
312    const patch = await fetchRun($, token, org, r).then(
313      (p): Partial<DeployRun> => ({ ...p, error: undefined }),
314      (err: Error): Partial<DeployRun> => ({ error: err.message }),
315    )
316    return [r.key, patch] as const
317  }))
318  due.forEach(r => deployFetching.delete(r.key))
319  const byKey = new Map(patches)
320  await update($, deploys, list => listOf<DeployRun>(list).map(r => {
321    const patch = byKey.get(r.key)
322    if (!patch) return r
323    const { error, ...rest } = { ...r, ...patch }
324    return error ? { ...rest, error } : rest
325  }))
326}
327
328async function rememberDeploy($: EngineInterface, tool: string, input: Readonly<Record<string, unknown>>, output: string, isError: boolean, settings: Settings) {
329  const t = triggerOf(tool, input, output, isError)
330  if (!t) return
331  const now = await $.clock.now()
332  await update($, deploys, list => addRun(listOf<DeployRun>(list), t, now))
333  $.clock.after(DEPLOY_FIRST_CHECK_MS, () => void guard($, 'deploy', () => refreshDeploys($, settings, t.key)))
334}
335
336/** Tracks the AppStack stages linked in `text` without deploying anything: each shows its latest run. */
337async function trackDeploys($: EngineInterface, text: string, settings: Settings): Promise<number> {
338  const targets = findStageTargets(text)
339  if (!targets.length) return 0
340  const now = await $.clock.now()
341  await update($, deploys, list => targets.reduce((runs, t) => addRun(runs, t, now, true), listOf<DeployRun>(list)))
342  await Promise.all(targets.map(t => refreshDeploys($, settings, t.key)))
343  return targets.length
344}
345
346// ── /hud ────────────────────────────────────────────────────────────────────────────────────────────────────────────
347
348async function runHud($: EngineInterface, args: string, settings: Settings): Promise<string> {
349  const cmd = parseHud(args)
350  if (cmd.kind === 'reply') return cmd.text
351  if (cmd.kind === 'status') return status(settings, (await read($, collapsed)) ?? settings.collapsed)
352  if (cmd.kind === 'fold') {
353    await update($, collapsed, () => cmd.isCollapsed)
354    return cmd.isCollapsed ? '已折叠' : '已展开'
355  }
356  const { deny } = await $.config.set({ key: `${PLUGIN}.${cmd.key}`, value: cmd.value })
357  return deny ? `未能修改 ${cmd.key}:${deny}` : `已设置 ${cmd.key} = ${String(cmd.value)}`
358}
359
360// ── wiring ──────────────────────────────────────────────────────────────────────────────────────────────────────────
361
362export const register: Register = (on, options) => {
363  const settings = settingsOf(options)
364  const isAnyOn = MODULE_IDS.some(id => settings[id])
365
366  on('session.start', async ($, e, next) => {
367    const result = await next(e)
368    await $.command.register({ name: 'hud', description: 'session-hud:查看 / 启停模块、折叠、修改配置', argumentHint: '[usage|mr|docs|deploy on|off | fold | unfold | set <key> <value>]' })
369    if (settings.mr) await $.command.register({ name: 'mr', description: '列出本会话涉及仓库的 Codeup MR 及链接' })
370    if (settings.docs) await $.command.register({ name: 'docs', description: '列出本会话创建或提到的飞书文档及链接' })
371    if (settings.deploy) await $.command.register({ name: 'deploys', description: '列出本会话的云效发布;带 AppStack 阶段链接则跟踪该阶段', argumentHint: '[AppStack 阶段链接]' })
372    if (settings.usage) await guard($, 'usage', () => refreshUsage($))
373    if (settings.mr) {
374      void guard($, 'mr', () => refreshMrs($, settings))
375      $.clock.every(settings.mrPollSeconds * 1_000, () => void guard($, 'mr', () => refreshMrs($, settings)))
376    }
377    if (settings.deploy) {
378      // Only runs still going are fetched; with none, a tick reads the state and stops.
379      void guard($, 'deploy', () => refreshDeploys($, settings))
380      $.clock.every(settings.deployPollSeconds * 1_000, () => void guard($, 'deploy', () => refreshDeploys($, settings)))
381    }
382    return result
383  })
384
385  // Pushed with every measurement: no polling, no extra tokens.
386  on('session.measure', async ($, e, next) => {
387    if (settings.usage) await guard($, 'usage', () => refreshUsage($))
388    return next(e)
389  })
390
391  on('prompt.submit', async ($, e, next) => {
392    if (settings.docs) await guard($, 'docs', () => rememberDocs($, findDocs(e.text), 'mentioned', settings))
393    if (settings.deploy) await guard($, 'deploy', () => trackDeploys($, e.text, settings))
394    return next(e)
395  })
396
397  on('tool.call', async ($, e, next) => {
398    const ran = await next(e)
399    const { tool, ...input } = e
400    // A failed call's error text is no output: nothing it names was made.
401    const output = !ran.isError && 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
402    if (settings.mr) await guard($, 'mr', () => rememberMrDirs($, tool, input))
403    if (settings.docs) await guard($, 'docs', () => rememberToolDocs($, tool, input, output, settings))
404    if (settings.deploy) await guard($, 'deploy', () => rememberDeploy($, tool, input, 'text' in ran && typeof ran.text === 'string' ? ran.text : '', Boolean(ran.isError), settings))
405    return ran
406  })
407
408  on('turn.complete', async ($, e, next) => {
409    const result = await next(e)
410    // A subagent's turn ends inside the main one: its answer reaches the main answer, and the refreshes wait for it.
411    if (e.agentId) return result
412    const answer = 'answer' in e && typeof e.answer === 'string' ? e.answer : ''
413    await Promise.all([
414      settings.mr && guard($, 'mr', () => refreshMrs($, settings)),
415      settings.docs && guard($, 'docs', () => rememberDocs($, findDocs(answer), 'mentioned', settings)),
416    ])
417    // Title lookups run lark-cli (seconds each): off the turn's path, on a timer.
418    if (settings.docs) $.clock.after(0, () => void guard($, 'docs', () => lookupTitles($)))
419    return result
420  })
421
422  on('command.run', { command: 'hud' }, async ($, e) => ({ text: await runHud($, e.args, settings) }))
423
424  on('command.run', { command: 'mr' }, async $ => {
425    if (!settings.mr) return { text: 'Codeup MR 模块已关闭:/hud mr on' }
426    await refreshMrs($, settings)
427    const v = await read($, mrView)
428    return { text: v?.error ?? mrList(v?.repos ?? [], state => stateOf(state).label) }
429  })
430
431  on('command.run', { command: 'deploys' }, async ($, e) => {
432    if (!settings.deploy) return { text: '云效发布模块已关闭:/hud deploy on' }
433    // `/deploys <stage link>…` tracks those stages; bare `/deploys` refreshes and lists.
434    if (!(await trackDeploys($, e.args, settings))) await refreshDeploys($, settings)
435    return { text: deployList(listOf<DeployRun>(await read($, deploys))) }
436  })
437
438  on('command.run', { command: 'docs' }, async $ => ({
439    text: settings.docs ? (await guard($, 'docs', () => lookupTitles($)), docList(listOf<DocLink>(await read($, docs)))) : '飞书文档模块已关闭:/hud docs on',
440  }))
441
442  // The band above the prompt is one site shared by every plugin: draw on top of whatever the plugins beneath drew
443  // (next), never instead of it, and step aside entirely while a survey holds it.
444  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
445    if (e.props.hasSurvey || !isAnyOn) return next(e)
446    const ui = $.ui.resolve(e)
447    const unfolded = listOf<string>(await read($, expandedLists))
448    const foldOf = (list: string): Fold => ({
449      isExpanded: unfolded.includes(list),
450      toggle: () => void update($, expandedLists, cur => {
451        const known = listOf<string>(cur)
452        return known.includes(list) ? known.filter(l => l !== list) : [...known, list]
453      }),
454    })
455    const drawn: (Section | null)[] = []
456    if (settings.usage) drawn.push(usageSection(ui, await read($, usage), await $.clock.now(), settings))
457    if (settings.mr) {
458      const mrState = { view: await read($, mrView), dismissed: listOf<string>(await read($, mrDismissed)), expanded: await read($, mrExpanded) }
459      drawn.push(mrSection(ui, mrState, mrLayout(settings, e.props.bodyColumns), {
460        // Unfolding the checks also fetches them afresh: merge state, branch, conflicts as Codeup has them now.
461        toggleChecks: key => {
462          void update($, mrExpanded, cur => (cur === key ? null : key))
463          void guard($, 'mr', () => refreshMrs($, settings))
464        },
465        dismiss: key => void update($, mrDismissed, list => [...listOf<string>(list), key]),
466      }, foldOf('mr')))
467    }
468    if (settings.docs) {
469      const shown = visibleDocs(listOf<DocLink>(await read($, docs)), listOf<string>(await read($, docsDismissed)), settings)
470      drawn.push(docsSection(ui, shown, docsLayout(settings, e.props.bodyColumns), token => void update($, docsDismissed, list => [...listOf<string>(list), token]), foldOf('docs')))
471    }
472    if (settings.deploy) {
473      const deployState = { runs: listOf<DeployRun>(await read($, deploys)), dismissed: listOf<string>(await read($, deployDismissed)), expanded: await read($, deployExpanded) }
474      drawn.push(deploySection(ui, deployState, mrLayout(settings, e.props.bodyColumns), {
475        // Unfolding a run's stages also fetches them afresh, finished or not.
476        toggle: key => {
477          void update($, deployExpanded, cur => (cur === key ? null : key))
478          void guard($, 'deploy', () => refreshDeploys($, settings, key))
479        },
480        dismiss: key => void update($, deployDismissed, list => [...listOf<string>(list), key]),
481      }, foldOf('deploy')))
482    }
483    const sections = drawn.filter((s): s is Section => s !== null)
484    if (!sections.length) return next(e)
485
486    const isCollapsed = (await read($, collapsed)) ?? settings.collapsed
487    const below = await next(e)
488    const mine = band(ui, sections, isCollapsed, () => void update($, collapsed, cur => !(cur ?? settings.collapsed)))
489    if (isBlank(below)) return mine
490    return (
491      <ui.Box flexDirection="column">
492        {mine}
493        {below}
494      </ui.Box>
495    )
496  })
497}
498
hooks/deploy/parse.ts 140 lines
1import type { DeployRun, DeployStage } from '../../types'
2
3const STAGE_URL_RE = /https:\/\/devops\.aliyun\.com\/appstack\/app\/([\w.-]+)\/workflow\/([0-9a-f]{32})\/stage\/([0-9a-f]{32})/
4const STAGE_URLS_RE = new RegExp(STAGE_URL_RE.source, 'g')
5const DEPLOY_SH_RE = /\bdeploy\.sh\s+([\w.-]+)\s+([\w.-]+)/
6const EXECUTE_RE = /\bapp-stack-execute-change-request-release-stage\b/
7/** Runs kept per session; the oldest drop off. */
8const MAX_RUNS = 10
9/** How much earlier than the trigger an execution may start and still be its run (clock skew). */
10const START_SLACK_MS = 2 * 60_000
11
12/** What a trigger names: the stage, and what deploy.sh printed about it. */
13export type DeployTarget = Omit<DeployRun, 'triggeredAt' | 'number' | 'status' | 'stages' | 'error'>
14
15const flagOf = (cmd: string, flag: string): string | undefined => cmd.match(new RegExp(`--${flag}[=\\s]+['"]?([\\w.-]+)`))?.[1]
16const lineOf = (out: string, label: string): string | undefined => out.match(new RegExp(`^\\s*${label}\\s*:\\s*(.+?)\\s*$`, 'm'))?.[1]
17
18const target = (app: string, workflowSn: string, stageSn: string, extra: Partial<DeployTarget>): DeployTarget => ({
19  key: `${app}/${stageSn}`,
20  app,
21  workflowSn,
22  stageSn,
23  url: `https://devops.aliyun.com/appstack/app/${app}/workflow/${workflowSn}/stage/${stageSn}/current`,
24  ...Object.fromEntries(Object.entries(extra).filter(([, v]) => v)),
25})
26
27/**
28 * The AppStack release stage a finished Bash call triggered, or null: a yunxiao-deploy `deploy.sh` run that printed
29 * `Triggered.` and the stage page, or a direct `app-stack-execute-change-request-release-stage` call that did not fail.
30 * A dry run prints no `Triggered.`, so it never counts.
31 */
32export function triggerOf(tool: string, input: { command?: unknown }, output: string, isError: boolean): DeployTarget | null {
33  const cmd = typeof input.command === 'string' ? input.command : ''
34  if (tool !== 'Bash' || isError || !cmd) return null
35  const deploySh = cmd.match(DEPLOY_SH_RE)
36  if (deploySh && /\bTriggered\b/.test(output)) {
37    const url = output.match(STAGE_URL_RE)
38    if (!url?.[1] || !url[2] || !url[3]) return null
39    const envLine = lineOf(output, 'Env') ?? ''
40    const branch = lineOf(output, 'Branch')
41    return target(url[1], url[2], url[3], {
42      env: envLine.split(/\s+/)[0] || deploySh[2],
43      stageName: envLine.match(/\(stage:\s*([^/)]+?)\s*(?:\/|\))/)?.[1],
44      branch: branch && !branch.startsWith('<') ? branch : undefined,
45      feature: lineOf(output, 'Labelled') ?? lineOf(output, 'Feature')?.replace(/^<none.*$/, ''),
46    })
47  }
48  if (!EXECUTE_RE.test(cmd) || /--cli-dry-run\b/.test(cmd)) return null
49  const app = flagOf(cmd, 'app-name')
50  const wf = flagOf(cmd, 'release-workflow-sn')
51  const stage = flagOf(cmd, 'release-stage-sn')
52  return app && wf && stage ? target(app, wf, stage, {}) : null
53}
54
55/** Every AppStack stage page linked in `text`, deduped: what a pasted link asks to track. */
56export function findStageTargets(text: string): DeployTarget[] {
57  const found = new Map<string, DeployTarget>()
58  for (const [, app, wf, stage] of text.matchAll(STAGE_URLS_RE)) {
59    if (app && wf && stage && !found.has(`${app}/${stage}`)) found.set(`${app}/${stage}`, target(app, wf, stage, {}))
60  }
61  return [...found.values()]
62}
63
64/** Adds a run, newest first; triggering (or tracking) a stage again starts its entry over. */
65export function addRun(known: readonly DeployRun[], t: DeployTarget, now: number, isTracked = false): DeployRun[] {
66  const run: DeployRun = isTracked ? { ...t, triggeredAt: now, isTracked } : { ...t, triggeredAt: now }
67  return [run, ...known.filter(r => r.key !== t.key)].slice(0, MAX_RUNS)
68}
69
70const ENV_KEYWORDS: readonly (readonly [string, RegExp])[] = [['prod', /生产|线上|正式|prod/i], ['pre', /预发|灰度|staging|pre/i], ['test', /测试|日常|daily|test/i]]
71
72/** A release stage's name as the env it deploys to: 生产阶段 → prod, 预发阶段 → pre; else the name without 阶段. */
73export function envOfStage(name: string): string {
74  return ENV_KEYWORDS.find(([, re]) => re.test(name))?.[0] ?? name.replace(/阶段$/, '')
75}
76
77const TERMINAL = new Set(['SUCCESS', 'FAIL', 'FAILED', 'CANCELED', 'CANCELLED'])
78
79/** Finished runs are not polled; an unpinned run (its execution not found yet) still is. */
80export const isSettled = (r: DeployRun) => r.number !== undefined && r.status !== undefined && TERMINAL.has(r.status)
81
82/** The stage execution that is this run: the latest one, unless it started before the trigger (0: any). */
83export function pickExecution(latest: { number?: unknown; startTime?: unknown } | undefined, triggeredAt: number): string | undefined {
84  if (!latest || (typeof latest.number !== 'string' && typeof latest.number !== 'number')) return undefined
85  const started = typeof latest.startTime === 'string' ? Date.parse(latest.startTime) : NaN
86  return Number.isNaN(started) || started >= triggeredAt - START_SLACK_MS ? String(latest.number) : undefined
87}
88
89type ApiJob = { name?: unknown; status?: unknown }
90type ApiStage = { name?: unknown; stageInfo?: { name?: unknown; status?: unknown; jobs?: ApiJob[] } }
91export type ApiPipelineRun = { pipelineRun?: { status?: unknown; stages?: ApiStage[]; sources?: { data?: { branch?: unknown } }[] } }
92
93const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? v : fallback)
94
95/** A getPipelineRun answer as the run's status, its stages' jobs and the branch it built. */
96export function runOf(api: ApiPipelineRun): { status: string; stages: DeployStage[]; branch?: string } {
97  const run = api.pipelineRun ?? {}
98  const stages = (run.stages ?? []).map((s): DeployStage => ({
99    name: str(s.name ?? s.stageInfo?.name, '阶段'),
100    status: str(s.stageInfo?.status, 'INIT'),
101    jobs: (s.stageInfo?.jobs ?? []).map(j => ({ name: str(j.name, '任务'), status: str(j.status, 'INIT') })),
102  }))
103  const branch = run.sources?.[0]?.data?.branch
104  return { status: str(run.status, 'RUNNING'), stages, ...(typeof branch === 'string' && branch ? { branch } : {}) }
105}
106
107export type Tone = 'success' | 'error' | 'warning' | 'inactive' | 'suggestion'
108
109const STATES: Record<string, { label: string; glyph: string; tone: Tone }> = {
110  SUCCESS: { label: '成功', glyph: '✓', tone: 'success' },
111  FAIL: { label: '失败', glyph: '✕', tone: 'error' },
112  FAILED: { label: '失败', glyph: '✕', tone: 'error' },
113  RUNNING: { label: '运行中', glyph: '◐', tone: 'suggestion' },
114  WAITING: { label: '待确认', glyph: '◷', tone: 'warning' },
115  SWITCH_MANUAL: { label: '待确认', glyph: '◷', tone: 'warning' },
116  CANCELED: { label: '已取消', glyph: '⊘', tone: 'inactive' },
117  CANCELLED: { label: '已取消', glyph: '⊘', tone: 'inactive' },
118  SKIPPED: { label: '已跳过', glyph: '–', tone: 'inactive' },
119  INIT: { label: '未开始', glyph: '○', tone: 'inactive' },
120  QUEUED: { label: '排队中', glyph: '○', tone: 'inactive' },
121}
122
123export function stateOf(status: string | undefined): { label: string; glyph: string; tone: Tone } {
124  if (status === undefined) return { label: '查询中', glyph: '…', tone: 'inactive' }
125  return STATES[status] ?? { label: status, glyph: '?', tone: 'inactive' }
126}
127
128/**
129 * Where the run stands, as one line: the job it stopped at or is on (the first that is not done), else the last job.
130 * `✕ 灰度人工卡点`, `◐ 预热发布`, `✓ 全量发布`.
131 */
132export function progressOf(r: DeployRun): string {
133  if (r.error) return `查询失败:${r.error}`
134  const jobs = (r.stages ?? []).flatMap(s => s.jobs)
135  if (!jobs.length) return r.number ? `#${r.number}` : '等待运行开始'
136  const current = jobs.find(j => j.status !== 'SUCCESS' && j.status !== 'SKIPPED') ?? jobs[jobs.length - 1]
137  const done = jobs.filter(j => j.status === 'SUCCESS').length
138  return current ? `${stateOf(current.status).glyph} ${current.name} (${done}/${jobs.length})` : ''
139}
140
hooks/deploy/view.tsx 107 lines
1import type { RenderElement } from 'claude-code'
2
3import type { DeployRun } from '../../types'
4import type { Settings } from '../settings'
5import { ICON_W, ID_W, STATE_W, capRows, cell } from '../ui'
6import type { Fold, Section, Ui } from '../ui'
7import { progressOf, stateOf } from './parse'
8
9/** The 检查 button's column, as wide as the MR rows' so the two line up at the right. */
10const CHECKS_W = 12
11/** The progress column spans the MR rows' `→ target` columns: the arrow, its gap and the target. */
12const ARROW_AND_GAP = 2
13const SUMMARY_MAX = 3
14
15export type DeployActions = {
16  toggle: (key: string) => void
17  dismiss: (key: string) => void
18}
19
20export type DeployState = { runs: readonly DeployRun[]; dismissed: readonly string[]; expanded: string | null }
21
22/** A run's stages unfolded: one line per job, `✓ 部署 · 预热发布`, the release note first. */
23function jobList(ui: Ui, r: DeployRun): RenderElement {
24  const { Box, Text } = ui
25  const jobs = (r.stages ?? []).flatMap(s => s.jobs.map(j => ({ stage: s.name, ...j })))
26  return (
27    <Box key={`deploy-jobs-${r.key}`} flexDirection="column" paddingLeft={ICON_W + ID_W + 2}>
28      {r.feature ? (
29        <Box key={`deploy-feature-${r.key}`} height={1} overflow="hidden">
30          <Text dimColor wrap="truncate-end">发布说明:{r.feature}</Text>
31        </Box>
32      ) : null}
33      {jobs.length ? jobs.map((j, i) => {
34        const s = stateOf(j.status)
35        return (
36          <Box key={`deploy-job-${r.key}-${i}`} height={1} columnGap={1}>
37            <Text color={s.tone}>{s.glyph}</Text>
38            <Text wrap="truncate-end">{j.stage} · {j.name}</Text>
39            <Text dimColor>{s.label}</Text>
40          </Box>
41        )
42      }) : <Text key={`deploy-nojobs-${r.key}`} dimColor>{progressOf(r)}</Text>}
43    </Box>
44  )
45}
46
47/** One release on the MR rows' grid: env under `!id`, the run's state under the MR state, app (the link), branch, progress. */
48function row(ui: Ui, r: DeployRun, settings: Settings, isOpen: boolean, act: DeployActions): RenderElement {
49  const { Box, Button, Text } = ui
50  const s = stateOf(r.error ? undefined : r.status)
51  const sum = r.error ? { glyph: '!', tone: 'warning' } : s
52  return (
53    <Box key={`deploy-${r.key}`} flexDirection="column">
54      <Box height={1} flexWrap="nowrap" overflow="hidden" columnGap={1} alignItems="center">
55        <Box width={ICON_W} flexShrink={0}><Text color={s.tone}>⇪</Text></Box>
56        {cell(ui, { key: `deploy-env-${r.key}`, text: r.env ?? r.stageName ?? '发布', width: ID_W, dimColor: true })}
57        {cell(ui, { key: `deploy-state-${r.key}`, text: s.label, width: STATE_W, color: s.tone })}
58        {cell(ui, { key: `deploy-app-${r.key}`, text: r.app, width: settings.mrRepoWidth, href: r.url })}
59        {cell(ui, { key: `deploy-branch-${r.key}`, text: r.branch ?? r.stageName ?? '', width: settings.mrSourceWidth, mode: 'middle', dimColor: true })}
60        {cell(ui, { key: `deploy-progress-${r.key}`, text: progressOf(r), width: settings.mrTargetWidth + ARROW_AND_GAP, color: r.error ? 'warning' : undefined, dimColor: !r.error })}
61        <Box flexGrow={1} />
62        <Box key={`deploy-checks-${r.key}`} minWidth={CHECKS_W} flexShrink={0}>
63          <Button key={`deploy-check-${r.key}`} onPress={() => act.toggle(r.key)}>
64            <Text color={sum.tone}>{sum.glyph}</Text> 检查 {isOpen ? '⌃' : '⌄'}
65          </Button>
66        </Box>
67        <Button key={`deploy-dismiss-${r.key}`} role="dismiss" plain label="×" dimColor onPress={() => act.dismiss(r.key)} />
68      </Box>
69      {isOpen ? jobList(ui, r) : null}
70    </Box>
71  )
72}
73
74/** `/deploys` output: one markdown line per release, linked. */
75export function deployList(runs: readonly DeployRun[]): string {
76  const lines = runs.map(r => {
77    const s = stateOf(r.status)
78    const where = [r.env, r.branch && `\`${r.branch}\``].filter(Boolean).join(' · ')
79    return `- ${s.glyph} **${r.app}**${r.isTracked ? '(跟踪)' : ''} ${where} · ${s.label}${r.number ? ` #${r.number}` : ''} · [AppStack](${r.url}) · ${progressOf(r)}`
80  })
81  return lines.length ? lines.join('\n') : '本会话还没有触发或跟踪云效发布。跟踪已有发布:`/deploys <AppStack 阶段链接>`,或在消息里贴该链接。'
82}
83
84/** One row per release the session triggered, newest first; null with none. */
85export function deploySection(ui: Ui, state: DeployState, settings: Settings, act: DeployActions, fold: Fold): Section | null {
86  const runs = state.runs.filter(r => !state.dismissed.includes(r.key))
87  if (!runs.length) return null
88  const { Box, Text } = ui
89  const rows = runs.map(r => row(ui, r, settings, state.expanded === r.key, act))
90  const shown = runs.slice(0, SUMMARY_MAX)
91  return {
92    rows: capRows(ui, 'deploy', rows, settings.deployMaxRows, '次发布', fold),
93    summary: [
94      ...shown.map(r => {
95        const s = stateOf(r.status)
96        return (
97          <Box key={`deploy-sum-${r.key}`} columnGap={1} flexShrink={0}>
98            <Text color={s.tone}>⇪{r.app}{r.env ? `·${r.env}` : ''}</Text>
99            <Text color={s.tone}>{s.glyph}</Text>
100          </Box>
101        )
102      }),
103      ...(runs.length > shown.length ? [<Text key="deploy-sum-more" dimColor>+{runs.length - shown.length}</Text>] : []),
104    ],
105  }
106}
107
hooks/docs/links.ts 73 lines
1import type { DocLink, DocOrigin } from '../../types'
2
3const HOSTS = String.raw`[\w-]+(?:\.[\w-]+)*\.(?:feishu\.cn|larksuite\.com|larkoffice\.com|feishu\.net)`
4const KINDS = 'docx|docs|doc|wiki|sheets|sheet|base|bitable|mindnotes|mindnote|slides|file'
5// The token must end the path segment: `/wiki/settings/123` is a page, not the document `settings`.
6const URL_RE = new RegExp(String.raw`https?://(${HOSTS})/(${KINDS})/([A-Za-z0-9]{8,})(?![\w/-])`, 'gi')
7const MD_LINK_RE = /\[([^\]\n]{1,200})\]\((https?:\/\/[^)\s]+)\)/g
8const CREATE_RE = /\blark-cli\b[^\n;&|]*?\s\+(?:create|import|copy|duplicate)\b/
9/** Mentions kept per session; created documents are always kept. */
10const MAX_MENTIONED = 30
11
12export type FoundDoc = { token: string; url: string; kind: string; label?: string }
13
14export const KIND_LABELS: Record<string, string> = {
15  docx: '文档', docs: '文档', doc: '文档', wiki: '知识库', sheets: '表格', sheet: '表格',
16  base: '多维表', bitable: '多维表', mindnotes: '脑图', mindnote: '脑图', slides: '幻灯片', file: '文件',
17}
18
19/** Every Feishu / Lark document link in `text`, deduped by token; a markdown link's text is its label. */
20export function findDocs(text: string): FoundDoc[] {
21  const labels = new Map<string, string>()
22  for (const [, label, url] of text.matchAll(MD_LINK_RE)) {
23    const token = [...(url ?? '').matchAll(URL_RE)][0]?.[3]
24    const shown = label?.trim()
25    if (token && shown && !/^https?:\/\//i.test(shown)) labels.set(token, shown)
26  }
27  const found = new Map<string, FoundDoc>()
28  for (const [, host, kind, token] of text.matchAll(URL_RE)) {
29    if (!host || !kind || !token || found.has(token)) continue
30    const label = labels.get(token)
31    const k = kind.toLowerCase()
32    found.set(token, { token, kind: k, url: `https://${host.toLowerCase()}/${k}/${token}`, ...(label ? { label } : {}) })
33  }
34  return [...found.values()]
35}
36
37/** A tool call that makes a document: `lark-cli [flags] <domain> +create|+import|+copy`, or a Lark MCP tool named so. */
38export function isCreation(tool: string, input: { command?: unknown }): boolean {
39  if (tool === 'Bash') return typeof input.command === 'string' && CREATE_RE.test(input.command)
40  return /lark|feishu/i.test(tool) && /create|import|copy|duplicate/i.test(tool)
41}
42
43/** A tool call worth scanning for links: lark-cli in Bash, or a Lark / Feishu MCP tool. */
44export function isLarkTool(tool: string, input: { command?: unknown }): boolean {
45  if (tool === 'Bash') return typeof input.command === 'string' && /\blark-cli\b/.test(input.command)
46  return /lark|feishu/i.test(tool)
47}
48
49/**
50 * Adds what was found, newest first: `created` outranks `mentioned`, a looked-up title is never replaced, and only
51 * mentions are dropped past the cap.
52 */
53export function mergeDocs(known: readonly DocLink[], found: readonly FoundDoc[], origin: DocOrigin, now: number): DocLink[] {
54  const byToken = new Map(known.map(d => [d.token, d]))
55  const fresh = found.map((f): DocLink => {
56    const old = byToken.get(f.token)
57    const label = f.label ?? old?.label
58    return {
59      ...old,
60      token: f.token,
61      url: old?.url ?? f.url,
62      kind: f.kind,
63      origin: old?.origin === 'created' || origin === 'created' ? 'created' : 'mentioned',
64      at: now,
65      ...(label ? { label } : {}),
66    }
67  })
68  const tokens = new Set(fresh.map(d => d.token))
69  const all = [...fresh, ...known.filter(d => !tokens.has(d.token))]
70  const keptMentions = new Set(all.filter(d => d.origin === 'mentioned').slice(0, MAX_MENTIONED).map(d => d.token))
71  return all.filter(d => d.origin === 'created' || keptMentions.has(d.token))
72}
73
hooks/docs/lookup.ts 44 lines
1/** The Desktop app does not inherit the shell's PATH, so the usual install folders are tried after the bare name. */
2export const LARK_CLI = ['lark-cli', '/opt/homebrew/bin/lark-cli', '/usr/local/bin/lark-cli'] as const
3
4/** URL path kinds `+inspect` spells differently from the links people share. */
5const INSPECT_KINDS: Record<string, string> = { mindnotes: 'mindnote', docs: 'doc' }
6
7/** The argv that prints a document's type, title and canonical token as JSON (wiki nodes unwrapped). */
8export function inspectArgv(bin: string, url: string): string[] {
9  const fixed = url.replace(/\/(mindnotes|docs)\//, (_m, kind: string) => `/${INSPECT_KINDS[kind] ?? kind}/`)
10  return [bin, 'drive', '+inspect', '--url', fixed, '--format', 'json']
11}
12
13/** A spawn failure that means "no such program here", so the next install folder is worth trying. */
14export const isMissingProgram = (err: unknown): boolean => /ENOENT|not found|no such file/i.test(err instanceof Error ? err.message : String(err))
15
16/** What one lookup came to: a title; a final no (no access, no such doc); or a failure worth trying again later. */
17export type Lookup = { title: string } | { final: string } | { retry: string }
18
19/**
20 * Reads one `lark-cli drive +inspect` run. An exit with an authorization error (no access) or a success without a
21 * title is final; any other failure (a timeout, a network error, a CLI that could not start) is tried again later.
22 */
23export function lookupOf(run: { exitCode: number; stdout: string; stderr: string } | { failed: string }): Lookup {
24  if ('failed' in run) return { retry: run.failed }
25  if (run.exitCode === 0) {
26    const title = titleOf(run.stdout)
27    return title ? { title } : { final: '无标题' }
28  }
29  const text = `${run.stderr}\n${run.stdout}`
30  if (/"type"\s*:\s*"authorization"|permission/i.test(text)) return { final: '无权限' }
31  return { retry: `lark-cli exit ${run.exitCode}: ${text.trim().split('\n').slice(-3).join(' ').slice(0, 160)}` }
32}
33
34/** Pulls `data.title` out of `lark-cli drive +inspect` JSON; undefined when the call failed or has none. */
35export function titleOf(stdout: string): string | undefined {
36  try {
37    const parsed = JSON.parse(stdout) as { ok?: boolean; data?: { title?: unknown } }
38    const title = parsed.ok === false ? undefined : parsed.data?.title
39    return typeof title === 'string' && title.trim() ? title.trim() : undefined
40  } catch {
41    return undefined
42  }
43}
44
hooks/docs/view.tsx 79 lines
1import type { RenderElement } from 'claude-code'
2
3import type { DocLink, DocOrigin } from '../../types'
4import type { Settings } from '../settings'
5import { DISMISS_W, ICON_W, ID_W, STATE_W, TOGGLE_W, capRows, cell } from '../ui'
6import type { Fold, Section, Ui } from '../ui'
7import { KIND_LABELS } from './links'
8
9const TOKEN_SHOWN = 8
10
11/** The document's state chip: what the session made stands out, what it only mentioned is dim. */
12const ORIGINS: Record<DocOrigin, { label: string; tone?: string; isDim?: boolean }> = {
13  created: { label: '创建', tone: 'success' },
14  mentioned: { label: '提及', isDim: true },
15}
16
17const GLYPHS: Record<string, string> = { wiki: '◈', sheets: '▦', sheet: '▦', base: '▥', bitable: '▥', slides: '▭', mindnotes: '◎', mindnote: '◎', file: '▢' }
18
19const kindLabel = (kind: string) => KIND_LABELS[kind] ?? kind
20
21/** The title; until it is known, the link text it was seen with, else the kind and the token's head. */
22export const docTitle = (d: DocLink) => d.title ?? d.label ?? `${kindLabel(d.kind)} ${d.token.slice(0, TOKEN_SHOWN)}…`
23
24/** Cells a doc row takes besides its title: glyph, kind, origin, ×, the five gaps between, and the fold toggle. */
25const ROW_OVERHEAD = ICON_W + ID_W + STATE_W + DISMISS_W + 5 + TOGGLE_W
26const MIN_TITLE_W = 10
27
28/** The settings with the title column narrowed to what `bodyColumns` leaves, so the row's controls stay on screen. */
29export const docsLayout = (settings: Settings, bodyColumns: number): Settings => ({
30  ...settings,
31  docsTitleWidth: Math.max(MIN_TITLE_W, Math.min(settings.docsTitleWidth, bodyColumns - ROW_OVERHEAD)),
32})
33
34/** The documents the band shows: not dismissed, and mentions only while `docsMentioned` is on. */
35export const visibleDocs = (list: readonly DocLink[], dismissed: readonly string[], settings: Settings): DocLink[] =>
36  list.filter(d => !dismissed.includes(d.token) && (settings.docsMentioned || d.origin === 'created'))
37
38/** `/docs` output: one markdown line per document, linked. */
39export function docList(list: readonly DocLink[]): string {
40  const lines = list.map(d => `- ${ORIGINS[d.origin].label} · ${kindLabel(d.kind)} · [${docTitle(d)}](${d.url})`)
41  return lines.length ? lines.join('\n') : '本会话还没有创建或提到飞书文档。'
42}
43
44/**
45 * One document on the MR rows' grid: glyph, kind under `!id`, 创建 / 提及 under the MR state, then the title, which is
46 * the link (cut to its column, whole on hover).
47 */
48function row(ui: Ui, d: DocLink, settings: Settings, dismiss: (token: string) => void): RenderElement {
49  const { Box, Button, Text } = ui
50  const o = ORIGINS[d.origin]
51  return (
52    <Box key={`doc-${d.token}`} height={1} flexWrap="nowrap" overflow="hidden" columnGap={1} alignItems="center">
53      <Box width={ICON_W} flexShrink={0}><Text color="suggestion">{GLYPHS[d.kind] ?? '▤'}</Text></Box>
54      {cell(ui, { key: `doc-kind-${d.token}`, text: kindLabel(d.kind), width: ID_W, dimColor: true })}
55      {cell(ui, { key: `doc-origin-${d.token}`, text: o.label, width: STATE_W, color: o.tone, dimColor: o.isDim })}
56      {cell(ui, { key: `doc-title-${d.token}`, text: docTitle(d), width: settings.docsTitleWidth, href: d.url })}
57      <Box flexGrow={1} />
58      <Button key={`doc-dismiss-${d.token}`} role="dismiss" plain label="×" dimColor onPress={() => dismiss(d.token)} />
59    </Box>
60  )
61}
62
63/** One row per document, newest first, capped at `docsMaxRows`; null with none. */
64export function docsSection(ui: Ui, list: readonly DocLink[], settings: Settings, dismiss: (token: string) => void, fold: Fold): Section | null {
65  if (!list.length) return null
66  const { Box, Text } = ui
67  const created = list.filter(d => d.origin === 'created').length
68  return {
69    rows: capRows(ui, 'docs', list.map(d => row(ui, d, settings, dismiss)), settings.docsMaxRows, '篇文档', fold),
70    summary: [
71      <Box key="docs-sum" columnGap={1} flexShrink={0}>
72        <Text color="suggestion">▤</Text>
73        <Text>文档 {list.length}</Text>
74        {created ? <Text dimColor>(创建 {created})</Text> : null}
75      </Box>,
76    ],
77  }
78}
79
hooks/hud.tsx 75 lines
1import type { RenderElement } from 'claude-code'
2
3import { DEFAULTS, MODULE_IDS, isSettingKey, parseSetting } from './settings'
4import type { SettingKey, Settings } from './settings'
5import { TOGGLE_W, gap } from './ui'
6import type { Section, Ui } from './ui'
7
8const MODULE_NAMES: Record<string, string> = { usage: '用量', mr: 'Codeup MR', docs: '飞书文档', deploy: '云效发布' }
9
10export const HELP = [
11  '用法:',
12  '- `/hud` 查看各模块开关与配置',
13  '- `/hud usage|mr|docs|deploy on|off` 启停模块(写入插件配置,立即生效)',
14  '- `/hud fold` / `/hud unfold` 折叠 / 展开本会话的 HUD(默认值:`/hud set collapsed on|off`)',
15  '- `/hud set <配置项> <值>` 改任一配置,如 `/hud set mrSourceWidth 30`',
16].join('\n')
17
18/** `/hud` with no arguments: each module's switch, then every other setting. */
19export function status(settings: Settings, isCollapsed: boolean): string {
20  const modules = MODULE_IDS.map(id => `- ${MODULE_NAMES[id]} \`${id}\`:${settings[id] ? '开' : '关'}`)
21  const rest = (Object.keys(DEFAULTS) as SettingKey[])
22    .filter(k => !(MODULE_IDS as readonly string[]).includes(k))
23    .map(k => `\`${k}\`=${String(settings[k])}`)
24  return [`**session-hud**(${isCollapsed ? '已折叠' : '已展开'})`, ...modules, `配置:${rest.join(' · ')}`, '', HELP].join('\n')
25}
26
27export type HudCommand =
28  | { kind: 'status' }
29  | { kind: 'fold'; isCollapsed: boolean }
30  | { kind: 'set'; key: SettingKey; value: boolean | number }
31  | { kind: 'reply'; text: string }
32
33/** `/hud` arguments → what to do. */
34export function parseHud(args: string): HudCommand {
35  const [first = '', second = '', third = ''] = args.trim().split(/\s+/)
36  if (!first) return { kind: 'status' }
37  if (first === 'fold' || first === 'unfold') return { kind: 'fold', isCollapsed: first === 'fold' }
38  const [key, text] = first === 'set' ? [second, third] : [first, second]
39  const isModule = (MODULE_IDS as readonly string[]).includes(first)
40  if (!isModule && first !== 'set') return { kind: 'reply', text: HELP }
41  if (!isSettingKey(key)) return { kind: 'reply', text: `没有配置项 \`${key}\`\n\n${HELP}` }
42  const parsed = parseSetting(key, text)
43  return 'error' in parsed ? { kind: 'reply', text: parsed.error } : { kind: 'set', key, value: parsed.value }
44}
45
46/** The band: every enabled module's rows under one fold toggle, or folded, one summary row. */
47export function band(ui: Ui, sections: readonly Section[], isCollapsed: boolean, onToggle: () => void): RenderElement {
48  const { Box, Button, Text } = ui
49  const toggle = (
50    <Box key="hud-toggle" width={TOGGLE_W} flexShrink={0}>
51      <Button key="hud-fold" plain dimColor label={isCollapsed ? '▸' : '▾'} onPress={onToggle} />
52    </Box>
53  )
54  if (isCollapsed) {
55    const parts = sections.flatMap((s, i) => (i === 0 ? s.summary : [<Text key={`hud-sep-${i}`} dimColor>·</Text>, ...s.summary]))
56    return (
57      <Box key="hud" height={1} flexWrap="nowrap" overflow="hidden" columnGap={1}>
58        {toggle}
59        {parts}
60      </Box>
61    )
62  }
63  const rows = sections.flatMap(s => s.rows)
64  return (
65    <Box key="hud" flexDirection="column">
66      {rows.map((r, i) => (
67        <Box key={`hud-row-${i}`} flexDirection="row">
68          {i === 0 ? toggle : gap(ui, `hud-pad-${i}`, TOGGLE_W)}
69          <Box flexGrow={1} flexDirection="column">{r}</Box>
70        </Box>
71      ))}
72    </Box>
73  )
74}
75
hooks/mr/codeup.ts 167 lines
1import type { Check, CheckStatus, MrView } from '../../types'
2
3export const API_BASE = 'https://openapi-rdc.aliyuncs.com/oapi/v1/codeup/organizations'
4export const TOKEN_VAR = 'ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN'
5const MAX_SHOWN = 3
6const CLOSED_STATES = new Set(['MERGED', 'CLOSED'])
7
8export type Repo = { org: string; path: string }
9
10/** git@codeup.aliyun.com:ORG/group/repo.git | https://[user@]codeup.aliyun.com/ORG/group/repo(.git) */
11export function parseRemote(url: string): Repo | null {
12  const m = url.trim().match(/^(?:git@codeup\.aliyun\.com:|(?:ssh|https?):\/\/(?:[^@/]+@)?codeup\.aliyun\.com(?::\d+)?\/)(.+?)(?:\.git)?\/?$/)
13  const path = m?.[1]
14  const org = path?.split('/')[0]
15  return path && org && path.includes('/') ? { org, path } : null
16}
17
18/** Value of the last `export ALIBABA_CLOUD_YUNXIAO_ACCESS_TOKEN=...` line in a shell profile. */
19export function tokenFromProfile(text: string): string | undefined {
20  return exportFromProfile(text, TOKEN_VAR)
21}
22
23/** Value of the last `export <name>=...` line in a shell profile, quotes and trailing comment stripped. */
24export function exportFromProfile(text: string, name: string): string | undefined {
25  const lines = text.split('\n').filter(l => new RegExp(`^\\s*export\\s+${name}=`).test(l))
26  const raw = lines.at(-1)?.replace(/^[^=]*=/, '').replace(/\s+#.*$/, '').trim()
27  const value = raw?.replace(/^(['"])(.*)\1$/, '$2')
28  return value || undefined
29}
30
31type ApiReviewer = { reviewOpinionStatus?: string }
32export type ApiChangeRequest = {
33  localId: number
34  detailUrl: string
35  title: string
36  sourceBranch: string
37  targetBranch: string
38  state: string
39  hasConflict?: boolean
40  reviewers?: ApiReviewer[]
41  unResolvedCommentCount?: number
42}
43type ApiCheckItem = { itemType: string; pass?: boolean }
44export type ApiDetail = {
45  checkList?: { requirementRuleItems?: ApiCheckItem[]; needAttentionItems?: ApiCheckItem[] }
46  /** Source commits the target does not have yet. */
47  ahead?: number
48  /** Target commits the source does not have: how far the branch is behind. */
49  behind?: number
50}
51export type ApiCompare = { diffs?: { diff?: string }[] }
52
53export const isOpen = (cr: ApiChangeRequest) => !CLOSED_STATES.has(cr.state)
54
55/** Open MRs of the branch (newest first, capped); none open → the latest closed/merged one. */
56export function pickMrs<T extends ApiChangeRequest>(list: readonly T[], branch: string): T[] {
57  const mine = list.filter(cr => cr.sourceBranch === branch)
58  const open = mine.filter(isOpen)
59  return open.length ? open.slice(0, MAX_SHOWN) : mine.slice(0, 1)
60}
61
62/** +/- line counts of a Codeup compare, same as `git diff --shortstat target...source`. */
63export function diffStat(compare: ApiCompare): { additions: number; deletions: number } {
64  const lines = (compare.diffs ?? []).flatMap(d => (d.diff ?? '').split('\n'))
65  return {
66    additions: lines.filter(l => l.startsWith('+') && !l.startsWith('+++')).length,
67    deletions: lines.filter(l => l.startsWith('-') && !l.startsWith('---')).length,
68  }
69}
70
71const CHECK_LABELS: Record<string, string> = { MERGE_CONFLICT_CHECK: '合并冲突检查' }
72
73/** Merge requirements, conflict, reviews and unresolved comments as one checks list (the CI dropdown). */
74/**
75 * Where the branch stands: merged or how much is left to merge, how far it is behind its target, and whether the source
76 * branch still exists (kept while open; worth deleting once merged). `isBranchAlive` undefined: not known, not shown.
77 */
78export function branchChecks(cr: ApiChangeRequest, detail: ApiDetail | undefined, isBranchAlive: boolean | undefined): Check[] {
79  const isMerged = cr.state === 'MERGED'
80  const merge: Check = isMerged
81    ? { label: `已合入 ${cr.targetBranch}`, status: 'pass' }
82    : !isOpen(cr)
83      ? { label: '未合入(MR 已关闭)', status: 'fail' }
84      : { label: detail?.ahead === undefined ? '待合入' : `待合入 ${detail.ahead} 个提交`, status: 'pending' }
85  const behind = detail?.behind
86  const sync: Check[] = !isOpen(cr) || behind === undefined ? []
87    : [behind > 0 ? { label: `落后 ${cr.targetBranch} ${behind} 个提交`, status: 'pending' } : { label: `已同步 ${cr.targetBranch}`, status: 'pass' }]
88  const branch: Check[] = isBranchAlive === undefined ? []
89    : isOpen(cr)
90      ? [isBranchAlive ? { label: '源分支存在', status: 'pass' } : { label: '源分支已删除', status: 'fail' }]
91      : [isBranchAlive ? { label: '源分支未删除', status: 'pending' } : { label: '源分支已删除', status: 'pass' }]
92  return [merge, ...sync, ...branch]
93}
94
95export function checksOf(cr: ApiChangeRequest, detail: ApiDetail | undefined, isBranchAlive?: boolean): Check[] {
96  const rules = (detail?.checkList?.requirementRuleItems ?? []).map((it): Check => ({
97    label: CHECK_LABELS[it.itemType] ?? it.itemType,
98    status: it.pass ? 'pass' : 'fail',
99  }))
100  const attention = (detail?.checkList?.needAttentionItems ?? []).map((it): Check => ({ label: CHECK_LABELS[it.itemType] ?? it.itemType, status: 'fail' }))
101  const reviewers = cr.reviewers ?? []
102  const approved = reviewers.filter(r => r.reviewOpinionStatus === 'PASS').length
103  const reviewStatus: CheckStatus = reviewers.some(r => r.reviewOpinionStatus === 'NOT_PASS') ? 'fail' : approved === reviewers.length ? 'pass' : 'pending'
104  const reviews: Check[] = reviewers.length ? [{ label: `评审 ${approved}/${reviewers.length}`, status: reviewStatus }] : []
105  const conflict: Check[] = cr.hasConflict && !rules.some(r => r.label === CHECK_LABELS.MERGE_CONFLICT_CHECK) ? [{ label: '存在冲突', status: 'fail' }] : []
106  const unresolved = cr.unResolvedCommentCount ?? 0
107  const comments: Check[] = unresolved ? [{ label: `未解决评论 ${unresolved}`, status: 'pending' }] : []
108  return [...branchChecks(cr, detail, isBranchAlive), ...rules, ...attention, ...conflict, ...reviews, ...comments]
109}
110
111export function summarize(checks: readonly Check[]): CheckStatus | 'none' {
112  if (!checks.length) return 'none'
113  if (checks.some(c => c.status === 'fail')) return 'fail'
114  return checks.some(c => c.status === 'pending') ? 'pending' : 'pass'
115}
116
117export function toView(cr: ApiChangeRequest, repoPath: string, detail?: ApiDetail, stats?: { additions: number; deletions: number }, isBranchAlive?: boolean): MrView {
118  return {
119    id: cr.localId,
120    url: cr.detailUrl,
121    title: cr.title,
122    repo: repoLabel(repoPath),
123    source: cr.sourceBranch,
124    target: cr.targetBranch,
125    state: cr.state,
126    ...stats,
127    checks: checksOf(cr, detail, isBranchAlive),
128  }
129}
130
131/** `org/frontend/web-app` → `frontend/web-app`: group and name, without the org id. */
132export function repoLabel(path: string): string {
133  return path.split('/').slice(1).slice(-2).join('/') || path
134}
135
136/** 39230 → `39,230` */
137export function thousands(n: number): string {
138  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
139}
140
141
142export type Tone = 'success' | 'warning' | 'error' | 'merged' | 'inactive'
143
144const STATES: Record<string, { label: string; tone: Tone; hex: string }> = {
145  UNDER_DEV: { label: '草稿', tone: 'inactive', hex: '#8c959f' },
146  UNDER_REVIEW: { label: '评审中', tone: 'success', hex: '#1a7f37' },
147  TO_BE_MERGED: { label: '待合并', tone: 'success', hex: '#1a7f37' },
148  MERGED: { label: '已合并', tone: 'merged', hex: '#8250df' },
149  CLOSED: { label: '已关闭', tone: 'error', hex: '#cf222e' },
150}
151
152export function stateOf(state: string): { label: string; tone: Tone; hex: string } {
153  return STATES[state] ?? { label: state, tone: 'inactive', hex: '#8c959f' }
154}
155
156export const CHECK_GLYPH: Record<CheckStatus | 'none', { glyph: string; tone: Tone | undefined }> = {
157  pass: { glyph: '✓', tone: 'success' },
158  fail: { glyph: '✕', tone: 'error' },
159  pending: { glyph: '○', tone: 'warning' },
160  none: { glyph: '○', tone: undefined },
161}
162
163/** The pull-request glyph, stroked in the state's color. */
164export function prIcon(hex: string): string {
165  return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="${hex}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4" cy="3.5" r="1.75"/><circle cx="4" cy="12.5" r="1.75"/><circle cx="12" cy="12.5" r="1.75"/><path d="M4 5.25v5.5M12 10.75V6.5a2 2 0 0 0-2-2H7.5M9 3 7.5 4.5 9 6"/></svg>`
166}
167
hooks/mr/session.ts 32 lines
1import type { MrView, RepoView } from '../../types'
2
3const MERGE = '🔀'
4const MAX_DIRS = 20
5
6export const mrKey = (mr: Pick<MrView, 'repo' | 'id'>) => `${mr.repo}!${mr.id}`
7
8/** Directories a tool call works in: an edited file's folder, a Bash `cd X` or `git -C X`. */
9export function dirsOf(tool: string, input: { file_path?: unknown; notebook_path?: unknown; command?: unknown }, home: string): string[] {
10  const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
11  if (file && tool !== 'Read') return [file.slice(0, file.lastIndexOf('/')) || '/']
12  if (tool !== 'Bash' || typeof input.command !== 'string') return []
13  const found = [...input.command.matchAll(/(?:^|[\s;&|(])(?:cd|git\s+-C)\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)]
14  return found
15    .map(m => (m[1] ?? '').replace(/^(['"])(.*)\1$/, '$2').replace(/^~(?=\/|$)/, home))
16    .filter(d => d.startsWith('/'))
17}
18
19/** Adds dirs, deduped, newest last, capped. */
20export function rememberDirs(known: readonly string[], added: readonly string[]): string[] {
21  const merged = [...known.filter(d => !added.includes(d)), ...added]
22  return merged.slice(-MAX_DIRS)
23}
24
25/** `/mr` output: one markdown line per MR, linked. */
26export function mrList(repos: readonly RepoView[], label: (state: string) => string): string {
27  const lines = repos.flatMap(r => r.mrs.map(mr =>
28    `- ${MERGE} **${r.repo}** [!${mr.id} ${mr.title}](${mr.url}) · ${label(mr.state)} · \`${mr.source}\` → \`${mr.target}\``,
29  ))
30  return lines.length ? lines.join('\n') : '当前会话涉及的 Codeup 仓库分支没有 MR。'
31}
32
hooks/mr/view.tsx 160 lines
1import type { RenderElement } from 'claude-code'
2
3import type { MrView, SessionView } from '../../types'
4import type { Settings } from '../settings'
5import { DISMISS_W, ICON_W, ID_W, STATE_W, TOGGLE_W, capRows, cell, gap } from '../ui'
6import type { Fold, Section, Ui } from '../ui'
7import { CHECK_GLYPH, prIcon, stateOf, summarize, thousands } from './codeup'
8import { mrKey } from './session'
9
10/** Fixed column widths, in cells: every MR row lines up whatever its id, state or numbers. */
11const STATS_W = 16
12const CHECKS_W = 12
13const SUMMARY_MAX = 3
14const ARROW_W = 1
15const MIN_COL_W = 6
16
17/**
18 * The settings with the repo / source / target columns narrowed to what `bodyColumns` leaves after the fixed columns,
19 * in proportion and no narrower than MIN_COL_W; the repo column is dropped when even that does not fit. So the row's
20 * controls (checks, ×) stay on screen in a narrow terminal, and every row still has the same columns.
21 */
22export function mrLayout(settings: Settings, bodyColumns: number): Settings {
23  const fixed = [ICON_W, ID_W, STATE_W, ARROW_W, settings.mrStats ? STATS_W : 0, settings.mrChecks ? CHECKS_W : 0, DISMISS_W].filter(w => w > 0)
24  const spacer = 1
25  const fit = (withRepo: boolean): Settings | null => {
26    const widths = withRepo ? [settings.mrRepoWidth, settings.mrSourceWidth, settings.mrTargetWidth] : [0, settings.mrSourceWidth, settings.mrTargetWidth]
27    const columns = fixed.length + widths.filter(w => w > 0).length + spacer
28    const room = bodyColumns - TOGGLE_W - fixed.reduce((a, b) => a + b, 0) - (columns - 1)
29    const wanted = widths.reduce((a, b) => a + b, 0)
30    const scale = Math.min(1, room / wanted)
31    const [repo = 0, src = 0, tgt = 0] = widths.map(w => (w > 0 ? Math.max(MIN_COL_W, Math.floor(w * scale)) : 0))
32    if (repo + src + tgt > room) return null
33    return { ...settings, mrRepo: withRepo, mrRepoWidth: repo || settings.mrRepoWidth, mrSourceWidth: src, mrTargetWidth: tgt }
34  }
35  return (settings.mrRepo ? fit(true) : null) ?? fit(false) ?? { ...settings, mrRepo: false, mrSourceWidth: MIN_COL_W, mrTargetWidth: MIN_COL_W }
36}
37
38export type MrActions = {
39  toggleChecks: (key: string) => void
40  dismiss: (key: string) => void
41}
42
43export type MrState = { view: SessionView | null; dismissed: readonly string[]; expanded: string | null }
44
45/**
46 * The state-coloured MR glyph. On the terminal it links to the MR like the `!id` beside it; a desktop draws it as an
47 * Svg, which no link may hold, so there the `!id` is the link.
48 */
49const icon = (ui: Ui, mr: MrView): RenderElement => {
50  const s = stateOf(mr.state)
51  if ('Svg' in ui) return <ui.Svg source={prIcon(s.hex)} alt={s.label} width={16} height={16} />
52  return <ui.Link href={mr.url}><ui.Text color={s.tone}>⑂</ui.Text></ui.Link>
53}
54
55function statsCell(ui: Ui, mr: MrView): RenderElement {
56  const { Box, Text } = ui
57  const key = `mr-stats-${mrKey(mr)}`
58  if (mr.additions === undefined || mr.deletions === undefined) return gap(ui, key, STATS_W)
59  return (
60    <Box key={key} width={STATS_W} flexShrink={0} justifyContent="flex-end" columnGap={1}>
61      <Text color="diffAdded" bold>+{thousands(mr.additions)}</Text>
62      <Text color="diffRemoved" bold>-{thousands(mr.deletions)}</Text>
63    </Box>
64  )
65}
66
67function checksCell(ui: Ui, mr: MrView, isOpen: boolean, act: MrActions): RenderElement {
68  const { Box, Button, Text } = ui
69  const key = mrKey(mr)
70  const sum = CHECK_GLYPH[summarize(mr.checks)]
71  return (
72    <Box key={`mr-checks-${key}`} minWidth={CHECKS_W} flexShrink={0}>
73      {mr.checks.length ? (
74        <Button key={`checks-${key}`} onPress={() => act.toggleChecks(key)}>
75          <Text color={sum.tone}>{sum.glyph}</Text> 检查 {isOpen ? '⌃' : '⌄'}
76        </Button>
77      ) : null}
78    </Box>
79  )
80}
81
82function checksList(ui: Ui, mr: MrView): RenderElement {
83  const { Box, Text } = ui
84  const key = mrKey(mr)
85  return (
86    <Box key={`mr-checklist-${key}`} flexDirection="column" paddingLeft={ICON_W + ID_W + 2}>
87      {mr.checks.map(c => {
88        const g = CHECK_GLYPH[c.status]
89        return (
90          <Box key={`check-${key}-${c.label}`} height={1} columnGap={1}>
91            <Text color={g.tone}>{g.glyph}</Text>
92            <Text wrap="truncate-end">{c.label}</Text>
93          </Box>
94        )
95      })}
96    </Box>
97  )
98}
99
100/** One MR: a single fixed-height line of fixed-width columns (long names cut, full on hover), checks unfolded below. */
101function row(ui: Ui, mr: MrView, settings: Settings, isOpen: boolean, act: MrActions): RenderElement {
102  const { Box, Button, Link, Text } = ui
103  const key = mrKey(mr)
104  const s = stateOf(mr.state)
105  return (
106    <Box key={`mr-${key}`} flexDirection="column">
107      <Box height={1} flexWrap="nowrap" overflow="hidden" columnGap={1} alignItems="center">
108        <Box width={ICON_W} flexShrink={0}>{icon(ui, mr)}</Box>
109        <Box width={ID_W} flexShrink={0}><Link href={mr.url} label={`!${mr.id}`} /></Box>
110        {cell(ui, { key: `mr-state-${key}`, text: s.label, width: STATE_W, color: s.tone })}
111        {settings.mrRepo ? cell(ui, { key: `mr-repo-${key}`, text: mr.repo, width: settings.mrRepoWidth, dimColor: true }) : null}
112        {cell(ui, { key: `mr-src-${key}`, text: mr.source, width: settings.mrSourceWidth, mode: 'middle', dimColor: true })}
113        <Text dimColor>→</Text>
114        {cell(ui, { key: `mr-tgt-${key}`, text: mr.target, width: settings.mrTargetWidth, mode: 'middle', dimColor: true })}
115        <Box flexGrow={1} />
116        {settings.mrStats ? statsCell(ui, mr) : null}
117        {settings.mrChecks ? checksCell(ui, mr, isOpen, act) : null}
118        <Button key={`dismiss-${key}`} role="dismiss" plain label="×" dimColor onPress={() => act.dismiss(key)} />
119      </Box>
120      {isOpen && settings.mrChecks ? checksList(ui, mr) : null}
121    </Box>
122  )
123}
124
125function summary(ui: Ui, mrs: readonly MrView[]): RenderElement[] {
126  const { Box, Text } = ui
127  if (!mrs.length) return [<Text key="mr-sum-err" color="warning">⑂ !</Text>]
128  const shown = mrs.slice(0, SUMMARY_MAX).map(mr => {
129    const sum = CHECK_GLYPH[summarize(mr.checks)]
130    return (
131      <Box key={`mr-sum-${mrKey(mr)}`} columnGap={1} flexShrink={0}>
132        <Text color={stateOf(mr.state).tone}>⑂!{mr.id}</Text>
133        {mr.checks.length ? <Text color={sum.tone}>{sum.glyph}</Text> : null}
134      </Box>
135    )
136  })
137  const rest = mrs.length - shown.length
138  return rest > 0 ? [...shown, <Text key="mr-sum-more" dimColor>+{rest}</Text>] : shown
139}
140
141/** The MR rows of the session's repos, dismissed ones left out; null with nothing to show. */
142export function mrSection(ui: Ui, state: MrState, settings: Settings, act: MrActions, fold: Fold): Section | null {
143  const v = state.view
144  if (!v) return null
145  const mrs = v.repos.flatMap(r => r.mrs).filter(mr => !state.dismissed.includes(mrKey(mr)))
146  const errors = v.error ? [v.error] : v.repos.flatMap(r => (r.error ? [`${r.repo}: ${r.error}`] : []))
147  if (!mrs.length && !errors.length) return null
148  const { Box, Text } = ui
149  const rows = mrs.map(mr => row(ui, mr, settings, state.expanded === mrKey(mr), act))
150  const errorRows = errors.map(err => (
151    <Box key={`mr-err-${err}`} height={1} overflow="hidden">
152      <Text dimColor wrap="truncate-end">Codeup MR · {err}</Text>
153    </Box>
154  ))
155  return {
156    rows: [...capRows(ui, 'mr', rows, settings.mrMaxRows, '个 MR', fold), ...errorRows],
157    summary: summary(ui, mrs),
158  }
159}
160
hooks/settings.ts 77 lines
1import type { PluginOptions } from 'claude-code'
2
3export const PLUGIN = 'session-hud'
4
5export const MODULE_IDS = ['usage', 'mr', 'docs', 'deploy'] as const
6export type ModuleId = (typeof MODULE_IDS)[number]
7
8/** Every userConfig field but the token, with its default: the one place defaults live in code. */
9export const DEFAULTS = {
10  usage: true,
11  mr: true,
12  docs: true,
13  deploy: true,
14  collapsed: false,
15  usageReset: true,
16  mrMaxRows: 3,
17  mrRepo: true,
18  mrRepoWidth: 18,
19  mrSourceWidth: 24,
20  mrTargetWidth: 16,
21  mrStats: true,
22  mrChecks: true,
23  mrPollSeconds: 60,
24  docsMaxRows: 3,
25  docsTitleWidth: 40,
26  docsMentioned: true,
27  deployMaxRows: 3,
28  deployPollSeconds: 30,
29} as const
30
31export type SettingKey = keyof typeof DEFAULTS
32export type Settings = { -readonly [K in SettingKey]: (typeof DEFAULTS)[K] extends boolean ? boolean : number } & { token?: string }
33
34/** Inclusive bounds for the numeric fields; a value outside is clamped. */
35const RANGES: Partial<Record<SettingKey, readonly [number, number]>> = {
36  mrMaxRows: [1, 10],
37  mrRepoWidth: [6, 60],
38  mrSourceWidth: [6, 80],
39  mrTargetWidth: [6, 60],
40  mrPollSeconds: [15, 3600],
41  docsMaxRows: [1, 10],
42  docsTitleWidth: [10, 120],
43  deployMaxRows: [1, 10],
44  deployPollSeconds: [10, 600],
45}
46
47const clamp = (n: number, [lo, hi]: readonly [number, number]) => Math.min(hi, Math.max(lo, Math.round(n)))
48
49export const isSettingKey = (key: string): key is SettingKey => Object.hasOwn(DEFAULTS, key)
50
51/** The plugin's options, typed: unknown or mistyped values fall back to the default. */
52export function settingsOf(options: PluginOptions): Settings {
53  const entries = (Object.keys(DEFAULTS) as SettingKey[]).map(key => {
54    const fallback = DEFAULTS[key]
55    const raw = options[key]
56    if (typeof fallback === 'boolean') return [key, typeof raw === 'boolean' ? raw : fallback]
57    const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
58    const range = RANGES[key]
59    return [key, Number.isFinite(n) ? (range ? clamp(n, range) : n) : fallback]
60  })
61  const token = typeof options.token === 'string' && options.token ? options.token : undefined
62  return { ...(Object.fromEntries(entries) as Settings), ...(token ? { token } : {}) }
63}
64
65/** `/hud set <key> <value>` text → the value the field takes, or an error. */
66export function parseSetting(key: SettingKey, text: string): { value: boolean | number } | { error: string } {
67  if (typeof DEFAULTS[key] === 'boolean') {
68    if (/^(on|true|1|yes|开)$/i.test(text)) return { value: true }
69    if (/^(off|false|0|no|关)$/i.test(text)) return { value: false }
70    return { error: `${key} 取值 on / off` }
71  }
72  const n = Number(text)
73  if (!text || !Number.isFinite(n)) return { error: `${key} 取值为数字` }
74  const range = RANGES[key]
75  return { value: range ? clamp(n, range) : n }
76}
77
hooks/tree.ts 14 lines
1import type { RenderElement } from 'claude-code'
2
3type Node = RenderElement | string | number | boolean | null | undefined
4
5// True when a tree would draw nothing visible: no node, blank text, or Box/Text holding only such.
6export const isBlank = (node: Node): boolean => {
7  if (node === null || node === undefined || typeof node === 'boolean') return true
8  if (typeof node === 'string') return node.trim() === ''
9  if (typeof node === 'number') return false
10  if (node.type !== 'Box' && node.type !== 'Text') return false
11  const children = ((node as { children?: Node[] }).children ?? []) as Node[]
12  return children.every(isBlank)
13}
14