feat: 階段 1 骨架(插件、角色啟動/離開、persona、/config、狀態列)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "jsc-anima",
|
||||||
|
"owner": {
|
||||||
|
"name": "jsc"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "jsc-anima",
|
||||||
|
"source": "./",
|
||||||
|
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "jsc 的動漫助理插件"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "modules": ["./register.tsx"] }
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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}` },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user