Merge pull request 'refactor: 拆分 cockpit 為可個別安裝的 statusbar 與 companion' (#12) from refactor/split-statusbar-companion into main

Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-10-12 05:57:49 +00:00
50 changed files with 162 additions and 133 deletions
+8 -3
View File
@@ -6,9 +6,14 @@
},
"plugins": [
{
"name": "cockpit",
"source": "./plugins/cockpit",
"description": "Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟"
"name": "statusbar",
"source": "./plugins/statusbar",
"description": "輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟"
},
{
"name": "companion",
"source": "./plugins/companion",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色"
}
]
}
+26 -13
View File
@@ -1,31 +1,44 @@
# jsc-marketplace
JSC 自用 Claude Code mods。
JSC 自用 Claude Code mods。每個 mod 皆可單獨安裝、更新與移除。
| Mod | 說明 |
| --- | --- |
| [cockpit](plugins/cockpit) | Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 |
| [statusbar](plugins/statusbar) | 輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 |
| [companion](plugins/companion) | 小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色 |
## 安裝
本機資料夾:
```bash
claude plugin marketplace add /root/jsc-marketplace
claude plugin install cockpit@jsc-marketplace
```
或透過 Gitea 遠端:
先加入 marketplace,再依需要安裝個別 mod:
```bash
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.git
claude plugin install cockpit@jsc-marketplace
claude plugin install statusbar@jsc-marketplace
claude plugin install companion@jsc-marketplace
```
## 指令
- `/cockpit hud on | off | compact`:切換狀態列
- `/hud on | off | compact`:上述指令的別名
- `/statusbar on | off | compact`:切換狀態列
- `/companion status | switch <角色> | off | setup`:角色陪伴控制
## 更新
```bash
claude plugin marketplace update jsc-marketplace
claude plugin update statusbar@jsc-marketplace
claude plugin update companion@jsc-marketplace
```
更新後需重新啟動 Claude Code 才會生效。
## 移除
```bash
claude plugin uninstall statusbar@jsc-marketplace
claude plugin uninstall companion@jsc-marketplace
claude plugin marketplace remove jsc-marketplace
```
## 授權
@@ -1,10 +0,0 @@
{
"name": "cockpit",
"version": "0.1.0",
"description": "Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
"license": "MIT",
"types": "./types/index.d.ts",
"author": {
"name": "JSC"
}
}
@@ -1,4 +0,0 @@
import type { Register } from 'claude-code'
// companion 模組:目前為空殼,之後的階段再補內容
export const register: Register = () => {}
-9
View File
@@ -1,9 +0,0 @@
import type { Register } from 'claude-code'
import { register as companion } from './companion'
import { register as hud } from './hud'
export const register: Register = on => {
hud(on)
companion(on)
}
@@ -0,0 +1,9 @@
{
"name": "companion",
"version": "0.1.0",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT",
"author": {
"name": "JSC"
}
}
@@ -1,19 +1,8 @@
import { describe, expect, test } from 'claude-code/testing'
import { bandLines, fit, lineWidth } from '../hud/format'
import type { HudData } from '../hud/format'
import { fitCompanion } from './band'
import type { View } from './core'
const DATA: HudData = {
model: 'claude-opus-5-5[1m]',
cwd: '/home/dev/work/luopan',
home: '/home/dev',
git: { branch: 'issue-3', dirty: 1, ahead: 0, behind: 0 },
usage: { fiveHour: { percent: 42 }, sevenDay: { percent: 71 }, ctxPercent: 31 },
sys: { cpu: 23 },
tzOffsetMin: 480,
}
import { lineWidth } from './seg'
const VIEW: View = {
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
@@ -56,19 +45,3 @@ describe('角色行', () => {
expect(text({ ...VIEW, todos: 0 }, 200)).not.toContain('☐')
})
})
describe('band', () => {
test('沒有角色時只有第一行,且與現況逐字相同', () => {
for (const cols of [200, 100, 60]) {
const lines = bandLines(DATA, cols, false, null)
expect(lines.length).toBe(1)
expect(lines[0]).toEqual(fit(DATA, cols, false))
}
})
test('有角色時第一行不變,第二行是角色行', () => {
const lines = bandLines(DATA, 120, false, fitCompanion(VIEW, 120))
expect(lines.length).toBe(2)
expect(lines[0]).toEqual(fit(DATA, 120, false))
})
})
@@ -1,5 +1,5 @@
import { bar, lineWidth } from '../hud/format'
import type { Seg } from '../hud/format'
import { bar, lineWidth } from './seg'
import type { Seg } from './seg'
import type { View } from './core'
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
@@ -140,7 +140,7 @@ describe('關心:靜音與不打斷', () => {
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start'])
})
test('/cockpit quiet 在期間內靜音,到期後恢復', async () => {
test('/companion quiet 在期間內靜音,到期後恢復', async () => {
const s = setup()
await s.care.quiet(30)
await s.care.onUserTurn()
@@ -291,7 +291,7 @@ export interface CareEngine {
onToolResult(ok: boolean): Promise<void>
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
tick(signals?: Signals): Promise<Care[]>
/** /cockpit quiet [分鐘],0 取消靜音 */
/** /companion quiet [分鐘],0 取消靜音 */
quiet(minutes: number): Promise<void>
state(): Promise<CareState>
}
@@ -23,7 +23,7 @@ const MIKO: CompanionCore = {
}
const H = 3_600_000
const DAY = 24 * H
const ROOT = '/h/.cockpit'
const ROOT = '/h/.companion'
function setup(builtins = [MIKO]) {
const fs = memoryFs()
@@ -39,10 +39,10 @@ describe('啟用', () => {
expect((await c.ensureActivated()).first).toBe(false)
})
test('companion off 後沒有角色,且重新啟動不會自動再啟用', async () => {
test('off 後沒有角色,且重新啟動不會自動再啟用', async () => {
const { c } = setup()
await c.ensureActivated()
await c.command('companion off')
await c.command('off')
expect(await c.view()).toBe(null)
await c.ensureActivated()
expect(await c.view()).toBe(null)
@@ -138,7 +138,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
else if (k === 'quietEnd') u.quiet.end = val
else (u as Record<string, unknown>)[k] = val
}
if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/cockpit setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM`
if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/companion setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM`
if (args.length) await writeJson(fs, userPath, u)
return [
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
@@ -151,14 +151,14 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function command(raw: string): Promise<string> {
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
if (sub === 'status') return status()
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/cockpit switch <角色>。可用:${(await available()).join('、')}`
if (sub === 'companion' && rest[0] === 'off') {
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/companion switch <角色>。可用:${(await available()).join('、')}`
if (sub === 'off') {
const g = await getGlobal()
await writeJson(fs, globalPath, { ...g, active: null, activatedOnce: true })
return '助理已關閉,所有資料保留。'
}
if (sub === 'setup') return setup(rest)
return '用法:/cockpit status | switch <角色> | companion off | setup [key=value…]'
return '用法:/companion status | switch <角色> | off | setup [key=value…]'
}
return { ensureActivated, view, moodEvent, affinityEvent, command, getUser }
@@ -24,7 +24,7 @@ type State = { mood: Pad; moodAt: number; affinity: aff.Affinity }
export type Options = {
fs: Fs
clock: Clock
/** 資料根目錄,例如 ~/cockpit */
/** 資料根目錄,例如 ~/.companion */
root: string
/** 當前角色資料夾名稱 */
id: string
@@ -160,7 +160,7 @@ export function createCompanion({ fs, clock, root, id }: Options) {
await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on }))
},
/** `/cockpit tone on|off` */
/** `/companion tone on|off` */
async command(line: string): Promise<string | null> {
const m = /^tone\s+(on|off)$/.exec(line.trim())
if (!m) return null
+17
View File
@@ -0,0 +1,17 @@
// 角色行用的字串片段與寬度計算(與 statusbar 外掛各自獨立,不互相依賴)
export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
export function bar(p: number, cells = 5): string {
const n = Math.max(0, Math.min(cells, Math.round((p / 100) * cells)))
return '▰'.repeat(n) + '▱'.repeat(cells - n)
}
const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/
export function width(s: string): number {
let w = 0
for (const ch of s) w += WIDE.test(ch) ? 2 : 1
return w
}
export const lineWidth = (segs: Seg[]) => segs.reduce((w, s) => w + width(s.text), 0)
+49
View File
@@ -0,0 +1,49 @@
import type { Register } from 'claude-code'
import { fitCompanion } from './companion/band'
import { createCompanion } from './companion/core'
import { nodeFs } from './companion/node-fs'
let companion: ReturnType<typeof createCompanion> | undefined
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'companion',
description: '角色陪伴控制:狀態、切換角色、關閉、使用者設定',
argumentHint: 'status | switch <角色> | off | setup',
})
const home = await $.env.get('HOME')
companion = createCompanion({ fs: nodeFs, clock: { now: Date.now }, root: `${home ?? ''}/.companion` })
const first = await companion.ensureActivated()
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /companion off 可關閉`)
return next(e)
})
on('command.run', { command: 'companion' }, async ($, e) => ({
text: companion ? await companion.command(e.args) : '尚未就緒。',
}))
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const rest = await next(e)
const view = await companion?.view()
if (e.props.hasSurvey || !view) return rest
const line = fitCompanion(view, e.props.bodyColumns)
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column">
{rest}
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
</Box>
)
})
}
@@ -1,6 +1,6 @@
---
name: ingest
description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 cockpit 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。
description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 companion 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。
---
# 小說章節解析
@@ -23,5 +23,5 @@ description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,
## 注意
- 模型從對白推得的個性可信度偏低,只當先驗。
- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 cockpit 資料夾。
- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 `~/.companion` 資料夾。
- 範例完整流程見 `docs/ingest-walkthrough.md`。
@@ -0,0 +1,10 @@
{
"name": "statusbar",
"version": "0.1.0",
"description": "輸入框上方的單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
"license": "MIT",
"types": "./types/index.d.ts",
"author": {
"name": "JSC"
}
}
@@ -232,9 +232,3 @@ export function parseDf(out: string): number | undefined {
const m = row && /(\d+)%/.exec(row)
return m ? Number(m[1]) : undefined
}
// band 的所有行:第一行恆為 HUD;有角色時才多第二行角色行
export function bandLines(d: HudData, columns: number, compact: boolean, companion: Seg[] | null): Seg[][] {
const first = fit(d, columns, compact)
return companion ? [first, companion] : [first]
}
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
@@ -1,19 +1,16 @@
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
import type { Mode, Sys, Usage } from '../../types'
import { fitCompanion } from '../companion/band'
import { createCompanion } from '../companion/core'
import { nodeFs } from '../companion/node-fs'
import { bandLines, cpuPercent, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format'
import type { Mode, Sys, Usage } from '../types'
import { fit, cpuPercent, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format'
import type { CpuSample } from './format'
const usageAtom = atom({ plugin: 'cockpit', key: 'usage' } as const, {})
const gitAtom = atom({ plugin: 'cockpit', key: 'git' } as const, null)
const sysAtom = atom({ plugin: 'cockpit', key: 'sys' } as const, {})
const modeAtom = atom({ plugin: 'cockpit', key: 'mode' } as const, 'auto')
const alertedAtom = atom({ plugin: 'cockpit', key: 'alerted' } as const, [])
const effortAtom = atom({ plugin: 'cockpit', key: 'effort' } as const, null)
const usageAtom = atom({ plugin: 'statusbar', key: 'usage' } as const, {})
const gitAtom = atom({ plugin: 'statusbar', key: 'git' } as const, null)
const sysAtom = atom({ plugin: 'statusbar', key: 'sys' } as const, {})
const modeAtom = atom({ plugin: 'statusbar', key: 'mode' } as const, 'auto')
const alertedAtom = atom({ plugin: 'statusbar', key: 'alerted' } as const, [])
const effortAtom = atom({ plugin: 'statusbar', key: 'effort' } as const, null)
const SYS_MS = 3000
const GIT_MS = 15_000
@@ -37,7 +34,6 @@ function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[]):
}
let home: string | undefined
let companion: ReturnType<typeof createCompanion> | undefined
let tzOffsetMin = 0
let prevCpu: CpuSample | null = null
let ticks = 0
@@ -98,20 +94,11 @@ async function runHud($: EngineInterface, args: string) {
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'cockpit',
description: 'Cockpit 控制:助理(status、switch、companion off、setup)與 hud 狀態列',
argumentHint: 'status | switch <角色> | companion off | setup | hud on | off | compact',
})
// 別名:/hud <arg> 等同 /cockpit hud <arg>
await $.command.register({
name: 'hud',
description: '切換輸入框上方的狀態列(/cockpit hud 的別名)',
name: 'statusbar',
description: '切換輸入框上方的狀態列',
argumentHint: 'on | off | compact',
})
home = await $.env.get('HOME')
companion = createCompanion({ fs: nodeFs, clock: { now: Date.now }, root: `${home ?? ''}/.cockpit` })
const first = await companion.ensureActivated()
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /cockpit companion off 可關閉`)
try {
const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
tzOffsetMin = parseOffset(r.stdout)
@@ -150,18 +137,12 @@ export const register: Register = on => {
return ran
})
on('command.run', { command: 'hud' }, ($, e) => runHud($, e.args))
on('command.run', { command: 'cockpit' }, async ($, e, next) => {
const [sub, ...rest] = e.args.trim().split(/\s+/)
if (sub === 'hud') return runHud($, rest.join(' '))
return { text: companion ? await companion.command(e.args) : '尚未就緒。' }
})
on('command.run', { command: 'statusbar' }, ($, e) => runHud($, e.args))
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const mode = await read($, modeAtom)
if (e.props.hasSurvey || mode === 'off') return next(e)
const rest = await next(e)
if (e.props.hasSurvey || mode === 'off') return rest
const data = {
model: await $.session.model(),
@@ -173,23 +154,20 @@ export const register: Register = on => {
tzOffsetMin,
effort: await read($, effortAtom),
}
const view = await companion?.view()
const cols = e.props.bodyColumns
const lines = bandLines(data, cols, mode === 'compact', view ? fitCompanion(view, cols, mode === 'compact') : null)
const line = fit(data, e.props.bodyColumns, mode === 'compact')
const { Box, Text } = $.ui.resolve(e)
// 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同
return (
<Box marginTop={1} flexDirection="column">
{lines.map(line => (
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
))}
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
{rest}
</Box>
)
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.claude-plugin/types/tsconfig.json"
}
@@ -16,7 +16,7 @@ export type Mode = 'auto' | 'compact' | 'off'
declare module 'claude-code' {
interface PluginState {
cockpit: {
statusbar: {
usage: Usage
git: Git | null
sys: Sys