Files
jsc-anima/hooks/register.tsx
T

573 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Engine, Register } from 'claude-code'
import { CHARACTERS, applyOverrides, findCharacter, packToCharacter, personaSection, registerCharacters, themeColor } from './characters'
import type { PackLike } from './characters'
import type { Axes } from './characters'
import { EXTRACT_COMMAND, runExtract } from './extractCommand'
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
import type { Affinity } from './affinity'
import { relationOf, relationText } from './relation'
import { CLASSIFY_PROMPT, choose, parseRoute } from './router'
import type { Effort, ModelName, Route } from './router'
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
import type { Entry } from './wiki'
import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo'
import type { Todo } from './todo'
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const AFFINITY_COMMAND = 'anima-affinity'
const RAW_LOG_KEY = 'rawlog'
const RAW_LOG_MAX = 200
const WIKI_COMMAND = 'anima-wiki'
const SYNCED_KEY = 'wikiSyncedAt'
const AXIS_OPTION: Record<keyof Axes, string> = {
gentle: 'axisGentle',
tsundere: 'axisTsundere',
lively: 'axisLively',
formal: 'axisFormal',
talkative: 'axisTalkative',
humor: 'axisHumor',
proactive: 'axisProactive',
expressive: 'axisExpressive',
}
const SYS_SCRIPT =
'a=$(awk \'{print $1}\' /proc/loadavg); n=$(nproc); ' +
'echo "$a $n"; free | awk \'/Mem:/{printf "%d\\n", $3*100/$2}\'; df / | awk \'NR==2{gsub("%","",$5); print $5}\''
const resetText = (iso: string | undefined, long: boolean): string => {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const p = (n: number) => String(n).padStart(2, '0')
return long ? ` ${d.getMonth() + 1}/${d.getDate()}` : ` ${p(d.getHours())}:${p(d.getMinutes())}`
}
const fmtTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
type Sys = { at: number; cpu: number; ram: number; dsk: number }
async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Promise<Sys | undefined> {
const now = await $.clock.now()
if (holder.sys && now - holder.sys.at < refreshMs) return holder.sys
try {
const r = await $.process.run(['sh', '-c', SYS_SCRIPT], { timeoutMs: 3000 })
const [l1, l2, l3] = String(r.stdout).trim().split('\n')
const [load, cores] = (l1 ?? '').split(' ').map(Number)
holder.sys = {
at: now,
cpu: Math.min(100, Math.round(((load || 0) / (cores || 1)) * 100)),
ram: Number(l2) || 0,
dsk: Number(l3) || 0,
}
} catch {
holder.sys = undefined
}
return holder.sys
}
type WikiCfg = { url: string; cacheDir: string }
type Wiki = { dir: string; url: string; auth: string[] }
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
let url = cfg.url
if (!url) {
const cwd = await $.session.cwd().catch(() => '')
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
}
if (!url) return undefined
const home = (await $.env.get('HOME')) ?? ''
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
}
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r : undefined
}
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
const now = await $.clock.now()
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
if (ok) {
await $.store.set(SYNCED_KEY, now)
return 'fresh'
}
return cloned ? 'cached' : 'none'
}
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
}
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
const name = commandName(e)
if (known.has(name)) return
known.add(name)
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
}
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
if (!known.has(WIKI_COMMAND)) {
known.add(WIKI_COMMAND)
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
}
try {
const w = await wikiCtx($, cfg)
if (!w) return
await sync($, w, false)
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
} catch {}
}
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
const parsed = parseWriteArgs(args)
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
const w = await wikiCtx($, cfg)
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
const { entry, source, body } = parsed
const file = `${w.dir}/${pageName(entry)}.md`
const existed = await $.fs.exists(file).catch(() => false)
const catalog = addEntry(await readCatalog($, w), entry)
await $.fs.write(file, renderPage(entry, body, source))
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
return '寫入失敗:無法建立 commit。'
}
if (!(await git($, w, ['push']))) {
await git($, w, ['reset', '--hard', 'HEAD~1'])
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
}
await $.store.set(SYNCED_KEY, await $.clock.now())
await registerEntry($, known, entry)
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
}
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
const w = await wikiCtx($, cfg)
if (!w) return undefined
await sync($, w, false)
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
if (!e) return undefined
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
}
async function todoRun($: Engine, name: string, persona: string, args: string): Promise<string> {
const todos = (((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []).slice()
const types = ((await $.store.get(TYPES_KEY)) as string[] | undefined) ?? DEFAULT_TYPES
const p = parseTodo(args)
if (!p) return USAGE
const save = () => $.store.set(TODO_KEY, todos)
const find = (id: number) => todos.find(t => t.id === id)
if (p.op === 'list') return renderList(todos)
if (p.op === 'type') {
if (types.includes(p.name)) return `類型「${p.name}」已存在。`
await $.store.set(TYPES_KEY, [...types, p.name])
return `已新增類型「${p.name}」。`
}
if (p.op === 'add' || p.op === 'update') {
if (!types.includes(p.type)) return `沒有「${p.type}」這個類型,現有:${types.join('、')}。可用 type 新增。`
const project = (await $.session.cwd().catch(() => '')) || ''
if (p.op === 'add') {
const t: Todo = { id: nextId(todos), type: p.type, at: p.at, task: p.task, project, status: 'pending' }
todos.push(t)
await save()
return `已建立待辦 #${t.id}。`
}
const t = find(p.id)
if (!t) return `找不到待辦 #${p.id}。`
Object.assign(t, { type: p.type, at: p.at, task: p.task, status: 'pending', result: undefined })
await save()
return `已更新待辦 #${t.id}。`
}
const t = find(p.id)
if (!t) return `找不到待辦 #${p.id}。`
if (t.status === 'done') return `待辦 #${t.id} 已經完成。`
if (p.op === 'done') {
t.status = 'done'
await save()
return `待辦 #${t.id} 已標記完成。`
}
// 使用者輸入 run 即為確認;執行結果由角色告知。
const res = await $.model
.complete({ model: 'sonnet', prompt: `[${t.type}] ${t.task}`, system: `${RUN_SYSTEM}\n角色:${name}。${persona}`, maxTokens: 2000, timeoutMs: 120000 })
.catch(() => undefined)
if (!res || !res.isAnswered || !res.text.trim()) return `待辦 #${t.id} 執行失敗,仍保留為待辦。`
t.status = 'done'
t.result = res.text.trim()
await save()
return withPrefix(name, t.result)
}
async function remindDue($: Engine, name: string): Promise<void> {
const todos = ((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []
const due = dueNow(todos, await $.clock.now())
if (!due.length) return
for (const t of due) {
t.status = 'reminded'
$.ui.log(withPrefix(name, reminderText(t)))
}
await $.store.set(TODO_KEY, todos)
}
const affinityKey = (slug: string): string => `affinity:${slug}`
async function loadAffinity($: Engine, slug: string): Promise<Affinity> {
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
return v && typeof v.score === 'number' ? v : initialAffinity()
}
async function quotaRemain($: Engine): Promise<number | undefined> {
const usage = await $.session.usage().catch(() => undefined)
const used = usage?.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day').map(r => r.percentUsed)
return used && used.length ? 100 - Math.max(...used) : undefined
}
async function pickRoute($: Engine, r: Route | undefined, o: Omit<Parameters<typeof choose>[1], 'quotaRemain'>) {
return choose(r ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }, { ...o, quotaRemain: await quotaRemain($) })
}
// 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。
async function loadPacks($: Engine): Promise<void> {
const dir = `${$.plugin.root}/characters/`
try {
if (!(await $.fs.exists(`${dir}index.json`))) return
const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] }
for (const i of index.packs) {
const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike
const work = (await $.fs.exists(`${dir}${i.work}/work.json`)) ? (JSON.parse(await $.fs.read(`${dir}${i.work}/work.json`)) as { terms?: { term: string; definition: string }[] }) : undefined
registerCharacters([packToCharacter(pack, i.workName, work?.terms ?? [])])
}
} catch {
// 角色包損壞不影響內建角色。
}
}
export const register: Register = (on, options) => {
const opt = options as Record<string, unknown>
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d)
const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d)
const isEnabled = flag('enabled', true)
let active: string | undefined
let toolRegistered = false
let route: Route | undefined
let delegating = false
const jobs = new Map<string, string>()
let lastPick: { model: ModelName; effort: Effort } | undefined
let prefixed = false
const holder: { sys?: Sys } = {}
const wikiCommands = new Set<string>()
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
const routingOn = () => flag('routing', true)
const candidates = (): ModelName[] =>
str('candidates', 'haiku,sonnet,opus').split(',').map(x => x.trim()).filter((x): x is ModelName => x === 'haiku' || x === 'sonnet' || x === 'opus')
const lockOf = <T extends string>(k: string, ok: readonly T[]): T | undefined => {
const v = str(k, 'auto')
return (ok as readonly string[]).includes(v) ? (v as T) : undefined
}
const overrides = (): Partial<Axes> => {
const out: Partial<Axes> = {}
for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) {
const v = num(AXIS_OPTION[k], -1)
if (v >= 0) out[k] = v
}
return out
}
const routeOpts = () => ({
candidates: candidates(),
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
downgradeAt: num('downgradeAt', 15),
})
on('session.start', async ($, e, next) => {
if (isEnabled) {
await loadPacks($)
for (const c of CHARACTERS) {
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
}
await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active && !toolRegistered) {
toolRegistered = true
await registerTools($)
}
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
const ch = findCharacter(active)
if (ch && flag('todoRemind', true)) await remindDue($, ch.name)
}
return next(e)
})
on('command.run', async ($, e, next) => {
if (!isEnabled) return next(e)
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
if (e.command === OFF_COMMAND) {
active = undefined
await $.store.delete(ACTIVE_KEY)
return { text: '已離開角色,回到一般模式。' }
}
if (e.command === TOOL_COMMAND) {
return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE }
}
if (e.command === TODO_COMMAND) {
const c = findCharacter(active)
return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE }
}
if (e.command === AFFINITY_COMMAND) {
const c = findCharacter(active)
if (!c) return { text: '請先啟動角色。' }
const arg = String(e.args ?? '').trim()
const cur = await loadAffinity($, c.slug)
if (arg === 'reset') {
await $.store.set(affinityKey(c.slug), initialAffinity())
return { text: '好感度已重置為 0。' }
}
const n = Number(arg)
if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `目前好感度 ${cur.score}(${stageName(cur.score)})。用法:reset 或 0-100 的數字。` }
await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) })
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
}
const c = findCharacter(e.command)
if (!c) {
if (!wikiCommands.has(e.command)) return next(e)
if (!findCharacter(active)) return { text: NEED_ACTIVE }
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
const page = await readWiki($, wikiCfg(), e.command)
return { text: page ?? `找不到知識「${e.command}」。` }
}
active = c.slug
route = undefined
await $.store.set(ACTIVE_KEY, c.slug)
// 工具二到四只在啟動角色後才註冊。
if (!toolRegistered) {
toolRegistered = true
await registerTools($)
}
await registerWikiTools($, wikiCfg(), wikiCommands)
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
})
on('prompt.compose', async ($, e, next) => {
const res = await next(e)
const c = isEnabled ? findCharacter(active) : undefined
if (!c) return res
let text = personaSection(c, applyOverrides(c.axes, overrides()))
if (flag('affinity', true)) {
const a = await loadAffinity($, c.slug)
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}`
} else {
text += `\n${relationText(relationOf(c.timeline, 0, false))}`
}
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
return {
...res,
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
}
})
on('prompt.submit', async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (c && flag('todoRemind', true)) await remindDue($, c.name)
if (!c || e.command || e.isMeta) return next(e)
prefixed = false
delegating = false
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
if (e.origin?.kind === 'plugin') return next(e)
const axes = applyOverrides(c.axes, overrides())
if (routingOn()) {
const res = await $.model
.complete({ model: 'haiku', prompt: CLASSIFY_PROMPT + e.text, system: `角色:${c.name}。${c.persona}`, effort: 'low', maxTokens: 300, timeoutMs: 8000 })
.catch(() => undefined)
route = parseRoute(res && res.isAnswered ? res.text : '')
if (route.ack) $.ui.log(withPrefix(c.name, route.ack))
if (route.work && flag('background', true)) {
const pick = await pickRoute($, route, routeOpts())
const started = await $.agent
.spawn({ prompt: e.text, description: e.text.slice(0, 30), model: pick.model })
.catch(() => undefined)
if (started && !('deny' in started && started.deny)) {
if (started.agentId) jobs.set(started.agentId, e.text)
delegating = true
lastPick = pick
}
}
} else {
route = undefined
}
if (flag('affinity', true)) {
const today = dayKey(await $.clock.now())
let a = await loadAffinity($, c.slug)
a = applyEvent(a, 'daily', axes, today)
const tone = route?.tone
if (tone === 'praise' || tone === 'scold') a = applyEvent(a, tone, axes, today)
await $.store.set(affinityKey(c.slug), a)
}
return next(e)
})
on('turn.step', async function* ($, e, next) {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !routingOn()) return yield* next(e)
// 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
lastPick = { model: pick.model, effort: pick.effort }
return yield* next({ ...e, model: pick.model, effort: pick.effort })
})
on('turn.complete', async ($, e, next) => {
const res = await next(e)
const task = e.agentId ? jobs.get(e.agentId) : undefined
const c = isEnabled ? findCharacter(active) : undefined
if (e.agentId && task !== undefined) {
jobs.delete(e.agentId)
if (c) {
const body = e.reason === 'answer' ? e.answer : `(工作未完成:${e.reason})`
void $.prompt.submit({ text: `[工作線回報] 使用者的要求:${task}\n處理結果:\n${body}\n請用角色口吻向使用者說明結果,技術內容原樣保留。` }).catch(() => undefined)
}
} else if (!e.agentId) {
delegating = false
}
return res
})
on('session.append', { door: 'response' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e)
const blocks = e.message.content
const out = []
for (const b of blocks) {
if (b.type !== 'text' || typeof b.text !== 'string' || !b.text.trim()) {
out.push(b)
continue
}
const orig = b.text
let text = orig
const { text: masked, tokens } = protect(orig)
const model = needsSonnet(orig, num('longReply', 1200)) ? 'sonnet' : 'haiku'
const res = await $.model
.complete({ model, prompt: masked, system: `${REWRITE_SYSTEM}\n角色:${c.name}。${c.persona}`, maxTokens: 4000, timeoutMs: num('rewriteTimeoutSec', 20) * 1000 })
.catch(() => undefined)
const done = res && res.isAnswered ? restore(res.text.trim(), tokens) : undefined
if (done) {
text = done
if (flag('rawLog', true)) {
const log = ((await $.store.get(RAW_LOG_KEY)) as { at: number; raw: string }[] | undefined) ?? []
log.push({ at: await $.clock.now(), raw: orig })
await $.store.set(RAW_LOG_KEY, log.slice(-RAW_LOG_MAX))
}
}
if (!prefixed) {
text = withPrefix(c.name, text)
prefixed = true
}
out.push({ ...b, text })
}
return next({ ...e, message: { ...e.message, content: out } })
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e)
const warn = num('warnAt', 60)
const danger = num('dangerAt', 85)
const style = str('barStyle', 'block') === 'dot' ? 'dot' : 'block'
const usage = await $.session.usage().catch(() => undefined)
const cwd = await $.session.cwd().catch(() => '')
const branch = await $.process
.run(['git', 'branch', '--show-current'], { cwd: cwd || undefined, timeoutMs: 2000 })
.then(r => String(r.stdout).trim())
.catch(() => '')
const model = await $.session.model().catch(() => '')
const quota = (kind: string, label: string, long: boolean): Segment['parts'] | undefined => {
const rl = usage?.rateLimits.find(r => r.kind === kind)
if (!rl) return undefined
const remain = Math.max(0, 100 - rl.percentUsed)
return meter(label, remain, remainColor(remain, warn, danger), style, resetText(rl.resetsAt, long))
}
const builders: Record<string, () => Promise<Segment['parts'] | undefined> | Segment['parts'] | undefined> = {
character: () => [{ text: c.name, color: themeColor(c) }],
love: async () => {
if (!flag('affinity', true) || !flag('showHearts', true)) return undefined
const a = await loadAffinity($, c.slug)
return [{ text: hearts(a.score), color: 'red' as const }]
},
model: () => (model ? [{ text: lastPick && routingOn() ? `${model} · ${lastPick.effort}` : model }] : undefined),
dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined),
branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined),
'5h': () => quota('five_hour', '5h', false),
'7d': () => quota('seven_day', '7d', true),
ctx: () => {
const cx = usage?.context
if (!cx || cx.percent === undefined) return undefined
return meter('ctx', cx.percent, usedColor(cx.percent, warn, danger), style, ` ${fmtTokens(cx.tokens ?? 0)}/${fmtTokens(cx.window)}`)
},
sys: async () => {
const s = await readSys($, holder, num('refreshSec', 10) * 1000)
if (!s) return undefined
return [
{ text: 'CPU ' }, { text: `${s.cpu}%`, color: usedColor(s.cpu, warn, danger) },
{ text: ' RAM ' }, { text: `${s.ram}%`, color: usedColor(s.ram, warn, danger) },
{ text: ' DSK ' }, { text: `${s.dsk}%`, color: usedColor(s.dsk, warn, danger) },
]
},
}
const segments: Segment[] = []
for (const id of parseFields(str('fields', ''))) {
const parts = await builders[id]?.()
if (parts) segments.push({ id, parts })
}
const shown = layout(segments, e.props.bodyColumns)
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column">
<Text> </Text>
<Text wrap="truncate-end">
{shown.map((s, i) => (
<Text key={s.id}>
{i > 0 ? <Text dimColor>{SEPARATOR}</Text> : null}
{s.parts.map((p, j) => (
<Text key={j} color={p.color}>{p.text}</Text>
))}
</Text>
))}
</Text>
<Text> </Text>
</Box>
)
})
}