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

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,把当前会话相关的四类信息放在一处:
| 模块 | 显示什么 |
|---|---|
用量 usage | 5h / 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 ◐
/plugin),提示添加 marketplace 时输入 y,范围选 user: /plugin install session-hud --marketplace jasoncai0/claude-session-hud
usage-hud / codeup-mr,先卸载,否则会显示两遍(云效 token 读取方式不变,无需重新配置): claude plugin uninstall usage-hud@claude-usage-hud
claude plugin uninstall codeup-mr@claude-codeup-mr
/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 即可关掉某个模块;关掉的模块不注册任何工作,不轮询,也不调接口。/hud set 修改。usage 用量5h / 7d(经网关时还有 额度),6 格进度条,< 70% 绿、70–90% 黄、≥ 90% 红↻1h41m 为距离重置的倒计时;重置时间已过(会话空闲后读数过期)的窗口自动隐藏session.measure 推送,不轮询、不额外消耗 tokenmr Codeup 合并请求涉及哪些仓库:当前工作目录所在仓库,加上会话中编辑过文件、或通过 cd <dir> / git -C <dir> 操作过的仓库(绝对路径或 ~/),最多 5 个。只识别 origin 指向 codeup.aliyun.com 的仓库(ssh / https 均可)。
显示哪些 MR:以当前分支为源分支的 MR。有未关闭的就显示未关闭的(同一分支合往多个目标分支时各一行,最多 3 条);全部已关闭时显示最近一条已合并 / 已关闭的。
| 列 | 说明 |
|---|---|
⑂ | MR 图标,颜色随状态;终端里也可点击跳转 |
!128 | MR 编号,点击打开 MR |
| 状态 | 草稿 / 评审中 / 待合并 / 已合并 / 已关闭 |
| 仓库 | group/repo,可用 mrRepo 关闭 |
| 源 → 目标 | 分支名,超长时中间截断,悬停看全 |
+422 -10 | MR 的增删行数(同 git diff --shortstat target...source),分支有新提交时才重新计算 |
检查 ⌄ | 展开 / 收起检查项,按钮上的符号取最差一项:✕ > ○ > ✓ |
× | 在本会话中隐藏这条 MR |
检查项(点「检查」时会立即从 Codeup 重新拉取):
| 检查 | 未关闭的 MR | 已合并的 MR | 已关闭的 MR |
|---|---|---|---|
| 合入 | ○ 待合入 N 个提交 | ✓ 已合入 目标分支 | ✕ 未合入(MR 已关闭) |
| 同步 | ○ 落后 目标分支 N 个提交 / ✓ 已同步 | — | — |
| 源分支 | ✓ 源分支存在 / ✕ 源分支已删除 | ○ 源分支未删除 / ✓ 源分支已删除 | ○ 源分支未删除 / ✓ 源分支已删除 |
| 合并规则 | Codeup 的合并要求,如「合并冲突检查」 | 同左 | 同左 |
| 冲突 | ✕ 存在冲突 | — | — |
| 评审 | 评审 1/2:全部通过为 ✓,有人拒绝为 ✕,否则 ○ | 同左 | 同左 |
| 评论 | ○ 未解决评论 N | 同左 | 同左 |
刷新时机:会话开始、每轮对话结束、每 mrPollSeconds 秒,以及点「检查」或执行 /mr 时。
docs 飞书文档创建的文档(绿色「创建」)来自:
lark-cli … +create / +import / +copy / +duplicate(可带全局参数,如 lark-cli --as user docs +create)。取输出里新出现的文档链接,失败的调用不计。lark / feishu 且含 create / import / copy 的 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
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 / deploy | on | 模块开关 |
collapsed | off | 新会话默认折叠 |
usageReset | on | 显示重置倒计时 |
mrMaxRows | 3 | MR 最多行数,超出时可原地展开(1–10) |
mrRepo | on | 显示仓库列 |
mrRepoWidth | 18 | 仓库列宽,单位为字符格(6–60) |
mrSourceWidth | 24 | 源分支列宽(6–80) |
mrTargetWidth | 16 | 目标分支列宽(6–60) |
mrStats | on | 显示增删行数 |
mrChecks | on | 显示「检查」按钮 |
mrPollSeconds | 60 | MR 轮询间隔,单位为秒(15–3600) |
docsMaxRows | 3 | 文档最多行数,超出时可原地展开(1–10) |
docsTitleWidth | 40 | 文档标题列宽(10–120) |
docsMentioned | on | 显示提及的文档;off 时只显示创建的 |
deployMaxRows | 3 | 发布最多行数,超出时可原地展开(1–10) |
deployPollSeconds | 30 | 未结束发布的轮询间隔,单位为秒(10–600) |
token | — | 云效个人访问令牌(敏感字段,存于系统安全存储);只能在 /plugin configure 或 /config 中设置 |
mr / deploy 模块需要云效个人访问令牌(mr 需要 Codeup 读权限,deploy 需要 AppStack 读权限),按以下顺序查找:tokenALIBABA_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 中的组织 IDdocs 模块显示链接无需额外依赖;显示标题需要已登录的 lark-cli,会在 PATH、/opt/homebrew/bin、/usr/local/bin 中查找openapi-rdc.aliyuncs.com 上的只读接口。Codeup 部分包括仓库、MR 列表、MR 详情、对比、分支;AppStack 部分包括阶段列表、阶段执行列表、流水线运行。插件不会触发、取消或推进任何发布。token 只放在请求头里。lark-cli 完成,使用你自己的登录态。| 现象 | 原因与处理 |
|---|---|
| 看不到 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 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。
发布流程:
.claude-plugin/plugin.json 中提升 versionmain/deploys <链接>/deploysusage-hud 与 codeup-mr,新增飞书文档模块、定宽单行布局、模块独立启停、细粒度配置与折叠hooks/register.tsx 498 lines1import { 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}
498hooks/deploy/parse.ts 140 lines1import 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}
140hooks/deploy/view.tsx 107 lines1import 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}
107hooks/docs/links.ts 73 lines1import 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}
73hooks/docs/lookup.ts 44 lines1/** 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}
44hooks/docs/view.tsx 79 lines1import 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}
79hooks/hud.tsx 75 lines1import 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}
75hooks/mr/codeup.ts 167 lines1import 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}
167hooks/mr/session.ts 32 lines1import 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}
32hooks/mr/view.tsx 160 lines1import 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}
160hooks/settings.ts 77 lines1import 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}
77hooks/tree.ts 14 lines1import 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