feat: 階段 1 骨架(插件、角色啟動/離開、persona、/config、狀態列)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 06:07:37 +00:00
co-authored by Claude Sonnet 5.5
parent cda5631e16
commit 7873828bc3
10 changed files with 542 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { expect, mock, test } from 'claude-code/testing'
import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline'
import type { Segment } from './statusline'
const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] })
test('啟動角色後 persona 注入系統提示', async ($, on) => {
mock.store(on)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
const res = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect(res.sections.some(s => s.id === 'jsc-anima:persona' && s.text.includes('亞絲娜'))).toBe(true)
})
test('切換角色與離開', async ($, on) => {
mock.store(on)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
await $.command.run({ command: 'sao-suguha', args: '' } as never)
const compose = () => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect((await compose()).sections.find(s => s.id === 'jsc-anima:persona')?.text).toContain('直葉')
await $.command.run({ command: 'anima-off', args: '' } as never)
expect((await compose()).sections.some(s => s.id === 'jsc-anima:persona')).toBe(false)
})
test('狀態列:窄時依序整欄隱藏,不截斷', () => {
const all = [seg('character', 'AB'), seg('model', 'CD'), seg('dir', 'EF'), seg('branch', 'GH'), seg('5h', 'IJ'), seg('7d', 'KL'), seg('ctx', 'MN'), seg('sys', 'OP')]
expect(layout(all, 999).length).toBe(8)
expect(layout(all, 26).map(s => s.id)).not.toContain('sys')
const narrow = layout(all, 14).map(s => s.id)
expect(narrow).toEqual(['character', 'model', '5h', 'ctx'])
expect([...HIDE_ORDER]).toEqual(['sys', '7d', 'dir', 'branch'])
})
test('量表固定長度,剩餘越少越紅', () => {
expect(bar(0).length).toBe(bar(100).length)
expect(remainColor(10, 60, 85)).toBe('red')
expect(remainColor(90, 60, 85)).toBe('green')
expect(usedColor(70, 60, 85)).toBe('yellow')
})
+69
View File
@@ -0,0 +1,69 @@
export type Axes = {
gentle: number
tsundere: number
lively: number
formal: number
talkative: number
humor: number
proactive: number
expressive: number
}
export type Character = {
slug: string
name: string
work: string
persona: string
axes: Axes
}
// 角色包只含自行撰寫的人設,不含原作台詞。
export const CHARACTERS: readonly Character[] = [
{
slug: 'sao-asuna',
name: '亞絲娜',
work: '刀劍神域',
persona:
'自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。',
axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 },
},
{
slug: 'sao-suguha',
name: '直葉',
work: '刀劍神域',
persona:
'自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。',
axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 },
},
]
export const findCharacter = (slug: string | undefined): Character | undefined =>
CHARACTERS.find(c => c.slug === slug)
const AXIS_LABEL: Record<keyof Axes, string> = {
gentle: '溫柔',
tsundere: '傲嬌',
lively: '活潑',
formal: '正式',
talkative: '話量',
humor: '幽默',
proactive: '主動',
expressive: '情緒外顯',
}
export const applyOverrides = (axes: Axes, over: Partial<Axes>): Axes => {
const out = { ...axes }
for (const k of Object.keys(over) as (keyof Axes)[]) {
const v = over[k]
if (typeof v === 'number' && v >= 0 && v <= 10) out[k] = v
}
return out
}
export const personaSection = (c: Character, axes: Axes): string =>
[
`你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`,
c.persona,
`個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`,
'預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。',
].join('\n')
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
+183
View File
@@ -0,0 +1,183 @@
import type { Engine, Register } from 'claude-code'
import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters'
import type { Axes } from './characters'
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: 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 === 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>
)
})
}
+61
View File
@@ -0,0 +1,61 @@
export type Color = 'green' | 'yellow' | 'red' | 'cyan' | undefined
export type Part = { text: string; color?: Color; dim?: boolean }
export type Segment = { id: string; parts: Part[] }
export const BAR_LEN = 8
export const textWidth = (s: string): number => {
let w = 0
for (const ch of s) {
const c = ch.codePointAt(0)!
w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
}
return w
}
export const bar = (pct: number, style: 'block' | 'dot' = 'block'): string => {
const n = Math.round((Math.max(0, Math.min(100, pct)) / 100) * BAR_LEN)
const [on, off] = style === 'dot' ? ['●', '○'] : ['█', '░']
return on.repeat(n) + off.repeat(BAR_LEN - n)
}
// 使用率:越高越紅
export const usedColor = (pct: number, warn: number, danger: number): Color =>
pct >= danger ? 'red' : pct >= warn ? 'yellow' : 'green'
// 剩餘:量表越少越紅
export const remainColor = (remain: number, warn: number, danger: number): Color =>
usedColor(100 - remain, warn, danger)
export const FIELD_IDS = ['character', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const
// 窄終端機時整欄隱藏的順序
export const HIDE_ORDER = ['sys', '7d', 'dir', 'branch'] as const
export const parseFields = (s: string): string[] => {
const ids = s.split(',').map(x => x.trim()).filter(x => (FIELD_IDS as readonly string[]).includes(x))
return ids.length ? [...new Set(ids)] : [...FIELD_IDS]
}
const SEP = ' │ '
export const segmentWidth = (s: Segment): number => s.parts.reduce((n, p) => n + textWidth(p.text), 0)
// 整欄隱藏,絕不截斷。
export const layout = (segments: Segment[], columns: number): Segment[] => {
const list = [...segments]
const total = () => list.reduce((n, s) => n + segmentWidth(s), 0) + Math.max(0, list.length - 1) * textWidth(SEP)
for (const id of HIDE_ORDER) {
if (total() <= columns) break
const i = list.findIndex(s => s.id === id)
if (i >= 0) list.splice(i, 1)
}
return list
}
export const SEPARATOR = SEP
export const meter = (label: string, pct: number, color: Color, style: 'block' | 'dot', suffix: string): Segment['parts'] => [
{ text: `${label} ` },
{ text: bar(pct, style), color },
{ text: ` ${Math.round(pct)}%${suffix}` },
]