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
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、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