opencode-subagent-magazine 1.4.2 → 1.5.1

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/src/index.tsx CHANGED
@@ -20,12 +20,14 @@ import {
20
20
  For,
21
21
  } from "solid-js"
22
22
  import { PLUGIN_VERSION } from "./_version"
23
+ import { copyText } from "./clipboard"
24
+ import { LangCode, LANG_META, createT, detectLang } from "./i18n"
23
25
 
24
26
  // ===================================================================
25
27
  // Types
26
28
  // ===================================================================
27
29
 
28
- type SubStatus = "running" | "done" | "error"
30
+ type SubStatus = "running" | "done" | "error" | "cancel_requested" | "cancelled"
29
31
 
30
32
  interface SubEntry {
31
33
  id: string
@@ -42,108 +44,18 @@ interface SubEntry {
42
44
  model?: string
43
45
  todoTotal?: number
44
46
  todoDone?: number
47
+ cancelRequestedAt?: number
48
+ abortAccepted?: boolean
49
+ cancelReason?: "manual"
45
50
  }
46
51
 
47
- type Lang = "zh" | "en"
52
+ type Lang = LangCode
48
53
  type SortOrder = "desc" | "asc"
49
54
  type ScrollMode = "wheel" | "click"
50
55
 
51
56
  /** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
52
57
  const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
53
58
 
54
- // ===================================================================
55
- // i18n
56
- // ===================================================================
57
-
58
- const I18N: Record<Lang, Record<string, string>> = {
59
- zh: {
60
- "panel.title": "子代理",
61
- "status.none": "暂无子代理",
62
- "agent.label": "代理",
63
- "status.label": "状态",
64
- "time.label": "耗时",
65
- "tokens.label": "上下文",
66
- "error.label": "错误",
67
- "model.label": "模型",
68
- "todo.label": "进度",
69
- "session.label": "会话 ID",
70
- "session.toast.copy": "可手动复制上方 ID",
71
- "open.label": "进入会话",
72
- "cost.label": "费用",
73
- "scroll.more": "更多",
74
- "scroll.top": "回顶",
75
- "scroll.bottom": "回底",
76
- "dismiss.label": "标记完成",
77
- "status.running": "运行中",
78
- "status.done": "已完成",
79
- "status.error": "错误",
80
- "order.desc": "降序(最新在前)",
81
- "order.asc": "升序(最早在前)",
82
- "scroll.wheel": "滚轮翻页",
83
- "scroll.click": "点击翻页",
84
- "ttl.label": "清理周期",
85
- "ttl.3d": "3 天",
86
- "ttl.7d": "7 天",
87
- "ttl.14d": "14 天",
88
- "ttl.30d": "30 天",
89
- "ttl.unlimited": "无期限",
90
- "ttl.toast": "清理周期已设为 {n} 天",
91
- "ttl.toast_unlimited": "清理周期已设为无期限",
92
- "clear.title": "确认清除",
93
- "clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。",
94
- "clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?",
95
- "clear.done": "已清除 {n} 条子代理记录",
96
- "clear.empty": "当前会话无子代理记录",
97
- },
98
- en: {
99
- "panel.title": "SubAgent",
100
- "status.none": "No sub-agents yet",
101
- "agent.label": "agent",
102
- "status.label": "status",
103
- "time.label": "time",
104
- "tokens.label": "tokens",
105
- "error.label": "error",
106
- "model.label": "model",
107
- "todo.label": "todo",
108
- "session.label": "session ID",
109
- "session.toast.copy": "Copy the ID above manually",
110
- "open.label": "Open session",
111
- "cost.label": "cost",
112
- "scroll.more": "more",
113
- "scroll.top": "Top",
114
- "scroll.bottom": "Bottom",
115
- "dismiss.label": "dismiss",
116
- "status.running": "running",
117
- "status.done": "done",
118
- "status.error": "error",
119
- "order.desc": "Desc (newest first)",
120
- "order.asc": "Asc (oldest first)",
121
- "scroll.wheel": "Wheel Scroll",
122
- "scroll.click": "Click Scroll",
123
- "ttl.label": "TTL (Time to Live)",
124
- "ttl.3d": "3 days",
125
- "ttl.7d": "7 days",
126
- "ttl.14d": "14 days",
127
- "ttl.30d": "30 days",
128
- "ttl.unlimited": "Never",
129
- "ttl.toast": "TTL set to {n} days",
130
- "ttl.toast_unlimited": "TTL set to Never",
131
- "clear.title": "Confirm",
132
- "clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.",
133
- "clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?",
134
- "clear.done": "Cleared {n} sub-agent record(s)",
135
- "clear.empty": "No sub-agent records in this session",
136
- },
137
- }
138
-
139
- declare const process: { env: Record<string, string | undefined> } | undefined
140
-
141
- function detectLang(): Lang {
142
- const env = process?.env?.OPENCODE_LANG ?? process?.env?.LANG ?? ""
143
- if (env.startsWith("zh")) return "zh"
144
- return "en"
145
- }
146
-
147
59
  // ===================================================================
148
60
  // Helpers — visual width
149
61
  // ===================================================================
@@ -304,7 +216,7 @@ function SubAgentPanel(props: {
304
216
  scrollMode: () => ScrollMode
305
217
  sessionId: string
306
218
  }): JSX.Element {
307
- const t = (key: string) => I18N[props.lang()][key] ?? key
219
+ const t = createT(() => props.lang())
308
220
 
309
221
  // ── session data (single-key, true deletion on cleanup) ──
310
222
  const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
@@ -449,7 +361,7 @@ function SubAgentPanel(props: {
449
361
  let needsImmediateFlush = false
450
362
  for (const [id, entry] of next) {
451
363
  const prevEntry = prev.get(id)
452
- if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error")) {
364
+ if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error" || entry.status === "cancelled")) {
453
365
  needsImmediateFlush = true
454
366
  break
455
367
  }
@@ -497,6 +409,7 @@ function SubAgentPanel(props: {
497
409
  )
498
410
  const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
499
411
  const [hoveredDismiss, setHoveredDismiss] = createSignal<string | undefined>(undefined)
412
+ const [hoveredCancel, setHoveredCancel] = createSignal<string | undefined>(undefined)
500
413
  const [hoveredTop, setHoveredTop] = createSignal(false)
501
414
  const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false)
502
415
  const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false)
@@ -602,7 +515,7 @@ function SubAgentPanel(props: {
602
515
  const next = new Map(prev)
603
516
  const nowTs = Date.now()
604
517
  const e = partial.status
605
- const ended = e === "done" || e === "error"
518
+ const ended = e === "done" || e === "error" || e === "cancelled"
606
519
  next.set(partial.id, {
607
520
  ...(existing ?? { startedAt: nowTs }),
608
521
  ...partial,
@@ -613,6 +526,108 @@ function SubAgentPanel(props: {
613
526
  })
614
527
  }
615
528
 
529
+ // ── cancel helpers ──
530
+ const isDescendantOf = (childId: string, rootId: string): boolean => {
531
+ const visited = new Set<string>()
532
+ try {
533
+ let current = props.api.state.session.get(childId) as any
534
+ while (current?.parentID) {
535
+ if (visited.has(current.id)) return false
536
+ visited.add(current.id)
537
+ if (current.parentID === rootId) return true
538
+ current = props.api.state.session.get(current.parentID) as any
539
+ }
540
+ } catch {}
541
+ return false
542
+ }
543
+
544
+ const settleOnIdle = (entry: SubEntry): SubStatus => {
545
+ if (entry.status === "cancel_requested" && entry.abortAccepted) return "cancelled"
546
+ return "done"
547
+ }
548
+
549
+ const cancelEntry = async (entry: SubEntry) => {
550
+ const childId = entry.sessionId
551
+ if (!childId) {
552
+ props.api.ui.toast({
553
+ title: entry.title || entry.agent,
554
+ message: t("cancel.label") + ": " + t("cancel.no_session"),
555
+ })
556
+ return
557
+ }
558
+
559
+ try {
560
+ const child = props.api.state.session.get(childId) as any
561
+ if (!child?.parentID) {
562
+ props.api.ui.toast({
563
+ title: entry.title || entry.agent,
564
+ message: t("cancel.label") + ": " + t("cancel.not_child"),
565
+ })
566
+ return
567
+ }
568
+ } catch {
569
+ props.api.ui.toast({
570
+ title: entry.title || entry.agent,
571
+ message: t("cancel.label") + ": " + t("cancel.read_error"),
572
+ })
573
+ return
574
+ }
575
+
576
+ if (!isDescendantOf(childId, props.sessionId)) {
577
+ props.api.ui.toast({
578
+ title: entry.title || entry.agent,
579
+ message: t("cancel.label") + ": " + t("cancel.outside_tree"),
580
+ })
581
+ return
582
+ }
583
+
584
+ try {
585
+ const st = props.api.state.session.status(childId)
586
+ if (st?.type !== "busy") {
587
+ const tokens = readSessionTokens(childId)
588
+ const cost = readSessionCost(childId)
589
+ upsertEntry({
590
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
591
+ status: "done", sessionId: entry.sessionId,
592
+ tokens, cost,
593
+ })
594
+ return
595
+ }
596
+ } catch {
597
+ props.api.ui.toast({
598
+ title: entry.title || entry.agent,
599
+ message: t("cancel.label") + ": " + t("cancel.status_error"),
600
+ })
601
+ return
602
+ }
603
+
604
+ upsertEntry({
605
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
606
+ status: "cancel_requested", sessionId: entry.sessionId,
607
+ cancelRequestedAt: Date.now(), abortAccepted: false, cancelReason: "manual",
608
+ } as any)
609
+
610
+ try {
611
+ await (props.api as any).client.session.abort({ sessionID: childId })
612
+ upsertEntry({
613
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
614
+ status: "cancel_requested", sessionId: entry.sessionId,
615
+ abortAccepted: true,
616
+ } as any)
617
+ props.api.ui.toast({ message: t("cancel.label") + ": " + t("cancel.sent") })
618
+ } catch (err) {
619
+ upsertEntry({
620
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
621
+ status: "error", sessionId: entry.sessionId,
622
+ error: String(err),
623
+ })
624
+ props.api.ui.toast({
625
+ title: entry.title || entry.agent,
626
+ message: t("cancel.label") + ": " + t("cancel.failed"),
627
+ })
628
+ }
629
+ }
630
+
616
631
  // ── event handlers ──
617
632
  const handlePartUpdated = (event: unknown) => {
618
633
  const e = event as Record<string, unknown>
@@ -728,10 +743,11 @@ function SubAgentPanel(props: {
728
743
  targetStatus: SubStatus,
729
744
  nowTs: number,
730
745
  ): boolean => {
731
- // 精确匹配:sessionId 对得上 + 状态为 running
746
+ // 精确匹配:sessionId 对得上 + 状态为 running / cancel_requested
732
747
  for (const [, entry] of entriesMap) {
733
- if (entry.sessionId === targetSid && entry.status === "running") {
734
- entry.status = targetStatus
748
+ if (entry.sessionId === targetSid && (entry.status === "running" || entry.status === "cancel_requested")) {
749
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(entry)
750
+ entry.status = finalStatus
735
751
  entry.endedAt = nowTs
736
752
  entry.tokens = entry.tokens ?? sessionTokens
737
753
  entry.cost = entry.cost ?? sessionCost
@@ -742,13 +758,13 @@ function SubAgentPanel(props: {
742
758
  return true
743
759
  }
744
760
  }
745
- // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running
761
+ // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running / cancel_requested
746
762
  if (sessionAgent) {
747
763
  const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
748
764
  const saNorm = normalize(sessionAgent)
749
765
  let best: { entry: SubEntry; gap: number } | null = null
750
766
  for (const [, entry] of entriesMap) {
751
- if (entry.status !== "running") continue
767
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
752
768
  const eaNorm = normalize(entry.agent)
753
769
  if (!eaNorm || !saNorm) continue
754
770
  if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
@@ -757,14 +773,15 @@ function SubAgentPanel(props: {
757
773
  }
758
774
  if (!best) {
759
775
  for (const [, entry] of entriesMap) {
760
- if (entry.status !== "running") continue
776
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
761
777
  if (entry.sessionId) continue
762
778
  const gap = nowTs - (entry.startedAt || 0)
763
779
  if (!best || gap > best.gap) best = { entry, gap }
764
780
  }
765
781
  }
766
782
  if (best) {
767
- best.entry.status = targetStatus
783
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(best.entry)
784
+ best.entry.status = finalStatus
768
785
  best.entry.endedAt = nowTs
769
786
  best.entry.tokens = best.entry.tokens ?? sessionTokens
770
787
  best.entry.cost = best.entry.cost ?? sessionCost
@@ -784,14 +801,15 @@ function SubAgentPanel(props: {
784
801
  const next = new Map(prev)
785
802
  for (const [id, entry] of next) {
786
803
  if (entry.sessionId !== sid) continue
787
- if (entry.status !== "running" && entry.status !== "done") continue
804
+ if (entry.status !== "running" && entry.status !== "done" && entry.status !== "cancel_requested") continue
788
805
  // Skip parent session idle — subagent entries belong to child sessions only
789
806
  if (sid === props.sessionId) continue
790
807
  // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
791
- const alreadySettled = entry.status !== "running"
808
+ const alreadySettled = entry.status !== "running" && entry.status !== "cancel_requested"
809
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
792
810
  next.set(id, {
793
811
  ...entry,
794
- ...(alreadySettled ? {} : { status, endedAt: Date.now() }),
812
+ ...(alreadySettled ? {} : { status: finalStatus, endedAt: Date.now() }),
795
813
  tokens: entry.tokens ?? sessionTokens,
796
814
  cost: entry.cost ?? sessionCost,
797
815
  model: entry.model ?? sessionModel,
@@ -809,7 +827,7 @@ function SubAgentPanel(props: {
809
827
 
810
828
  // Phase 1: try matching by agent name(agent 名有交集)
811
829
  for (const [id, entry] of next) {
812
- if (entry.status !== "running") continue
830
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
813
831
  const eaNorm = normalize(entry.agent)
814
832
  if (!eaNorm || !saNorm) continue
815
833
  if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
@@ -821,7 +839,7 @@ function SubAgentPanel(props: {
821
839
  // fall back to time proximity for entries that have no sessionId yet
822
840
  if (!best) {
823
841
  for (const [id, entry] of next) {
824
- if (entry.status !== "running") continue
842
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
825
843
  if (entry.sessionId) continue
826
844
  const gap = nowTs - (entry.startedAt || 0)
827
845
  if (!best || gap > best.gap) best = { id, gap }
@@ -830,8 +848,9 @@ function SubAgentPanel(props: {
830
848
 
831
849
  if (best) {
832
850
  const entry = next.get(best.id)!
851
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
833
852
  next.set(best.id, {
834
- ...entry, status, endedAt: nowTs,
853
+ ...entry, status: finalStatus, endedAt: nowTs,
835
854
  tokens: sessionTokens || entry.tokens,
836
855
  cost: sessionCost || entry.cost,
837
856
  sessionId: sid,
@@ -1064,7 +1083,7 @@ function SubAgentPanel(props: {
1064
1083
  }
1065
1084
 
1066
1085
  // Already settled → skip
1067
- if (exists && exists.status !== "running") continue
1086
+ if (exists && exists.status !== "running" && exists.status !== "cancel_requested") continue
1068
1087
  // Running entry with no explicit status improvement from part:
1069
1088
  // try message-level heuristics first, then time-based fallback.
1070
1089
  if (exists && status === "running") {
@@ -1121,14 +1140,17 @@ function SubAgentPanel(props: {
1121
1140
  let changed = false
1122
1141
  const next = new Map(prev)
1123
1142
  for (const [id, entry] of next) {
1124
- if (entry.status !== "running" || !entry.sessionId) continue
1143
+ if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue
1125
1144
  try {
1126
1145
  const st = props.api.state.session.status(entry.sessionId)
1127
1146
  if (!st || st.type !== "idle") continue
1128
1147
  const tokens = readSessionTokens(entry.sessionId)
1129
1148
  const cost = readSessionCost(entry.sessionId)
1149
+ const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
1150
+ ? "cancelled" as SubStatus
1151
+ : "done" as SubStatus
1130
1152
  next.set(id, {
1131
- ...entry, status: "done" as SubStatus, endedAt: Date.now(),
1153
+ ...entry, status: finalStatus, endedAt: Date.now(),
1132
1154
  tokens: tokens ?? entry.tokens,
1133
1155
  cost: cost ?? entry.cost,
1134
1156
  })
@@ -1229,8 +1251,8 @@ function SubAgentPanel(props: {
1229
1251
  }))
1230
1252
  })
1231
1253
 
1232
- const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length)
1233
- const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length)
1254
+ const doneCount = createMemo(() => entryList().filter((e) => e.status === "done" || e.status === "cancelled").length)
1255
+ const runningCount = createMemo(() => entryList().filter((e) => e.status === "running" || e.status === "cancel_requested").length)
1234
1256
  const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
1235
1257
  const anyEntry = () => entryList().length > 0
1236
1258
 
@@ -1421,12 +1443,16 @@ function SubAgentPanel(props: {
1421
1443
  {(entry) => {
1422
1444
  const isExpanded = () => expanded() === entry.id
1423
1445
  const isRunning = entry.status === "running"
1446
+ const isCancelRequested = entry.status === "cancel_requested"
1447
+ const isCancelled = entry.status === "cancelled"
1424
1448
  const isError = entry.status === "error"
1449
+ const isActiveRunning = isRunning || isCancelRequested
1425
1450
  const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
1426
1451
 
1427
1452
  const statusDot = () => "\u25cf"
1428
1453
  const statusColor = () => {
1429
- if (!isRunning) return isError ? pal().error : pal().success
1454
+ if (isCancelled) return pal().muted
1455
+ if (!isActiveRunning) return isError ? pal().error : pal().success
1430
1456
  const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
1431
1457
  const a = rgb(pal().muted), b = rgb(pal().warning)
1432
1458
  if (!a || !b) return pal().warning
@@ -1437,7 +1463,7 @@ function SubAgentPanel(props: {
1437
1463
  }
1438
1464
 
1439
1465
  const timeColor = () =>
1440
- isRunning ? pal().warning : isError ? pal().error : pal().muted
1466
+ isActiveRunning ? pal().warning : isError ? pal().error : pal().muted
1441
1467
 
1442
1468
  // Entry label: collapsed shows title only, expanded shows title only too
1443
1469
  const tokenText = () =>
@@ -1446,7 +1472,7 @@ function SubAgentPanel(props: {
1446
1472
  : ""
1447
1473
  const timeText = () =>
1448
1474
  !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1449
- ? fmtDurationShort(elapsed(), isRunning)
1475
+ ? fmtDurationShort(elapsed(), isActiveRunning)
1450
1476
  : ""
1451
1477
  const suffixW = () => {
1452
1478
  let w = 0
@@ -1499,8 +1525,8 @@ function SubAgentPanel(props: {
1499
1525
  {" "}
1500
1526
  <span style={{ fg: pal().primary }}>{t("status.label")}: </span>
1501
1527
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("status.label")))}</span>
1502
- <span style={{ fg: isRunning ? pal().warning : isError ? pal().error : pal().success }}>
1503
- {isRunning ? t("status.running") : isError ? t("status.error") : t("status.done")}
1528
+ <span style={{ fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }}>
1529
+ {isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done")}
1504
1530
  </span>
1505
1531
  </text>
1506
1532
  <Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
@@ -1509,7 +1535,7 @@ function SubAgentPanel(props: {
1509
1535
  <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1510
1536
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1511
1537
  <span style={{ fg: pal().muted }}>
1512
- {fmtDurationShort(elapsed(), isRunning)}
1538
+ {fmtDurationShort(elapsed(), isActiveRunning)}
1513
1539
  </span>
1514
1540
  </text>
1515
1541
  </Show>
@@ -1560,14 +1586,28 @@ function SubAgentPanel(props: {
1560
1586
  </Show>
1561
1587
  <Show when={entry.sessionId}>
1562
1588
  <text
1563
- onMouseUp={() => {
1564
- if (entry.sessionId) {
1589
+ onMouseUp={async () => {
1590
+ const sessionId = entry.sessionId
1591
+ if (!sessionId) return
1592
+
1593
+ const result = await copyText(sessionId)
1594
+
1595
+ if (result.copied) {
1565
1596
  props.api.ui.toast({
1597
+ variant: "success",
1566
1598
  title: entry.title || entry.agent,
1567
- message: `${entry.sessionId}\n\n${t("session.toast.copy")}`,
1568
- duration: 8000,
1599
+ message: t("session.toast.copied"),
1600
+ duration: 2500,
1569
1601
  })
1602
+ return
1570
1603
  }
1604
+
1605
+ props.api.ui.toast({
1606
+ variant: "warning",
1607
+ title: entry.title || entry.agent,
1608
+ message: `${sessionId}\n\n${t("session.toast.copy_failed")}`,
1609
+ duration: 8000,
1610
+ })
1571
1611
  }}
1572
1612
  >
1573
1613
  {" "}
@@ -1577,19 +1617,19 @@ function SubAgentPanel(props: {
1577
1617
  <span style={{ fg: pal().warning }}> ⎘</span>
1578
1618
  </text>
1579
1619
  </Show>
1580
- {/* 进入会话 + 标记完成:同排左右两端,空间隔离防误触 */}
1620
+ {/* 进入会话 + 取消任务 + 仅清除显示:同排左右两端 */}
1581
1621
  <Show when={entry.sessionId || isRunning}>
