187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
import type { Engine, Register } from 'claude-code'
|
||
|
||
import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters'
|
||
import type { Axes } from './characters'
|
||
import { EXTRACT_COMMAND, runExtract } from './extractCommand'
|
||
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
|
||
import type { Segment } from './statusline'
|
||
|
||
const ACTIVE_KEY = 'active'
|
||
const OFF_COMMAND = 'anima-off'
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
const holder: { sys?: Sys } = {}
|
||
|
||
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
|
||
}
|
||
|
||
on('session.start', async ($, e, next) => {
|
||
if (isEnabled) {
|
||
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: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
|
||
const saved = await $.store.get(ACTIVE_KEY)
|
||
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
|
||
}
|
||
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: '已離開角色,回到一般模式。' }
|
||
}
|
||
const c = findCharacter(e.command)
|
||
if (!c) return next(e)
|
||
active = c.slug
|
||
await $.store.set(ACTIVE_KEY, c.slug)
|
||
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
|
||
const text = personaSection(c, applyOverrides(c.axes, overrides()))
|
||
return {
|
||
...res,
|
||
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
|
||
}
|
||
})
|
||
|
||
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: 'cyan' }],
|
||
model: () => (model ? [{ text: 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>
|
||
)
|
||
})
|
||
}
|