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/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/clipboard.d.ts +7 -0
- package/dist/clipboard.js +85 -0
- package/dist/index.js +188 -35
- package/dist/tui.js +336 -74
- package/package.json +2 -1
- package/src/_version.ts +1 -1
- package/src/clipboard.ts +134 -0
- package/src/index.tsx +213 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-subagent-magazine",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
2
|
+
export const PLUGIN_VERSION="1.5.0";
|
package/src/clipboard.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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(),
|
|
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:
|
|
1503
|
-
{
|
|
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(),
|
|
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
|
-
|
|
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:
|
|
1568
|
-
duration:
|
|
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
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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++
|