1582
1622
  {(() => {
1583
- const dismissLabel = () => `- ${t("dismiss.label")}`
1584
1623
  const openPrefix = () => " \u2192 "
1585
1624
  const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : ""
1586
1625
  const openW = () => entry.sessionId ? visualWidth(openFull()) : 0
1587
- const spacerW = () => Math.max(1, panelWidth() - openW() - visualWidth(dismissLabel()) - 2 /* indent */)
1626
+ const cancelLabel = () => ` ${t("cancel.label")}`
1627
+ const dismissLabel = () => ` ${t("dismiss.label")}`
1628
+ const rightW = (isRunning ? visualWidth(dismissLabel()) : 0) + (isRunning && entry.sessionId ? visualWidth(cancelLabel()) : 0)
1629
+ const spacerW = () => Math.max(1, panelWidth() - openW() - rightW - 2)
1588
1630
  return (
1589
1631
  <box flexDirection="row">
1590
- <Show when={entry.sessionId}
1591
- fallback={<text>{" "}</text>}
1592
- >
1632
+ <Show when={entry.sessionId}>
1593
1633
  <text
1594
1634
  onMouseOver={() => setHoveredOpen(entry.id)}
1595
1635
  onMouseOut={() => setHoveredOpen(undefined)}
@@ -1603,19 +1643,26 @@ function SubAgentPanel(props: {
1603
1643
  <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
1604
1644
  </text>
1605
1645
  </Show>
1646
+ <text style={{ fg: pal().muted }}>{" ".repeat(spacerW())}</text>
1647
+ <Show when={isRunning && entry.sessionId}>
1648
+ <text
1649
+ onMouseOver={() => setHoveredCancel(entry.id)}
1650
+ onMouseOut={() => setHoveredCancel(undefined)}
1651
+ onMouseUp={() => cancelEntry(entry)}
1652
+ >
1653
+ <span style={{ fg: hoveredCancel() === entry.id ? pal().warning : pal().error }}>{cancelLabel()}</span>
1654
+ </text>
1655
+ </Show>
1606
1656
  <Show when={isRunning}>
1607
- <>
1608
- <text style={{ fg: pal().muted }}>{" ".repeat(spacerW())}</text>
1609
- <text
1610
- onMouseOver={() => setHoveredDismiss(entry.id)}
1611
- onMouseOut={() => setHoveredDismiss(undefined)}
1612
- onMouseUp={() => {
1613
- upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" })
1614
- }}
1615
- >
1616
- <span style={{ fg: hoveredDismiss() === entry.id ? pal().warning : pal().muted }}>{dismissLabel()}</span>
1617
- </text>
1618
- </>
1657
+ <text
1658
+ onMouseOver={() => setHoveredDismiss(entry.id)}
1659
+ onMouseOut={() => setHoveredDismiss(undefined)}
1660
+ onMouseUp={() => {
1661
+ upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" })
1662
+ }}
1663
+ >
1664
+ <span style={{ fg: hoveredDismiss() === entry.id ? pal().warning : pal().muted }}>{dismissLabel()}</span>
1665
+ </text>
1619
1666
  </Show>
1620
1667
  </box>
1621
1668
  )
@@ -1732,7 +1779,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1732
1779
  // ── language ──
1733
1780
  const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
1734
1781
  const initialLang: Lang =
1735
- stored === "zh" || stored === "en" ? stored : detectLang()
1782
+ LANG_META.some((m) => m.code === stored) ? (stored as Lang) : detectLang()
1736
1783
  const [lang, setLang] = createSignal<Lang>(initialLang)
1737
1784
  const [maxEntries, setMaxEntries] = createSignal(
1738
1785
  parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
@@ -1759,16 +1806,13 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1759
1806
  dialog?.replace(() => (
1760
1807
  <api.ui.DialogSelect
1761
1808
  title="Language / 语言"
1762
- options={[
1763
- { title: "中文", value: "zh" },
1764
- { title: "English", value: "en" },
1765
- ]}
1809
+ options={LANG_META.map((m) => ({ title: m.label, value: m.code }))}
1766
1810
  onSelect={(opt) => {
1767
1811
  const l = opt.value as Lang
1768
1812
  setLang(l)
1769
1813
  api.kv.set(`${KV_PREFIX}.lang`, l)
1770
1814
  api.ui.toast({
1771
- message: l === "zh" ? "语言: 中文" : "Language: English",
1815
+ message: "Language: " + (LANG_META.find((m) => m.code === l)?.label ?? l),
1772
1816
  })
1773
1817
  dialog?.clear()
1774
1818
  }}
@@ -1782,7 +1826,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1782
1826
  description: "Set sub-agent entry sort order (desc / asc)",
1783
1827
  slash: { name: "subagent-order" },
1784
1828
  onSelect: (dialog) => {
1785
- const t = (k: string) => I18N[lang()][k] ?? k
1829
+ const t = createT(() => lang())
1786
1830
  dialog?.replace(() => (
1787
1831
  <api.ui.DialogSelect
1788
1832
  title="Sort Order / 排序方式"
@@ -1809,7 +1853,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1809
1853
  description: "Set scroll mode (wheel / click)",
1810
1854
  slash: { name: "subagent-scroll" },
1811
1855
  onSelect: (dialog) => {
1812
- const t = (k: string) => I18N[lang()][k] ?? k
1856
+ const t = createT(() => lang())
1813
1857
  dialog?.replace(() => (
1814
1858
  <api.ui.DialogSelect
1815
1859
  title="Scroll Mode / 滚动模式"
@@ -1890,7 +1934,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1890
1934
  }
1891
1935
  let count = 0
1892
1936
  for (const [, entry] of entries) {
1893
- if (entry.status === "running") {
1937
+ if (entry.status === "running" || entry.status === "cancel_requested") {
1894
1938
  entry.status = "done" as SubStatus
1895
1939
  entry.endedAt = Date.now()
1896
1940
  count++
@@ -1937,7 +1981,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1937
1981
  description: "Set session data retention period (days before auto-cleanup)",
1938
1982
  slash: { name: "subagent-ttl" },
1939
1983
  onSelect: (dialog) => {
1940
- const t = (k: string) => I18N[signals.lang()][k] ?? k
1984
+ const t = createT(() => signals.lang())
1941
1985
  const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
1942
1986
  const curDays = Number.isNaN(curRaw) ? 3 : curRaw
1943
1987
  const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d`
@@ -1954,7 +1998,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1954
1998
  onSelect={(opt) => {
1955
1999
  const days = parseInt(opt.value, 10)
1956
2000
  api.kv.set(`${KV_PREFIX}.ttl_days`, String(days))
1957
- const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast").replace("{n}", String(days))
2001
+ const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast", { n: days })
1958
2002
  api.ui.toast({ message: msg })
1959
2003
  dialog?.clear()
1960
2004
  }}
@@ -1968,7 +2012,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1968
2012
  description: "Delete all sub-agent records for the current session (cannot be undone)",
1969
2013
  slash: { name: "subagent-clear-entries" },
1970
2014
  onSelect: (dialog) => {
1971
- const t = (k: string) => I18N[signals.lang()][k] ?? k
2015
+ const t = createT(() => signals.lang())
1972
2016
  const sid = signals.sessionId
1973
2017
  const sessionObj = api.state.session.get(sid)
1974
2018
  const parentID = (sessionObj as any)?.parentID as string | undefined
@@ -1979,7 +2023,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1979
2023
  for (const [, e] of cached) { if (e.status === "running") runningCount++ }
1980
2024
  }
1981
2025
  const msg = runningCount > 0
1982
- ? t("clear.prompt_running").replace("{n}", String(runningCount))
2026
+ ? t("clear.prompt_running", { n: runningCount })
1983
2027
  : t("clear.prompt")
1984
2028
  dialog?.replace(() => (
1985
2029
  <api.ui.DialogConfirm
@@ -2012,7 +2056,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
2012
2056
  api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
2013
2057
  globalEntryCache.delete(sid)
2014
2058
  setClearTick((v) => v + 1)
2015
- const msg = t("clear.done").replace("{n}", String(count))
2059
+ const msg = t("clear.done", { n: count })
2016
2060
  api.ui.toast({ message: msg })
2017
2061
  } catch {}
2018
2062
  dialog?.clear()