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
+14
View File
@@ -0,0 +1,14 @@
{
"name": "jsc-anima",
"owner": {
"name": "jsc"
},
"plugins": [
{
"name": "jsc-anima",
"source": "./",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列"
}
],
"description": "jsc 的動漫助理插件"
}
+111
View File
@@ -0,0 +1,111 @@
{
"name": "jsc-anima",
"version": "0.1.0",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT",
"userConfig": {
"enabled": {
"type": "boolean",
"title": "啟用助理",
"description": "關閉後停止所有 persona 注入與狀態列",
"default": true
},
"persist": {
"type": "boolean",
"title": "跨 session 保留啟動",
"description": "下次開啟時自動恢復上次的角色",
"default": true
},
"statusbar": {
"type": "boolean",
"title": "顯示狀態列",
"description": "輸入框上方的單行狀態列",
"default": true
},
"fields": {
"type": "string",
"title": "狀態列欄位與順序",
"description": "逗號分隔:character,model,dir,branch,5h,7d,ctx,sys",
"default": "character,model,dir,branch,5h,7d,ctx,sys"
},
"barStyle": {
"type": "string",
"title": "量表樣式",
"description": "block 或 dot",
"options": [
"block",
"dot"
],
"default": "block"
},
"warnAt": {
"type": "number",
"title": "黃色門檻 (%)",
"description": "使用率達此值轉黃",
"default": 60
},
"dangerAt": {
"type": "number",
"title": "紅色門檻 (%)",
"description": "使用率達此值轉紅",
"default": 85
},
"refreshSec": {
"type": "number",
"title": "系統資源更新秒數",
"description": "CPU/RAM/DSK 的取樣間隔",
"default": 10
},
"axisGentle": {
"type": "number",
"title": "溫柔 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisTsundere": {
"type": "number",
"title": "傲嬌 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisLively": {
"type": "number",
"title": "活潑 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisFormal": {
"type": "number",
"title": "正式 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisTalkative": {
"type": "number",
"title": "話量 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisHumor": {
"type": "number",
"title": "幽默 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisProactive": {
"type": "number",
"title": "主動 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisExpressive": {
"type": "number",
"title": "情緒外顯 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
}
},
"author": {
"name": "jsc"
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 jsc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33
View File
@@ -1 +1,34 @@
<p align="center"><img src="icon.png" width="96" alt="jsc-anima"></p>
# jsc-anima # jsc-anima
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
## 安裝
```
/plugin install jsc-anima --marketplace jiantw83/jsc-anima
```
或用 CLI:
```
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima
claude plugin install jsc-anima
```
## 更新
```
claude plugin update jsc-anima
```
## 移除
```
claude plugin uninstall jsc-anima
```
## 授權
[MIT](LICENSE)
+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}` },
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB