Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ jobs:

- name: Build
run: npm run build

- name: Test
run: npm test
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@
- **手动清除条目**:`/subagent-clear-entries` 清除当前会话所有记录,防止历史条目扫描重建
- **语言适配**:支持 `/subagent-lang` 运行时切换中/英文,偏好持久化
- **可切换边框**:`/subagent-border` 控制面板外边框显隐(默认隐藏),开启后与缓存统计面板风格统一
- **斜杠命令**:`/subagent-lang` `/subagent-max` `/subagent-order` `/subagent-scroll` `/subagent-ttl` `/subagent-clear-entries` `/subagent-session` `/subagent-version` `/subagent-clear-running` `/subagent-border` 动态配置
- **多实例安全**:多开 TUI 时条目不再互相覆盖(原子合并写入)
- **漏事件兜底**:周期性校正 + 历史扫描 + 发现轮询,掉事件的子代理也能恢复显示
- **条目显示开关**:`/subagent-cost` 控制列表内条目费用(4 位小数,默认关;展开区始终显示);`/subagent-time`、`/subagent-tokens` 控制耗时与 Token(默认开,保持原有显示)
- **耗时格式**:`/subagent-time-format` 可选 short(默认)/ decimal / clock / compact / seconds
- **设置菜单**:`/subagent-sections` 打开原生循环设置菜单(V2);`/subagent-config` 查看当前设置摘要
- **斜杠命令**:`/subagent-lang` `/subagent-max` `/subagent-order` `/subagent-scroll` `/subagent-ttl` `/subagent-clear-entries` `/subagent-session` `/subagent-version` `/subagent-clear-running` `/subagent-border` `/subagent-config` `/subagent-sections` `/subagent-cost` `/subagent-time` `/subagent-tokens` `/subagent-time-format` 动态配置

---

Expand Down Expand Up @@ -152,6 +157,12 @@ npm install -g opencode-subagent-magazine@latest
| `/subagent-version` | 查看插件版本 | 弹出当前插件版本号 |
| `/subagent-clear-running` | 批量清理僵尸条目 | 一键将所有运行中的条目标记为完成,清理卡住的旧数据 |
| `/subagent-border` | 开关面板边框 | 切换面板外边框显隐(默认隐藏),即时生效并持久化 |
| `/subagent-config` | 查看设置摘要 | 弹窗列出语言、条目数、排序、翻页、保留期、边框与各显示开关的当前值 |
| `/subagent-sections` | 打开设置菜单 | 原生选择列表逐项修改,修改后自动回到菜单(Esc 关闭) |
| `/subagent-cost` | 开关条目费用 | 列表内条目费用显示(4 位小数,默认关;展开区始终显示) |
| `/subagent-time` | 开关条目耗时 | 列表内耗时显示(默认开) |
| `/subagent-tokens` | 开关条目 Token | 列表内 Token 显示(默认开) |
| `/subagent-time-format` | 切换耗时格式 | short(默认 45.32s / 112m35s)/ decimal / clock / compact / seconds |

