opencode-visual-cache 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README_EN.md +3 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/i18n.d.ts +3 -3
- package/dist/i18n.js +12 -12
- package/dist/index.js +194 -9
- package/dist/tui.js +407 -286
- package/package.json +6 -3
- package/src/_version.ts +1 -1
- package/src/i18n.ts +12 -12
- package/src/index.tsx +209 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-visual-cache",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -54,8 +54,11 @@
|
|
|
54
54
|
"solid-js": ">=1.9.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@opencode-ai/plugin": "^1.
|
|
58
|
-
"@opencode-ai/sdk": "^1.
|
|
57
|
+
"@opencode-ai/plugin": "^1.18.16",
|
|
58
|
+
"@opencode-ai/sdk": "^1.18.16",
|
|
59
|
+
"@opentui/core": "^0.5.1",
|
|
60
|
+
"@opentui/solid": "^0.5.1",
|
|
61
|
+
"@opentui/keymap": "^0.5.1",
|
|
59
62
|
"esbuild": "^0.25.0",
|
|
60
63
|
"esbuild-plugin-solid": "^0.5.0",
|
|
61
64
|
"tsx": "^4.22.3",
|
package/src/_version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// auto-generated
|
|
2
|
-
export const PLUGIN_VERSION="1.6.
|
|
2
|
+
export const PLUGIN_VERSION="1.6.2";
|
package/src/i18n.ts
CHANGED
|
@@ -27,11 +27,11 @@ const ZH_T = {
|
|
|
27
27
|
distTitle: "估算 Token 分布",
|
|
28
28
|
distSys: "系统提示:",
|
|
29
29
|
distUser: "用户:",
|
|
30
|
-
distAgent: "
|
|
30
|
+
distAgent: "子代理指令:",
|
|
31
31
|
distTool: "Tool 调用:",
|
|
32
32
|
distRes: "Tool 结果:",
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
distReason: "推理:",
|
|
34
|
+
stepsCount: "本回合 {n} 次调用:",
|
|
35
35
|
secDetail: "明细",
|
|
36
36
|
secModel: "模型",
|
|
37
37
|
secSkills: "已加载技能",
|
|
@@ -112,11 +112,11 @@ const EN_T: Translation = {
|
|
|
112
112
|
distTitle: "Estimated Token Dist.",
|
|
113
113
|
distSys: "System:",
|
|
114
114
|
distUser: "User:",
|
|
115
|
-
distAgent: "Agent Instr:",
|
|
115
|
+
distAgent: "Sub-Agent Instr:",
|
|
116
116
|
distTool: "Tool Call:",
|
|
117
117
|
distRes: "Tool Result:",
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
distReason: "Reasoning:",
|
|
119
|
+
stepsCount: "Turn #{n} calls:",
|
|
120
120
|
secDetail: "Detail",
|
|
121
121
|
secModel: "Model",
|
|
122
122
|
secSkills: "Loaded Skills",
|
|
@@ -194,11 +194,11 @@ const JA_T: Translation = {
|
|
|
194
194
|
distTitle: "Token 分布(推定)",
|
|
195
195
|
distSys: "システム:",
|
|
196
196
|
distUser: "ユーザー:",
|
|
197
|
-
distAgent: "
|
|
197
|
+
distAgent: "サブエージェント指示:",
|
|
198
198
|
distTool: "Tool 呼び出し:",
|
|
199
199
|
distRes: "Tool 結果:",
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
distReason: "推論:",
|
|
201
|
+
stepsCount: "今回 {n} 回呼出:",
|
|
202
202
|
secDetail: "明細",
|
|
203
203
|
secModel: "モデル",
|
|
204
204
|
secSkills: "読み込み済みスキル",
|
|
@@ -276,11 +276,11 @@ const KO_T: Translation = {
|
|
|
276
276
|
distTitle: "Token 분포(추정)",
|
|
277
277
|
distSys: "시스템:",
|
|
278
278
|
distUser: "사용자:",
|
|
279
|
-
distAgent: "
|
|
279
|
+
distAgent: "서브 에이전트 지시:",
|
|
280
280
|
distTool: "Tool 호출:",
|
|
281
281
|
distRes: "Tool 결과:",
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
distReason: "추론:",
|
|
283
|
+
stepsCount: "이번 턴 {n}회 호출:",
|
|
284
284
|
secDetail: "상세",
|
|
285
285
|
secModel: "모델",
|
|
286
286
|
secSkills: "로드된 스킬",
|
package/src/index.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
TuiThemeCurrent,
|
|
11
11
|
TuiDialogStack,
|
|
12
12
|
TuiPromptRef,
|
|
13
|
+
SequenceBindingLike,
|
|
13
14
|
} from "@opencode-ai/plugin/tui"
|
|
14
15
|
import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
15
16
|
import type {
|
|
@@ -19,7 +20,7 @@ import type {
|
|
|
19
20
|
FilePart,
|
|
20
21
|
ReasoningPart,
|
|
21
22
|
} from "@opencode-ai/sdk/v2"
|
|
22
|
-
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
|
|
23
|
+
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
|
|
23
24
|
import { PLUGIN_VERSION } from "./_version"
|
|
24
25
|
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
|
|
25
26
|
import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
|
|
@@ -259,13 +260,15 @@ function estimateTokens(text: string): number {
|
|
|
259
260
|
interface TokenDist {
|
|
260
261
|
system: number // UserMessage.system
|
|
261
262
|
user: number // user message text/file parts
|
|
262
|
-
agent: number //
|
|
263
|
+
agent: number // task tool input prompt/description (sub-agent delegation)
|
|
263
264
|
toolCall: number // ToolPart.input (actual tool params)
|
|
264
265
|
toolResult: number // ToolPart completed output / error
|
|
265
|
-
output: number // AssistantMessage.tokens.output (
|
|
266
|
+
output: number // AssistantMessage.tokens.output (API exact, reasoning excluded)
|
|
267
|
+
reasoning: number // AssistantMessage.tokens.reasoning (API exact)
|
|
266
268
|
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
267
269
|
apiInput: number // API exact total input context (input + cache read + cache write)
|
|
268
|
-
stepCost: number
|
|
270
|
+
stepCost: number // last step-finish part cost (USD) in the current round
|
|
271
|
+
stepCount: number // step-finish parts count across the current round (parentID chain)
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
// ---------------------------------------------------------------------------
|
|
@@ -402,6 +405,9 @@ interface PanelSignals {
|
|
|
402
405
|
/** When set, the panel renders stats for this session instead of the main one. */
|
|
403
406
|
overrideSessionId: () => string | undefined
|
|
404
407
|
setOverrideSessionId: (v: string | undefined) => void
|
|
408
|
+
/** True while our sidebar panel is mounted — host sidebar is visible (occupies 42 cols). */
|
|
409
|
+
sidebarVisible: () => boolean
|
|
410
|
+
setSidebarVisible: (v: boolean) => void
|
|
405
411
|
}
|
|
406
412
|
|
|
407
413
|
const CURRENCIES: Record<string, string> = {
|
|
@@ -439,6 +445,12 @@ function TokenCachePanel(props: {
|
|
|
439
445
|
const [skillsOpen, setSkillsOpen] = createSignal(true)
|
|
440
446
|
let boxEl: any
|
|
441
447
|
|
|
448
|
+
// 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
|
|
449
|
+
createEffect(() => {
|
|
450
|
+
props.signals.setSidebarVisible(true)
|
|
451
|
+
onCleanup(() => props.signals.setSidebarVisible(false))
|
|
452
|
+
})
|
|
453
|
+
|
|
442
454
|
// ── shared signals (de-structured so internal code is unchanged) ──
|
|
443
455
|
const {
|
|
444
456
|
currencySymbol, setCurrencySymbol,
|
|
@@ -472,7 +484,7 @@ function TokenCachePanel(props: {
|
|
|
472
484
|
// stable until the next successful computation arrives.
|
|
473
485
|
const [lastDist, setLastDist] = createSignal<TokenDist>({
|
|
474
486
|
system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
|
|
475
|
-
output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
|
|
487
|
+
output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
|
|
476
488
|
})
|
|
477
489
|
const [lastHasDist, setLastHasDist] = createSignal(false)
|
|
478
490
|
|
|
@@ -481,7 +493,7 @@ function TokenCachePanel(props: {
|
|
|
481
493
|
cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
|
|
482
494
|
hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
|
|
483
495
|
providerName: "", sessionHitRate: 0,
|
|
484
|
-
dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
|
|
496
|
+
dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
|
|
485
497
|
hasDistData: false,
|
|
486
498
|
skills: [] as { name: string; tokens: number }[],
|
|
487
499
|
hasSkills: false,
|
|
@@ -599,7 +611,7 @@ function TokenCachePanel(props: {
|
|
|
599
611
|
|
|
600
612
|
// untrack 只包裹已知触发死锁的 API
|
|
601
613
|
const distData = untrack(() => {
|
|
602
|
-
let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
|
|
614
|
+
let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 }
|
|
603
615
|
let hasDistData = false
|
|
604
616
|
const loadedSkills = new Map<string, { name: string; tokens: number }>()
|
|
605
617
|
try {
|
|
@@ -621,12 +633,20 @@ function TokenCachePanel(props: {
|
|
|
621
633
|
} else if (msg.role === "assistant") {
|
|
622
634
|
const am = msg as AssistantMessage
|
|
623
635
|
dist.output += num(am.tokens?.output)
|
|
636
|
+
dist.reasoning += num(am.tokens?.reasoning)
|
|
624
637
|
let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
|
|
625
638
|
for (const p of parts) {
|
|
626
639
|
if (p.type === "tool") {
|
|
627
640
|
const tp = p as any; let rawInput = ""
|
|
628
641
|
try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
|
|
629
642
|
if (rawInput) dist.toolCall += estimateTokens(rawInput)
|
|
643
|
+
// 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
|
|
644
|
+
if (tp.tool === "task" && tp.state?.input) {
|
|
645
|
+
const ti = tp.state.input
|
|
646
|
+
const prompt = typeof ti.prompt === "string" ? ti.prompt : ""
|
|
647
|
+
const desc = typeof ti.description === "string" ? ti.description : ""
|
|
648
|
+
dist.agent += estimateTokens(prompt || desc)
|
|
649
|
+
}
|
|
630
650
|
if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
|
|
631
651
|
else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
|
|
632
652
|
if (tp.tool === "skill" && tp.state.status === "completed") {
|
|
@@ -647,8 +667,7 @@ function TokenCachePanel(props: {
|
|
|
647
667
|
}
|
|
648
668
|
}
|
|
649
669
|
}
|
|
650
|
-
} else if (p.type === "
|
|
651
|
-
else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
|
|
670
|
+
} else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
|
|
652
671
|
}
|
|
653
672
|
}
|
|
654
673
|
}
|
|
@@ -661,7 +680,27 @@ function TokenCachePanel(props: {
|
|
|
661
680
|
// 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
|
|
662
681
|
dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
|
|
663
682
|
dist.apiOutput = num(lastAssMsg?.tokens?.output)
|
|
664
|
-
|
|
683
|
+
// 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
|
|
684
|
+
// opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
|
|
685
|
+
// 故按 parentID 链聚合统计,而非单条消息。
|
|
686
|
+
if (lastAssMsg) {
|
|
687
|
+
const roundParent = (lastAssMsg as AssistantMessage).parentID
|
|
688
|
+
let lastCost: number | undefined
|
|
689
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
690
|
+
const m = msgs[i]
|
|
691
|
+
if (m.role !== "assistant") continue
|
|
692
|
+
if ((m as AssistantMessage).parentID !== roundParent) break
|
|
693
|
+
let parts: readonly Part[] = []; try { parts = props.api.state.part(m.id) } catch {}
|
|
694
|
+
for (const p of parts) {
|
|
695
|
+
if (p.type !== "step-finish") continue
|
|
696
|
+
dist.stepCount++
|
|
697
|
+
const sc = (p as { cost?: unknown }).cost
|
|
698
|
+
if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc)) lastCost = sc
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (lastCost !== undefined) dist.stepCost = lastCost
|
|
702
|
+
}
|
|
703
|
+
hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0
|
|
665
704
|
} catch {}
|
|
666
705
|
const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
|
|
667
706
|
const skills = [...loadedSkills.values()]
|
|
@@ -976,6 +1015,12 @@ function TokenCachePanel(props: {
|
|
|
976
1015
|
<text fg={pal().muted}>
|
|
977
1016
|
{justify(t("out"), fmt(data().output), t("tok"))}
|
|
978
1017
|
</text>
|
|
1018
|
+
{/* 本回合多次 API 调用时才显示调用次数与末次成本(单次调用不占行) */}
|
|
1019
|
+
<Show when={data().dist.stepCount >= 2}>
|
|
1020
|
+
<text fg={pal().muted}>
|
|
1021
|
+
{justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate()))}
|
|
1022
|
+
</text>
|
|
1023
|
+
</Show>
|
|
979
1024
|
<Show when={data().saved > 0}>
|
|
980
1025
|
<text>
|
|
981
1026
|
<span style={{ fg: pal().muted }}>{t("saved")}</span>
|
|
@@ -1058,9 +1103,11 @@ function TokenCachePanel(props: {
|
|
|
1058
1103
|
{justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
|
|
1059
1104
|
</text>
|
|
1060
1105
|
</Show>
|
|
1061
|
-
<
|
|
1062
|
-
{
|
|
1063
|
-
|
|
1106
|
+
<Show when={data().dist.reasoning > 0}>
|
|
1107
|
+
<text fg={pal().muted}>
|
|
1108
|
+
{justify(t("distReason"), fmt(data().dist.reasoning), t("tok"))}
|
|
1109
|
+
</text>
|
|
1110
|
+
</Show>
|
|
1064
1111
|
</Show>
|
|
1065
1112
|
</Show>
|
|
1066
1113
|
</Show>
|
|
@@ -1141,6 +1188,33 @@ function TokenCachePanel(props: {
|
|
|
1141
1188
|
// Plugin entry
|
|
1142
1189
|
// ---------------------------------------------------------------------------
|
|
1143
1190
|
|
|
1191
|
+
/**
|
|
1192
|
+
* 路径截断的固定开销(列数):仅宿主布局常量,不含任何内容宽度——
|
|
1193
|
+
* 统计宽度、宿主 usage、commands 快捷键均为运行时动态计算。
|
|
1194
|
+
* - marginLeft=1 + space-between 余量 ≈ 2 列
|
|
1195
|
+
*/
|
|
1196
|
+
const PATH_CHROME = 2
|
|
1197
|
+
/**
|
|
1198
|
+
* 路径可用宽度低于此列数时整体隐藏(极窄终端下路径信息价值太低,
|
|
1199
|
+
* 残留的 "E:\Work…" 反而挤压右侧统计与 commands,直接让位更干净)。
|
|
1200
|
+
*/
|
|
1201
|
+
const HIDE_PATH_BELOW = 14
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* 从宿主 keymap 动态读取命令的快捷键显示文本(与宿主 Prompt 同源),
|
|
1205
|
+
* 取不到时回退到传入的默认文本。
|
|
1206
|
+
*/
|
|
1207
|
+
function keyShortcut(api: TuiPluginApi, command: string, fallback: string): string {
|
|
1208
|
+
try {
|
|
1209
|
+
const binds = api.tuiConfig.keybinds.get(command)
|
|
1210
|
+
const seq = binds?.map((b) => ({ key: b.key }))
|
|
1211
|
+
const s = api.keys.formatBindings(seq as unknown as SequenceBindingLike[])
|
|
1212
|
+
return s || fallback
|
|
1213
|
+
} catch {
|
|
1214
|
+
return fallback
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1144
1218
|
/**
|
|
1145
1219
|
* 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
|
|
1146
1220
|
* 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
|
|
@@ -1264,6 +1338,116 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess
|
|
|
1264
1338
|
try { return props.api.state.path.directory } catch { return "" }
|
|
1265
1339
|
})
|
|
1266
1340
|
|
|
1341
|
+
// 终端宽度信号:初始读取渲染器,窗口 resize 时更新(宿主不约束 hint 行宽度,
|
|
1342
|
+
// 路径截断必须基于终端宽度手动计算)。
|
|
1343
|
+
// CliRenderer 继承的 EventEmitter 因项目未装 @types/node 类型不可见,
|
|
1344
|
+
// 用最小接口声明补齐 resize 事件的 on/off。
|
|
1345
|
+
interface ResizeEmitter {
|
|
1346
|
+
on(event: "resize", cb: () => void): unknown
|
|
1347
|
+
off(event: "resize", cb: () => void): unknown
|
|
1348
|
+
}
|
|
1349
|
+
const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth)
|
|
1350
|
+
// resize 事件(主通道);事件接口若在插件环境不可用则跳过,由轮询兜底
|
|
1351
|
+
createEffect(() => {
|
|
1352
|
+
const r = props.api.renderer as unknown as ResizeEmitter
|
|
1353
|
+
if (typeof r.on !== "function" || typeof r.off !== "function") return
|
|
1354
|
+
const onResize = () => setTermW(props.api.renderer.terminalWidth)
|
|
1355
|
+
r.on("resize", onResize)
|
|
1356
|
+
onCleanup(() => r.off("resize", onResize))
|
|
1357
|
+
})
|
|
1358
|
+
// 轮询兜底:事件通道若在插件环境不可用,定期同步终端宽度(值不变时不触发更新)
|
|
1359
|
+
createEffect(() => {
|
|
1360
|
+
const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500)
|
|
1361
|
+
onCleanup(() => clearInterval(timer))
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
// 统计部分分段(单一数据源):量宽拼接 text,渲染逐段着色,避免双源漂移
|
|
1365
|
+
const statsSegs = createMemo<{ text: string; color: string | undefined }[]>(() => {
|
|
1366
|
+
const s = stats()
|
|
1367
|
+
const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--"
|
|
1368
|
+
const segs: { text: string; color: string | undefined }[] = [
|
|
1369
|
+
{ text: t("barHit") + " ", color: pal().muted },
|
|
1370
|
+
{ text: hr, color: hitColor() },
|
|
1371
|
+
]
|
|
1372
|
+
const tr = trend()
|
|
1373
|
+
if (tr !== null) {
|
|
1374
|
+
segs.push({ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%", color: tr > 0 ? pal().success : pal().error })
|
|
1375
|
+
}
|
|
1376
|
+
segs.push({ text: " \u00b7 " + t("barTok") + " ", color: pal().muted })
|
|
1377
|
+
segs.push({ text: s ? fmtCompact(s.input + s.read + s.write) : "--", color: pal().text })
|
|
1378
|
+
if (!props.signals.balanceUnsupported()) {
|
|
1379
|
+
segs.push({ text: " \u00b7 " + t("barBal") + " ", color: pal().muted })
|
|
1380
|
+
segs.push({ text: balanceText(), color: pal().text })
|
|
1381
|
+
}
|
|
1382
|
+
segs.push({ text: " \u00b7 ", color: pal().muted })
|
|
1383
|
+
return segs
|
|
1384
|
+
})
|
|
1385
|
+
const statsW = createMemo(() => {
|
|
1386
|
+
let w = 0
|
|
1387
|
+
for (const sg of statsSegs()) w += visualWidth(sg.text)
|
|
1388
|
+
return w
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
// 宿主右侧 usage 文本复刻(1.18.16 Prompt 口径):
|
|
1392
|
+
// 最后一条 output>0 的 assistant 消息 → tokens 合计格式化 + 模型 context limit 百分比 + session 累计费用
|
|
1393
|
+
const sessionCost = createMemo(() => {
|
|
1394
|
+
try { return num(props.api.state.session.get(sid)?.cost) } catch { return 0 }
|
|
1395
|
+
})
|
|
1396
|
+
const usageText = createMemo(() => {
|
|
1397
|
+
const id = sid
|
|
1398
|
+
if (!id) return ""
|
|
1399
|
+
const msgs = props.api.state.session.messages(id) as Message[]
|
|
1400
|
+
let last: AssistantMessage | undefined
|
|
1401
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
1402
|
+
const m = msgs[i]
|
|
1403
|
+
if (m.role !== "assistant") continue
|
|
1404
|
+
const tk = (m as AssistantMessage).tokens
|
|
1405
|
+
if (tk && num(tk.output) > 0) { last = m as AssistantMessage; break }
|
|
1406
|
+
}
|
|
1407
|
+
if (!last) return ""
|
|
1408
|
+
const tk = last.tokens
|
|
1409
|
+
if (!tk) return ""
|
|
1410
|
+
const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write)
|
|
1411
|
+
if (tokens <= 0) return ""
|
|
1412
|
+
let pct = ""
|
|
1413
|
+
try {
|
|
1414
|
+
const p = props.api.state.provider.find((x) => x.id === last.providerID)
|
|
1415
|
+
const limit = p?.models?.[last.modelID]?.limit?.context
|
|
1416
|
+
if (typeof limit === "number" && limit > 0) pct = ` (${Math.round((tokens / limit) * 100)}%)`
|
|
1417
|
+
} catch {}
|
|
1418
|
+
const context = fmtCompact(tokens) + pct
|
|
1419
|
+
const cost = sessionCost()
|
|
1420
|
+
return cost > 0 ? context + " \u00b7 " + fmtCost(cost) : context
|
|
1421
|
+
})
|
|
1422
|
+
|
|
1423
|
+
// 宿主 1513 行右侧文本:usage(有数据)或 "快捷键 agents"(无数据)+ commands,
|
|
1424
|
+
// 快捷键从宿主 keymap 动态读取
|
|
1425
|
+
const rightText = createMemo(() => {
|
|
1426
|
+
const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands"
|
|
1427
|
+
const u = usageText()
|
|
1428
|
+
if (u) return u + " " + cmds
|
|
1429
|
+
return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds
|
|
1430
|
+
})
|
|
1431
|
+
const rightW = createMemo(() => visualWidth(rightText()))
|
|
1432
|
+
|
|
1433
|
+
// 输入框实际宽度 = 终端宽度 - 侧边栏(可见时 42) - 边距 4
|
|
1434
|
+
// (与宿主 session 布局 contentWidth 口径一致;侧边栏可见性由本面板挂载状态驱动)
|
|
1435
|
+
const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4)
|
|
1436
|
+
|
|
1437
|
+
// 路径可用宽度 = 输入框宽度 - 统计宽度(精确) - 宿主右侧宽度(动态) - 布局开销;
|
|
1438
|
+
// 低于 HIDE_PATH_BELOW 时整体隐藏路径(宽度归零),把空间让给统计与 commands
|
|
1439
|
+
const dirDisplay = createMemo(() => {
|
|
1440
|
+
const avail = inputW() - statsW() - rightW() - PATH_CHROME
|
|
1441
|
+
if (avail < HIDE_PATH_BELOW) return ""
|
|
1442
|
+
return truncateVisual(directory(), avail)
|
|
1443
|
+
})
|
|
1444
|
+
// 状态栏关闭(仅路径)时同样在极窄条件下隐藏路径
|
|
1445
|
+
const dirFallback = createMemo(() => {
|
|
1446
|
+
const avail = inputW() - rightW() - PATH_CHROME
|
|
1447
|
+
if (avail < HIDE_PATH_BELOW) return ""
|
|
1448
|
+
return truncateVisual(directory(), avail)
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1267
1451
|
// 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
|
|
1268
1452
|
onMount(() => {
|
|
1269
1453
|
try {
|
|
@@ -1273,28 +1457,18 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess
|
|
|
1273
1457
|
})
|
|
1274
1458
|
|
|
1275
1459
|
return (
|
|
1276
|
-
<Show
|
|
1460
|
+
<Show
|
|
1461
|
+
when={props.signals.sectionBottom()}
|
|
1462
|
+
fallback={<text fg={pal().muted}>{dirFallback()}</text>}
|
|
1463
|
+
>
|
|
1277
1464
|
<box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
|
|
1278
|
-
<text fg={pal().muted}>{
|
|
1279
|
-
|
|
1465
|
+
<text fg={pal().muted}>{dirDisplay()}</text>
|
|
1466
|
+
<box flexDirection="row">
|
|
1280
1467
|
<text>
|
|
1281
|
-
<
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
<span style={{ fg: trend()! > 0 ? pal().success : pal().error }}>
|
|
1285
|
-
{" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"}
|
|
1286
|
-
</span>
|
|
1287
|
-
</Show>
|
|
1288
|
-
<span style={{ fg: pal().muted }}>{" \u00b7 " + t("barTok") + " "}</span>
|
|
1289
|
-
<span style={{ fg: pal().text }}>
|
|
1290
|
-
{stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"}
|
|
1291
|
-
</span>
|
|
1292
|
-
<Show when={!props.signals.balanceUnsupported()}>
|
|
1293
|
-
<span style={{ fg: pal().muted }}>{" \u00b7 " + t("barBal") + " "}</span>
|
|
1294
|
-
<span style={{ fg: pal().text }}>{balanceText()}</span>
|
|
1295
|
-
</Show>
|
|
1468
|
+
<For each={statsSegs()}>
|
|
1469
|
+
{(sg) => <span style={{ fg: sg.color }}>{sg.text}</span>}
|
|
1470
|
+
</For>
|
|
1296
1471
|
</text>
|
|
1297
|
-
<text fg={pal().muted}>{" \u00b7 "}</text>
|
|
1298
1472
|
</box>
|
|
1299
1473
|
</box>
|
|
1300
1474
|
</Show>
|
|
@@ -1346,6 +1520,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1346
1520
|
const [borderVisible, setBorderVisible] = createSignal(true)
|
|
1347
1521
|
const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
|
|
1348
1522
|
const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
|
|
1523
|
+
// 侧边栏可见性(由 TokenCachePanel 挂载状态驱动):可见时宿主输入框宽度 = 终端宽 - 42 - 4
|
|
1524
|
+
const [sidebarVisible, setSidebarVisible] = createSignal(false)
|
|
1349
1525
|
|
|
1350
1526
|
// ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
|
|
1351
1527
|
// 避免重复请求导致两处余额不一致 ──
|
|
@@ -1373,6 +1549,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
|
1373
1549
|
balanceCurrency, setBalanceCurrency,
|
|
1374
1550
|
borderVisible, setBorderVisible,
|
|
1375
1551
|
overrideSessionId, setOverrideSessionId,
|
|
1552
|
+
sidebarVisible, setSidebarVisible,
|
|
1376
1553
|
}
|
|
1377
1554
|
|
|
1378
1555
|
api.slots.register(createSidebarSlot(api, signals))
|