opencode-subagent-magazine 1.4.2 → 1.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-subagent-magazine",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "OpenCode TUI plugin monitoring sub-agent invocation status in real time",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
@@ -54,6 +54,7 @@
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.14.50",
56
56
  "@opencode-ai/sdk": "^1.14.50",
57
+ "@types/node": "^22.0.0",
57
58
  "esbuild": "^0.25.0",
58
59
  "esbuild-plugin-solid": "^0.5.0",
59
60
  "tsx": "^4.22.3",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.4.2";
2
+ export const PLUGIN_VERSION="1.5.0";
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process"
2
+ import { platform, release } from "node:os"
3
+
4
+ export type ClipboardMethod =
5
+ | "pbcopy"
6
+ | "osascript"
7
+ | "wl-copy"
8
+ | "xclip"
9
+ | "xsel"
10
+ | "powershell"
11
+ | "osc52"
12
+ | "none"
13
+
14
+ export interface CopyTextResult {
15
+ copied: boolean
16
+ method: ClipboardMethod
17
+ error?: string
18
+ }
19
+
20
+ function runWithInput(command: string, args: string[], input: string): Promise<void> {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(command, args, {
23
+ stdio: ["pipe", "ignore", "ignore"],
24
+ windowsHide: true,
25
+ })
26
+
27
+ child.once("error", reject)
28
+ child.once("close", (code) => {
29
+ if (code === 0) resolve()
30
+ else reject(new Error(`${command} exited with code ${code}`))
31
+ })
32
+
33
+ child.stdin?.end(input)
34
+ })
35
+ }
36
+
37
+ function writeOsc52(text: string): boolean {
38
+ if (!process.stdout.isTTY) return false
39
+
40
+ const payload = Buffer.from(text, "utf8").toString("base64")
41
+ const sequence = `\x1b]52;c;${payload}\x07`
42
+ const wrapped = process.env.TMUX || process.env.STY
43
+ ? `\x1bPtmux;\x1b${sequence}\x1b\\`
44
+ : sequence
45
+
46
+ process.stdout.write(wrapped)
47
+ return true
48
+ }
49
+
50
+ async function tryCommand(
51
+ method: ClipboardMethod,
52
+ command: string,
53
+ args: string[],
54
+ text: string,
55
+ ): Promise<CopyTextResult | undefined> {
56
+ try {
57
+ await runWithInput(command, args, text)
58
+ return { copied: true, method }
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ export async function copyText(text: string): Promise<CopyTextResult> {
65
+ if (!text) return { copied: false, method: "none", error: "empty_text" }
66
+
67
+ const os = platform()
68
+ const isWsl = release().toLowerCase().includes("microsoft")
69
+
70
+ const attempts: Array<() => Promise<CopyTextResult | undefined>> = []
71
+
72
+ if (os === "darwin") {
73
+ attempts.push(() => tryCommand("pbcopy", "pbcopy", [], text))
74
+ attempts.push(() =>
75
+ tryCommand(
76
+ "osascript",
77
+ "osascript",
78
+ ["-e", "set the clipboard to (read (POSIX file \"/dev/stdin\") as text)"],
79
+ text,
80
+ ),
81
+ )
82
+ }
83
+
84
+ if (os === "linux" && isWsl) {
85
+ attempts.push(() =>
86
+ tryCommand(
87
+ "powershell",
88
+ "powershell.exe",
89
+ [
90
+ "-NonInteractive",
91
+ "-NoProfile",
92
+ "-Command",
93
+ "[Console]::InputEncoding=[Text.Encoding]::UTF8; Set-Clipboard ([Console]::In.ReadToEnd())",
94
+ ],
95
+ text,
96
+ ),
97
+ )
98
+ } else if (os === "linux") {
99
+ if (process.env.WAYLAND_DISPLAY) {
100
+ attempts.push(() => tryCommand("wl-copy", "wl-copy", [], text))
101
+ }
102
+ attempts.push(() => tryCommand("xclip", "xclip", ["-selection", "clipboard"], text))
103
+ attempts.push(() => tryCommand("xsel", "xsel", ["--clipboard", "--input"], text))
104
+ }
105
+
106
+ if (os === "win32") {
107
+ attempts.push(() =>
108
+ tryCommand(
109
+ "powershell",
110
+ "powershell.exe",
111
+ [
112
+ "-NonInteractive",
113
+ "-NoProfile",
114
+ "-Command",
115
+ "[Console]::InputEncoding=[Text.Encoding]::UTF8; Set-Clipboard ([Console]::In.ReadToEnd())",
116
+ ],
117
+ text,
118
+ ),
119
+ )
120
+ }
121
+
122
+ for (const attempt of attempts) {
123
+ const result = await attempt()
124
+ if (result) return result
125
+ }
126
+
127
+ try {
128
+ if (writeOsc52(text)) return { copied: true, method: "osc52" }
129
+ } catch {
130
+ // Continue to the explicit failure result.
131
+ }
132
+
133
+ return { copied: false, method: "none", error: "clipboard_unavailable" }
134
+ }
package/src/index.tsx CHANGED
@@ -20,12 +20,13 @@ import {
20
20
  For,
21
21
  } from "solid-js"
22
22
  import { PLUGIN_VERSION } from "./_version"
23
+ import { copyText } from "./clipboard"
23
24
 
24
25
  // ===================================================================
25
26
  // Types
26
27
  // ===================================================================
27
28
 
28
- type SubStatus = "running" | "done" | "error"
29
+ type SubStatus = "running" | "done" | "error" | "cancel_requested" | "cancelled"
29
30
 
30
31
  interface SubEntry {
31
32
  id: string
@@ -42,6 +43,9 @@ interface SubEntry {
42
43
  model?: string
43
44
  todoTotal?: number
44
45
  todoDone?: number
46
+ cancelRequestedAt?: number
47
+ abortAccepted?: boolean
48
+ cancelReason?: "manual"
45
49
  }
46
50
 
47
51
  type Lang = "zh" | "en"
@@ -68,14 +72,18 @@ const I18N: Record<Lang, Record<string, string>> = {
68
72
  "todo.label": "进度",
69
73
  "session.label": "会话 ID",
70
74
  "session.toast.copy": "可手动复制上方 ID",
75
+ "session.toast.copied": "会话 ID 已复制",
76
+ "session.toast.copy_failed": "无法访问系统剪贴板,请手动复制上方 ID",
71
77
  "open.label": "进入会话",
72
78
  "cost.label": "费用",
73
79
  "scroll.more": "更多",
74
80
  "scroll.top": "回顶",
75
81
  "scroll.bottom": "回底",
76
82
  "dismiss.label": "标记完成",
83
+ "cancel.label": "取消",
77
84
  "status.running": "运行中",
78
85
  "status.done": "已完成",
86
+ "status.cancelled": "已取消",
79
87
  "status.error": "错误",
80
88
  "order.desc": "降序(最新在前)",
81
89
  "order.asc": "升序(最早在前)",
@@ -94,6 +102,14 @@ const I18N: Record<Lang, Record<string, string>> = {
94
102
  "clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?",
95
103
  "clear.done": "已清除 {n} 条子代理记录",
96
104
  "clear.empty": "当前会话无子代理记录",
105
+ "cancel.no_session": "子会话 ID 不可用",
106
+ "cancel.not_child": "目标不是子会话",
107
+ "cancel.read_error": "无法读取会话信息",
108
+ "cancel.outside_tree": "目标不在当前监控会话树中",
109
+ "cancel.already_ended": "会话已结束,无需取消",
110
+ "cancel.status_error": "无法查询会话状态",
111
+ "cancel.sent": "已发送取消指令",
112
+ "cancel.failed": "取消失败",
97
113
  },
98
114
  en: {
99
115
  "panel.title": "SubAgent",
@@ -107,14 +123,18 @@ const I18N: Record<Lang, Record<string, string>> = {
107
123
  "todo.label": "todo",
108
124
  "session.label": "session ID",
109
125
  "session.toast.copy": "Copy the ID above manually",
126
+ "session.toast.copied": "Session ID copied",
127
+ "session.toast.copy_failed": "Cannot access the system clipboard; copy the ID above manually",
110
128
  "open.label": "Open session",
111
129
  "cost.label": "cost",
112
130
  "scroll.more": "more",
113
131
  "scroll.top": "Top",
114
132
  "scroll.bottom": "Bottom",
115
133
  "dismiss.label": "dismiss",
134
+ "cancel.label": "Cancel",
116
135
  "status.running": "running",
117
136
  "status.done": "done",
137
+ "status.cancelled": "cancelled",
118
138
  "status.error": "error",
119
139
  "order.desc": "Desc (newest first)",
120
140
  "order.asc": "Asc (oldest first)",
@@ -133,6 +153,14 @@ const I18N: Record<Lang, Record<string, string>> = {
133
153
  "clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?",
134
154
  "clear.done": "Cleared {n} sub-agent record(s)",
135
155
  "clear.empty": "No sub-agent records in this session",
156
+ "cancel.no_session": "Child session ID is unavailable",
157
+ "cancel.not_child": "Target is not a child session",
158
+ "cancel.read_error": "Cannot read session info",
159
+ "cancel.outside_tree": "Target is outside the monitored session tree",
160
+ "cancel.already_ended": "Session already ended, no need to cancel",
161
+ "cancel.status_error": "Cannot query session status",
162
+ "cancel.sent": "Cancel instruction sent",
163
+ "cancel.failed": "Cancellation failed",
136
164
  },
137
165
  }
138
166
 
@@ -449,7 +477,7 @@ function SubAgentPanel(props: {
449
477
  let needsImmediateFlush = false
450
478
  for (const [id, entry] of next) {
451
479
  const prevEntry = prev.get(id)
452
- if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error")) {
480
+ if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error" || entry.status === "cancelled")) {
453
481
  needsImmediateFlush = true
454
482
  break
455
483
  }
@@ -497,6 +525,7 @@ function SubAgentPanel(props: {
497
525
  )
498
526
  const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
499
527
  const [hoveredDismiss, setHoveredDismiss] = createSignal<string | undefined>(undefined)
528
+ const [hoveredCancel, setHoveredCancel] = createSignal<string | undefined>(undefined)
500
529
  const [hoveredTop, setHoveredTop] = createSignal(false)
501
530
  const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false)
502
531
  const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false)
@@ -602,7 +631,7 @@ function SubAgentPanel(props: {
602
631
  const next = new Map(prev)
603
632
  const nowTs = Date.now()
604
633
  const e = partial.status
605
- const ended = e === "done" || e === "error"
634
+ const ended = e === "done" || e === "error" || e === "cancelled"
606
635
  next.set(partial.id, {
607
636
  ...(existing ?? { startedAt: nowTs }),
608
637
  ...partial,
@@ -613,6 +642,108 @@ function SubAgentPanel(props: {
613
642
  })
614
643
  }
615
644
 
645
+ // ── cancel helpers ──
646
+ const isDescendantOf = (childId: string, rootId: string): boolean => {
647
+ const visited = new Set<string>()
648
+ try {
649
+ let current = props.api.state.session.get(childId) as any
650
+ while (current?.parentID) {
651
+ if (visited.has(current.id)) return false
652
+ visited.add(current.id)
653
+ if (current.parentID === rootId) return true
654
+ current = props.api.state.session.get(current.parentID) as any
655
+ }
656
+ } catch {}
657
+ return false
658
+ }
659
+
660
+ const settleOnIdle = (entry: SubEntry): SubStatus => {
661
+ if (entry.status === "cancel_requested" && entry.abortAccepted) return "cancelled"
662
+ return "done"
663
+ }
664
+
665
+ const cancelEntry = async (entry: SubEntry) => {
666
+ const childId = entry.sessionId
667
+ if (!childId) {
668
+ props.api.ui.toast({
669
+ title: entry.title || entry.agent,
670
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.no_session"] ?? "Child session ID is unavailable"),
671
+ })
672
+ return
673
+ }
674
+
675
+ try {
676
+ const child = props.api.state.session.get(childId) as any
677
+ if (!child?.parentID) {
678
+ props.api.ui.toast({
679
+ title: entry.title || entry.agent,
680
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.not_child"] ?? "Target is not a child session"),
681
+ })
682
+ return
683
+ }
684
+ } catch {
685
+ props.api.ui.toast({
686
+ title: entry.title || entry.agent,
687
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.read_error"] ?? "Cannot read session info"),
688
+ })
689
+ return
690
+ }
691
+
692
+ if (!isDescendantOf(childId, props.sessionId)) {
693
+ props.api.ui.toast({
694
+ title: entry.title || entry.agent,
695
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.outside_tree"] ?? "Target is outside the monitored session tree"),
696
+ })
697
+ return
698
+ }
699
+
700
+ try {
701
+ const st = props.api.state.session.status(childId)
702
+ if (st?.type !== "busy") {
703
+ const tokens = readSessionTokens(childId)
704
+ const cost = readSessionCost(childId)
705
+ upsertEntry({
706
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
707
+ status: "done", sessionId: entry.sessionId,
708
+ tokens, cost,
709
+ })
710
+ return
711
+ }
712
+ } catch {
713
+ props.api.ui.toast({
714
+ title: entry.title || entry.agent,
715
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.status_error"] ?? "Cannot query session status"),
716
+ })
717
+ return
718
+ }
719
+
720
+ upsertEntry({
721
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
722
+ status: "cancel_requested", sessionId: entry.sessionId,
723
+ cancelRequestedAt: Date.now(), abortAccepted: false, cancelReason: "manual",
724
+ } as any)
725
+
726
+ try {
727
+ await (props.api as any).client.session.abort({ sessionID: childId })
728
+ upsertEntry({
729
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
730
+ status: "cancel_requested", sessionId: entry.sessionId,
731
+ abortAccepted: true,
732
+ } as any)
733
+ props.api.ui.toast({ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.sent"] ?? "Cancel instruction sent") })
734
+ } catch (err) {
735
+ upsertEntry({
736
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
737
+ status: "error", sessionId: entry.sessionId,
738
+ error: String(err),
739
+ })
740
+ props.api.ui.toast({
741
+ title: entry.title || entry.agent,
742
+ message: t("cancel.label") + ": " + (I18N[props.lang()]["cancel.failed"] ?? "Cancellation failed"),
743
+ })
744
+ }
745
+ }
746
+
616
747
  // ── event handlers ──
617
748
  const handlePartUpdated = (event: unknown) => {
618
749
  const e = event as Record<string, unknown>
@@ -728,10 +859,11 @@ function SubAgentPanel(props: {
728
859
  targetStatus: SubStatus,
729
860
  nowTs: number,
730
861
  ): boolean => {
731
- // 精确匹配:sessionId 对得上 + 状态为 running
862
+ // 精确匹配:sessionId 对得上 + 状态为 running / cancel_requested
732
863
  for (const [, entry] of entriesMap) {
733
- if (entry.sessionId === targetSid && entry.status === "running") {
734
- entry.status = targetStatus
864
+ if (entry.sessionId === targetSid && (entry.status === "running" || entry.status === "cancel_requested")) {
865
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(entry)
866
+ entry.status = finalStatus
735
867
  entry.endedAt = nowTs
736
868
  entry.tokens = entry.tokens ?? sessionTokens
737
869
  entry.cost = entry.cost ?? sessionCost
@@ -742,13 +874,13 @@ function SubAgentPanel(props: {
742
874
  return true
743
875
  }
744
876
  }
745
- // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running
877
+ // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running / cancel_requested
746
878
  if (sessionAgent) {
747
879
  const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
748
880
  const saNorm = normalize(sessionAgent)
749
881
  let best: { entry: SubEntry; gap: number } | null = null
750
882
  for (const [, entry] of entriesMap) {
751
- if (entry.status !== "running") continue
883
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
752
884
  const eaNorm = normalize(entry.agent)
753
885
  if (!eaNorm || !saNorm) continue
754
886
  if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
@@ -757,14 +889,15 @@ function SubAgentPanel(props: {
757
889
  }
758
890
  if (!best) {
759
891
  for (const [, entry] of entriesMap) {
760
- if (entry.status !== "running") continue
892
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
761
893
  if (entry.sessionId) continue
762
894
  const gap = nowTs - (entry.startedAt || 0)
763
895
  if (!best || gap > best.gap) best = { entry, gap }
764
896
  }
765
897
  }
766
898
  if (best) {
767
- best.entry.status = targetStatus
899
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(best.entry)
900
+ best.entry.status = finalStatus
768
901
  best.entry.endedAt = nowTs
769
902
  best.entry.tokens = best.entry.tokens ?? sessionTokens
770
903
  best.entry.cost = best.entry.cost ?? sessionCost
@@ -784,14 +917,15 @@ function SubAgentPanel(props: {
784
917
  const next = new Map(prev)
785
918
  for (const [id, entry] of next) {
786
919
  if (entry.sessionId !== sid) continue
787
- if (entry.status !== "running" && entry.status !== "done") continue
920
+ if (entry.status !== "running" && entry.status !== "done" && entry.status !== "cancel_requested") continue
788
921
  // Skip parent session idle — subagent entries belong to child sessions only
789
922
  if (sid === props.sessionId) continue
790
923
  // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
791
- const alreadySettled = entry.status !== "running"
924
+ const alreadySettled = entry.status !== "running" && entry.status !== "cancel_requested"
925
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
792
926
  next.set(id, {
793
927
  ...entry,
794
- ...(alreadySettled ? {} : { status, endedAt: Date.now() }),
928
+ ...(alreadySettled ? {} : { status: finalStatus, endedAt: Date.now() }),
795
929
  tokens: entry.tokens ?? sessionTokens,
796
930
  cost: entry.cost ?? sessionCost,
797
931
  model: entry.model ?? sessionModel,
@@ -809,7 +943,7 @@ function SubAgentPanel(props: {
809
943
 
810
944
  // Phase 1: try matching by agent name(agent 名有交集)
811
945
  for (const [id, entry] of next) {
812
- if (entry.status !== "running") continue
946
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
813
947
  const eaNorm = normalize(entry.agent)
814
948
  if (!eaNorm || !saNorm) continue
815
949
  if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
@@ -821,7 +955,7 @@ function SubAgentPanel(props: {
821
955
  // fall back to time proximity for entries that have no sessionId yet
822
956
  if (!best) {
823
957
  for (const [id, entry] of next) {
824
- if (entry.status !== "running") continue
958
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
825
959
  if (entry.sessionId) continue
826
960
  const gap = nowTs - (entry.startedAt || 0)
827
961
  if (!best || gap > best.gap) best = { id, gap }
@@ -830,8 +964,9 @@ function SubAgentPanel(props: {
830
964
 
831
965
  if (best) {
832
966
  const entry = next.get(best.id)!
967
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
833
968
  next.set(best.id, {
834
- ...entry, status, endedAt: nowTs,
969
+ ...entry, status: finalStatus, endedAt: nowTs,
835
970
  tokens: sessionTokens || entry.tokens,
836
971
  cost: sessionCost || entry.cost,
837
972
  sessionId: sid,
@@ -1064,7 +1199,7 @@ function SubAgentPanel(props: {
1064
1199
  }
1065
1200
 
1066
1201
  // Already settled → skip
1067
- if (exists && exists.status !== "running") continue
1202
+ if (exists && exists.status !== "running" && exists.status !== "cancel_requested") continue
1068
1203
  // Running entry with no explicit status improvement from part:
1069
1204
  // try message-level heuristics first, then time-based fallback.
1070
1205
  if (exists && status === "running") {
@@ -1121,14 +1256,17 @@ function SubAgentPanel(props: {
1121
1256
  let changed = false
1122
1257
  const next = new Map(prev)
1123
1258
  for (const [id, entry] of next) {
1124
- if (entry.status !== "running" || !entry.sessionId) continue
1259
+ if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue
1125
1260
  try {
1126
1261
  const st = props.api.state.session.status(entry.sessionId)
1127
1262
  if (!st || st.type !== "idle") continue
1128
1263
  const tokens = readSessionTokens(entry.sessionId)
1129
1264
  const cost = readSessionCost(entry.sessionId)
1265
+ const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
1266
+ ? "cancelled" as SubStatus
1267
+ : "done" as SubStatus
1130
1268
  next.set(id, {
1131
- ...entry, status: "done" as SubStatus, endedAt: Date.now(),
1269
+ ...entry, status: finalStatus, endedAt: Date.now(),
1132
1270
  tokens: tokens ?? entry.tokens,
1133
1271
  cost: cost ?? entry.cost,
1134
1272
  })
@@ -1229,8 +1367,8 @@ function SubAgentPanel(props: {
1229
1367
  }))
1230
1368
  })
1231
1369
 
1232
- const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length)
1233
- const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length)
1370
+ const doneCount = createMemo(() => entryList().filter((e) => e.status === "done" || e.status === "cancelled").length)
1371
+ const runningCount = createMemo(() => entryList().filter((e) => e.status === "running" || e.status === "cancel_requested").length)
1234
1372
  const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
1235
1373
  const anyEntry = () => entryList().length > 0
1236
1374
 
@@ -1421,12 +1559,16 @@ function SubAgentPanel(props: {
1421
1559
  {(entry) => {
1422
1560
  const isExpanded = () => expanded() === entry.id
1423
1561
  const isRunning = entry.status === "running"
1562
+ const isCancelRequested = entry.status === "cancel_requested"
1563
+ const isCancelled = entry.status === "cancelled"
1424
1564
  const isError = entry.status === "error"
1565
+ const isActiveRunning = isRunning || isCancelRequested
1425
1566
  const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
1426
1567
 
1427
1568
  const statusDot = () => "\u25cf"
1428
1569
  const statusColor = () => {
1429
- if (!isRunning) return isError ? pal().error : pal().success
1570
+ if (isCancelled) return pal().muted
1571
+ if (!isActiveRunning) return isError ? pal().error : pal().success
1430
1572
  const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
1431
1573
  const a = rgb(pal().muted), b = rgb(pal().warning)
1432
1574
  if (!a || !b) return pal().warning
@@ -1437,7 +1579,7 @@ function SubAgentPanel(props: {
1437
1579
  }
1438
1580
 
1439
1581
  const timeColor = () =>
1440
- isRunning ? pal().warning : isError ? pal().error : pal().muted
1582
+ isActiveRunning ? pal().warning : isError ? pal().error : pal().muted
1441
1583
 
1442
1584
  // Entry label: collapsed shows title only, expanded shows title only too
1443
1585
  const tokenText = () =>
@@ -1446,7 +1588,7 @@ function SubAgentPanel(props: {
1446
1588
  : ""
1447
1589
  const timeText = () =>
1448
1590
  !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1449
- ? fmtDurationShort(elapsed(), isRunning)
1591
+ ? fmtDurationShort(elapsed(), isActiveRunning)
1450
1592
  : ""
1451
1593
  const suffixW = () => {
1452
1594
  let w = 0
@@ -1499,8 +1641,8 @@ function SubAgentPanel(props: {
1499
1641
  {" "}
1500
1642
  <span style={{ fg: pal().primary }}>{t("status.label")}: </span>
1501
1643
  <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")}
1644
+ <span style={{ fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }}>
1645
+ {isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done")}
1504
1646
  </span>
1505
1647
  </text>
1506
1648
  <Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
@@ -1509,7 +1651,7 @@ function SubAgentPanel(props: {
1509
1651
  <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1510
1652
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1511
1653
  <span style={{ fg: pal().muted }}>
1512
- {fmtDurationShort(elapsed(), isRunning)}
1654
+ {fmtDurationShort(elapsed(), isActiveRunning)}
1513
1655
  </span>
1514
1656
  </text>
1515
1657
  </Show>
@@ -1560,14 +1702,28 @@ function SubAgentPanel(props: {
1560
1702
  </Show>
1561
1703
  <Show when={entry.sessionId}>
1562
1704
  <text
1563
- onMouseUp={() => {
1564
- if (entry.sessionId) {
1705
+ onMouseUp={async () => {
1706
+ const sessionId = entry.sessionId
1707
+ if (!sessionId) return
1708
+
1709
+ const result = await copyText(sessionId)
1710
+
1711
+ if (result.copied) {
1565
1712
  props.api.ui.toast({
1713
+ variant: "success",
1566
1714
  title: entry.title || entry.agent,
1567
- message: `${entry.sessionId}\n\n${t("session.toast.copy")}`,
1568
- duration: 8000,
1715
+ message: t("session.toast.copied"),
1716
+ duration: 2500,
1569
1717
  })
1718
+ return
1570
1719
  }
1720
+
1721
+ props.api.ui.toast({
1722
+ variant: "warning",
1723
+ title: entry.title || entry.agent,
1724
+ message: `${sessionId}\n\n${t("session.toast.copy_failed")}`,
1725
+ duration: 8000,
1726
+ })
1571
1727
  }}
1572
1728
  >
1573
1729
  {" "}
@@ -1577,19 +1733,19 @@ function SubAgentPanel(props: {
1577
1733
  <span style={{ fg: pal().warning }}> ⎘</span>
1578
1734
  </text>
1579
1735
  </Show>
1580
- {/* 进入会话 + 标记完成:同排左右两端,空间隔离防误触 */}
1736
+ {/* 进入会话 + 取消任务 + 仅清除显示:同排左右两端 */}
1581
1737
  <Show when={entry.sessionId || isRunning}>
1582
1738
  {(() => {
1583
- const dismissLabel = () => `- ${t("dismiss.label")}`
1584
1739
  const openPrefix = () => " \u2192 "
1585
1740
  const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : ""
1586
1741
  const openW = () => entry.sessionId ? visualWidth(openFull()) : 0
1587
- const spacerW = () => Math.max(1, panelWidth() - openW() - visualWidth(dismissLabel()) - 2 /* indent */)
1742
+ const cancelLabel = () => ` ${t("cancel.label")}`
1743
+ const dismissLabel = () => ` ${t("dismiss.label")}`
1744
+ const rightW = (isRunning ? visualWidth(dismissLabel()) : 0) + (isRunning && entry.sessionId ? visualWidth(cancelLabel()) : 0)
1745
+ const spacerW = () => Math.max(1, panelWidth() - openW() - rightW - 2)
1588
1746
  return (
1589
1747
  <box flexDirection="row">
1590
- <Show when={entry.sessionId}
1591
- fallback={<text>{" "}</text>}
1592
- >
1748
+ <Show when={entry.sessionId}>
1593
1749
  <text
1594
1750
  onMouseOver={() => setHoveredOpen(entry.id)}
1595
1751
  onMouseOut={() => setHoveredOpen(undefined)}
@@ -1603,19 +1759,26 @@ function SubAgentPanel(props: {
1603
1759
  <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
1604
1760
  </text>
1605
1761
  </Show>
1762
+ <text style={{ fg: pal().muted }}>{" ".repeat(spacerW())}</text>
1763
+ <Show when={isRunning && entry.sessionId}>
1764
+ <text
1765
+ onMouseOver={() => setHoveredCancel(entry.id)}
1766
+ onMouseOut={() => setHoveredCancel(undefined)}
1767
+ onMouseUp={() => cancelEntry(entry)}
1768
+ >
1769
+ <span style={{ fg: hoveredCancel() === entry.id ? pal().warning : pal().error }}>{cancelLabel()}</span>
1770
+ </text>
1771
+ </Show>
1606
1772
  <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
- </>
1773
+ <text
1774
+ onMouseOver={() => setHoveredDismiss(entry.id)}
1775
+ onMouseOut={() => setHoveredDismiss(undefined)}
1776
+ onMouseUp={() => {
1777
+ upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" })
1778
+ }}
1779
+ >
1780
+ <span style={{ fg: hoveredDismiss() === entry.id ? pal().warning : pal().muted }}>{dismissLabel()}</span>
1781
+ </text>
1619
1782
  </Show>
1620
1783
  </box>
1621
1784
  )
@@ -1890,7 +2053,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1890
2053
  }
1891
2054
  let count = 0
1892
2055
  for (const [, entry] of entries) {
1893
- if (entry.status === "running") {
2056
+ if (entry.status === "running" || entry.status === "cancel_requested") {
1894
2057
  entry.status = "done" as SubStatus
1895
2058
  entry.endedAt = Date.now()
1896
2059
  count++