<div align="center">
<img src="https://raw.githubusercontent.com/Hotakus/opencode-subagent-magazine/master/assets/slash_cmds.png" alt="斜杠命令" width="49%"></img>
Expand Down
13 changes: 12 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ Interested in token cache visualization? Check out [opencode-visual-cache](https
- **Manual Entry Clear**: `/subagent-clear-entries` removes all records for the current session and prevents scan re-creation of historical entries
- **Language Support**: Runtime language switch via `/subagent-lang` (Chinese / English), preference persisted
- **Toggleable Border**: `/subagent-border` controls the panel border (hidden by default); when shown, it matches the cache stats panel style
- **Slash Commands**: `/subagent-lang`, `/subagent-max`, `/subagent-order`, `/subagent-scroll`, `/subagent-ttl`, `/subagent-clear-entries`, `/subagent-session`, `/subagent-version`, `/subagent-clear-running`, `/subagent-border` for dynamic configuration
- **Multi-instance Safe**: sidebar entries no longer overwrite each other when several TUIs are open (atomic merge writes)
- **Missed-event Recovery**: periodic reconcile + history scan + discovery poll recover sub-agents whose events were dropped
- **Entry Display Toggles**: `/subagent-cost` shows per-entry cost in the list (4 decimals, off by default; always shown when expanded); `/subagent-time` and `/subagent-tokens` toggle elapsed time and tokens (on by default)
- **Configurable Time Format**: `/subagent-time-format` supports short (default), decimal, clock, compact, seconds
- **Settings Menu**: `/subagent-sections` opens the native looping settings menu (V2); `/subagent-config` shows a settings summary
- **Slash Commands**: `/subagent-lang`, `/subagent-max`, `/subagent-order`, `/subagent-scroll`, `/subagent-ttl`, `/subagent-clear-entries`, `/subagent-session`, `/subagent-version`, `/subagent-clear-running`, `/subagent-border`, `/subagent-config`, `/subagent-sections`, `/subagent-cost`, `/subagent-time`, `/subagent-tokens`, `/subagent-time-format` for dynamic configuration

---

Expand Down Expand Up @@ -116,6 +121,12 @@ The plugin supports dynamic configuration via slash commands or the command pale
| `/subagent-version` | View plugin version | Displays the current plugin version |
| `/subagent-clear-running` | Batch cleanup stuck entries | Marks all running entries as done in one click, cleaning up stale data |
| `/subagent-border` | Toggle panel border | Show/hide the panel border (hidden by default) |
| `/subagent-config` | Show settings summary | Popup listing current language, max entries, sort, scroll, retention, border and display toggles |
| `/subagent-sections` | Open settings menu | Native select list; each change returns to the menu (Esc to close) |
| `/subagent-cost` | Toggle entry cost | Per-entry cost in the list (4 decimals, off by default; always shown when expanded) |
| `/subagent-time` | Toggle entry time | Elapsed time in the list (on by default) |
| `/subagent-tokens` | Toggle entry tokens | Token count in the list (on by default) |
| `/subagent-time-format` | Set time format | short (default, 45.32s / 112m35s) / decimal / clock / compact / seconds |

<div align="center">
<img src="https://raw.githubusercontent.com/Hotakus/opencode-subagent-magazine/master/assets/slash_cmds.png" alt="Slash Commands" width="49%"></img>
Expand Down
4 changes: 2 additions & 2 deletions build.tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ await esbuild.build({
format: "esm",
platform: "node",
bundle: true,
external: ["@opencode-ai/*", "@opentui/*", "solid-js"],
external: ["@opencode-ai/*", "@opentui/*", "solid-js", "bun:sqlite"],
plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
})

Expand All @@ -30,6 +30,6 @@ await esbuild.build({
format: "esm",
platform: "node",
bundle: true,
external: ["@opencode-ai/*", "@opentui/*", "solid-js"],
external: ["@opencode-ai/*", "@opentui/*", "solid-js", "bun:sqlite"],
plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
})
44 changes: 44 additions & 0 deletions src/core/format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { TimeFormat } from "./types"

/** Visual width of a single character (CJK/wide chars count as 2). */
function charColumns(c: string): number {
const code = c.codePointAt(0) ?? 0
Expand Down Expand Up @@ -47,6 +49,48 @@ export function fmtDurationShort(ms: number, running: boolean): string {
return `${m}m${s}s`
}

/** All supported elapsed-time display modes, in picker order. */
export const TIME_FORMATS: readonly TimeFormat[] = ["short", "decimal", "clock", "compact", "seconds"]

/** Neutral samples shown in the time-format picker (language independent). */
export const TIME_FORMAT_SAMPLES: Record<TimeFormat, string> = {
decimal: "45.3s · 9.5m · 1.9h",
short: "45.32s · 112m35s",
clock: "0:45 · 1:52:35",
compact: "45s · 1h52m",
seconds: "45s · 6755s",
}

/** Formats elapsed milliseconds using the configured display mode. */
export function fmtDuration(ms: number, running: boolean, mode: TimeFormat = "short"): string {
if (running && ms < 2000) return ""
const t = Number.isFinite(ms) && ms > 0 ? ms : 0
switch (mode) {
case "short":
return fmtDurationShort(t, false)
case "compact": {
if (t < 60000) return `${Math.floor(t / 1000)}s`
if (t < 3600000) return `${Math.floor(t / 60000)}m${Math.floor((t % 60000) / 1000)}s`
return `${Math.floor(t / 3600000)}h${Math.floor((t % 3600000) / 60000)}m`
}
case "clock": {
const s = Math.floor((t % 60000) / 1000)
if (t < 3600000) return `${Math.floor(t / 60000)}:${String(s).padStart(2, "0")}`
const m = Math.floor((t % 3600000) / 60000)
return `${Math.floor(t / 3600000)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
case "seconds":
return `${Math.floor(t / 1000)}s`
case "decimal":
default: {
// Promote units when rounding would show "60.0s" / "60.0m".
if (t < 59950) return `${(t / 1000).toFixed(1)}s`
if (t < 3597000) return `${(t / 60000).toFixed(1)}m`
return `${(t / 3600000).toFixed(1)}h`
}
}
}

export function fmtTokens(n: number): string {
if (n < 1000) return `${n}`
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`
Expand Down
44 changes: 42 additions & 2 deletions src/core/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
export interface KVApi {
get(key: string, fallback?: unknown): unknown
set(key: string, value: unknown): void
/**
* Optional atomic read-modify-write. When the host exposes it (V2), the
* updater runs under the host storage lock against the latest on-disk value,
* which is what makes concurrent TUI instances safe: no instance can
* overwrite another one's fresh writes with a stale in-memory snapshot.
*/
update?(key: string, updater: (current: unknown) => unknown): void | Promise<void>
}

/** Setting keys shared by V1/V2 (values are raw strings in KV). */
Expand All @@ -18,6 +25,11 @@ export const SETTING_KEYS = {
open: `${KV_PREFIX}.open`,
ttlDays: `${KV_PREFIX}.ttl_days`,
border: `${KV_PREFIX}.border`,
showEntryCost: `${KV_PREFIX}.show_entry_cost`,
showEntryTime: `${KV_PREFIX}.show_entry_time`,
showEntryTokens: `${KV_PREFIX}.show_entry_tokens`,
timeFormat: `${KV_PREFIX}.time_format`,
dbSync: `${KV_PREFIX}.db_sync`,
} as const

export function loadSessionData(kv: KVApi): Record<string, SessionRecord> {
Expand All @@ -27,8 +39,36 @@ export function loadSessionData(kv: KVApi): Record<string, SessionRecord> {
} catch { return {} }
}

export function saveSessionData(kv: KVApi, data: Record<string, SessionRecord>) {
try { kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
/**
* Atomic read-merge-write of the session store.
*
* `mutator` receives the latest persisted map (read under the host lock when
* `kv.update` is available) and may mutate it freely; the result is written
* back. Every writer must go through here: writing a whole in-memory snapshot
* directly would silently drop entries created by the other TUIs.
*/
export function updateSessionData(
kv: KVApi,
mutator: (data: Record<string, SessionRecord>) => void,
): void | Promise<void> {
try {
if (typeof kv.update === "function") {
return kv.update(SESSION_DATA_KEY, (raw) => {
const data = parseSessionData(raw)
mutator(data)
return JSON.stringify(data)
})
}
// Fallback (V1 hosts without atomic update): read-modify-write. The window
// is small and the mutation is merged against the value we just read.
const data = loadSessionData(kv)
mutator(data)
kv.set(SESSION_DATA_KEY, JSON.stringify(data))
} catch {}
}

function parseSessionData(raw: unknown): Record<string, SessionRecord> {
try { return JSON.parse(String(raw ?? "{}")) } catch { return {} }
}

export function readTTLDays(kv: KVApi): number {
Expand Down
12 changes: 12 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface SubEntry {
export type Lang = LangCode
export type SortOrder = "desc" | "asc"
export type ScrollMode = "wheel" | "click"
/** How elapsed time is rendered in the sidebar (see TIME_FORMAT_SAMPLES). */
export type TimeFormat = "decimal" | "short" | "clock" | "compact" | "seconds"

/** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
export const SUBAGENT_TOOLS = new Set(["task", "subagent", "delegate", "call_omo_agent"])
Expand Down Expand Up @@ -56,5 +58,15 @@ export interface SharedSignals {
setScrollMode: (m: ScrollMode) => void
borderVisible: () => boolean
setBorderVisible: (v: boolean) => void
showEntryCost: () => boolean
setShowEntryCost: (v: boolean) => void
showEntryTime: () => boolean
setShowEntryTime: (v: boolean) => void
showEntryTokens: () => boolean
setShowEntryTokens: (v: boolean) => void
timeFormat: () => TimeFormat
setTimeFormat: (f: TimeFormat) => void
dbSync: () => boolean
setDbSync: (v: boolean) => void
sessionId: string
}
76 changes: 76 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const ZH_T = {
"clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。",
"clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?",
"clear.done": "已清除 {n} 条子代理记录",
"clear.failed": "清除失败",
"clear.empty": "当前会话无子代理记录",
"cancel.no_session": "子会话 ID 不可用",
"cancel.not_child": "目标不是子会话",
Expand All @@ -58,6 +59,24 @@ const ZH_T = {
"cancel.failed": "取消失败",
"borderShown": "面板边框 已显示",
"borderHidden": "面板边框 已隐藏",
"settings.title": "SubAgent Magazine — 设置",
"settings.section.general": "通用",
"settings.lang": "语言",
"settings.max": "最大条目数",
"settings.order": "排序方式",
"settings.scroll": "滚动模式",
"settings.ttl": "保留期限",
"settings.border": "面板边框",
"settings.showEntryCost": "每条子代理显示费用",
"settings.showEntryTime": "列表中显示耗时",
"settings.showEntryTokens": "列表中显示 Token 数",
"settings.timeFormat": "时间格式",
"settings.dbSync": "本地数据库补全",
"settings.on": "开",
"settings.off": "关",
"settings.saved": "已保存 {item}:{value}",
"settings.invalid": "无效的数值",
"settings.close": "关闭",
} as const

/** 结构约束:值放宽为 string,键集合来自中文表(新增语言缺 key 会编译报错)。 */
Expand Down Expand Up @@ -104,6 +123,7 @@ const EN_T: Translation = {
"clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.",
"clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?",
"clear.done": "Cleared {n} sub-agent record(s)",
"clear.failed": "Clear failed",
"clear.empty": "No sub-agent records in this session",
"cancel.no_session": "Child session ID is unavailable",
"cancel.not_child": "Target is not a child session",
Expand All @@ -115,6 +135,24 @@ const EN_T: Translation = {
"cancel.failed": "Cancellation failed",
"borderShown": "Panel border shown",
"borderHidden": "Panel border hidden",
"settings.title": "SubAgent Magazine — Settings",
"settings.section.general": "General",
"settings.lang": "Language",
"settings.max": "Max entries",
"settings.order": "Sort order",
"settings.scroll": "Scroll mode",
"settings.ttl": "Retention",
"settings.border": "Panel border",
"settings.showEntryCost": "Cost per sub-agent",
"settings.showEntryTime": "Elapsed time in list",
"settings.showEntryTokens": "Token count in list",
"settings.timeFormat": "Time format",
"settings.dbSync": "Local database enrichment",
"settings.on": "on",
"settings.off": "off",
"settings.saved": "Saved {item}: {value}",
"settings.invalid": "Invalid value",
"settings.close": "Close",
}

const JA_T: Translation = {
Expand Down Expand Up @@ -158,6 +196,7 @@ const JA_T: Translation = {
"clear.prompt": "このセッションの全サブエージェント記録を削除しますか?この操作は元に戻せません。",
"clear.prompt_running": "実行中のサブエージェントが {n} 個あります。削除すると復元できません。続行しますか?",
"clear.done": "{n} 件のサブエージェント記録を削除しました",
"clear.failed": "クリアに失敗しました",
"clear.empty": "このセッションにサブエージェント記録はありません",
"cancel.no_session": "子セッション ID が利用できません",
"cancel.not_child": "対象は子セッションではありません",
Expand All @@ -169,6 +208,24 @@ const JA_T: Translation = {
"cancel.failed": "キャンセルに失敗しました",
"borderShown": "パネル枠線 表示",
"borderHidden": "パネル枠線 非表示",
"settings.title": "SubAgent Magazine — 設定",
"settings.section.general": "一般",
"settings.lang": "言語",
"settings.max": "最大表示数",
"settings.order": "並び順",
"settings.scroll": "スクロール",
"settings.ttl": "保持期間",
"settings.border": "パネル枠",
"settings.showEntryCost": "サブエージェント毎の費用",
"settings.showEntryTime": "一覧に所要時間を表示",
"settings.showEntryTokens": "一覧にトークン数を表示",
"settings.timeFormat": "時間形式",
"settings.dbSync": "ローカルDB補完",
"settings.on": "オン",
"settings.off": "オフ",
"settings.saved": "保存しました {item}:{value}",
"settings.invalid": "無効な値です",
"settings.close": "閉じる",
}

const KO_T: Translation = {
Expand Down Expand Up @@ -212,6 +269,7 @@ const KO_T: Translation = {
"clear.prompt": "이 세션의 모든 서브 에이전트 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"clear.prompt_running": "실행 중인 서브 에이전트가 {n}개 있습니다. 삭제하면 복구할 수 없습니다. 계속하시겠습니까?",
"clear.done": "서브 에이전트 기록 {n}개를 삭제했습니다",
"clear.failed": "지우기에 실패했습니다",
"clear.empty": "이 세션에 서브 에이전트 기록이 없습니다",
"cancel.no_session": "하위 세션 ID를 사용할 수 없습니다",
"cancel.not_child": "대상이 하위 세션이 아닙니다",
Expand All @@ -223,6 +281,24 @@ const KO_T: Translation = {
"cancel.failed": "취소에 실패했습니다",
"borderShown": "패널 테두리 표시",
"borderHidden": "패널 테두리 숨김",
"settings.title": "SubAgent Magazine — 설정",
"settings.section.general": "일반",
"settings.lang": "언어",
"settings.max": "최대 항목 수",
"settings.order": "정렬 순서",
"settings.scroll": "스크롤 모드",
"settings.ttl": "보존 기간",
"settings.border": "패널 테두리",
"settings.showEntryCost": "하위 에이전트별 비용",
"settings.showEntryTime": "목록에 소요 시간 표시",
"settings.showEntryTokens": "목록에 토큰 수 표시",
"settings.timeFormat": "시간 형식",
"settings.dbSync": "로컬 DB 보완",
"settings.on": "켬",
"settings.off": "끔",
"settings.saved": "저장됨 {item}: {value}",
"settings.invalid": "잘못된 값",
"settings.close": "닫기",
}

export const LANGS: Record<LangCode, Translation> = { zh: ZH_T, en: EN_T, ja: JA_T, ko: KO_T }
Expand Down
Loading
Loading