@g3nomaly/g3n-mods 0.1.5 → 0.1.7
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +17 -4
- package/mods/agents-panel/hooks/register.tsx +49 -21
- package/mods/agents-panel/types/index.d.ts +16 -1
- package/mods/agents-panel/view/view.css +51 -0
- package/mods/agents-panel/view/view.ts +126 -0
- package/mods/custom-crab-agents/hooks/register.tsx +108 -95
- package/mods/custom-crab-agents/hooks/sprite.ts +131 -0
- package/mods/custom-crab-agents/types/index.d.ts +35 -0
- package/mods/custom-crab-agents/view/view.css +74 -0
- package/mods/custom-crab-agents/view/view.ts +154 -0
- package/mods/delete-guard/hooks/register.tsx +115 -33
- package/mods/delete-guard/types/index.d.ts +2 -0
- package/mods/mini-game/.claude-plugin/plugin.json +1 -1
- package/mods/mini-game/hooks/bubbles.ts +27 -0
- package/mods/mini-game/hooks/minesweeper.ts +108 -0
- package/mods/mini-game/hooks/mole.ts +91 -0
- package/mods/mini-game/hooks/register.tsx +196 -152
- package/mods/mini-game/types/index.d.ts +18 -1
- package/mods/tool-timeline/hooks/register.tsx +141 -38
- package/mods/tool-timeline/types/index.d.ts +33 -2
- package/mods/tool-timeline/view/view.css +124 -0
- package/mods/tool-timeline/view/view.ts +164 -0
- package/mods/tsconfig.view.json +14 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,13 +22,13 @@ Then open the project in VS Code and trust the workspace. New sessions load the
|
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `agents-panel` | The agents in `.claude/agents` with their model and a run button. A running agent shows a spinner, and a toast says when it starts and finishes | `/agents-panel` |
|
|
24
24
|
| `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
|
|
25
|
-
| `custom-crab-agents` | Adds each subagent to the agents panel as a crab: its role (from its name or type) picks its hat and what it holds, such as a reviewer's deerstalker and magnifying glass, a tester's work helmet and wrench, a designer's top hat and brush. Shows model, effort, steps, context, tokens, cost and time. Click a crab to open its whole conversation in a tab and message it there. Needs `agents-panel`, which installing it adds | `/agents-panel` |
|
|
25
|
+
| `custom-crab-agents` | Adds each subagent to the agents panel as a crab drawn as Claude's mascot, walking while it works and blinking when done: its role (from its name or type) picks its hat and what it holds, such as a reviewer's deerstalker and magnifying glass, a tester's work helmet and wrench, a designer's top hat and brush. Shows model, effort, steps, context, tokens, cost and time. Click anywhere on a crab to open its whole conversation in a tab and message it there. Needs `agents-panel`, which installing it adds | `/agents-panel` |
|
|
26
26
|
| `delete-guard` | Off by default: deletes run unasked. Turn it on with the switch in its pane, or `/delete-guard on`; it stays on in later sessions. While on, `rm -rf` and other permanent deletes wait on a card listing the files, their sizes and whether git can restore them, until you press **Delete** or **Cancel**. Cancel tells Claude which trash command to use instead. With nothing to draw the card on, the delete is refused | `/delete-guard on \| off` |
|
|
27
27
|
| `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. Set the length with − and + in the pane, or pass the minutes; the length you pick is kept for later blocks | `/focus-mode [minutes] \| end` |
|
|
28
28
|
| `handoff` | Writes a handoff with the `handoff` skill, clears the session, and attaches the handoff to your next prompt | `/handoff-new [what the next session is for]` |
|
|
29
29
|
| `hide-personal-data` | Masks names, emails, phone numbers, IBANs and card numbers in replies. Hover a mask to read it | `/hide-personal-data on \| off` |
|
|
30
|
-
| `mini-game` | Minesweeper,
|
|
31
|
-
| `tool-timeline` |
|
|
30
|
+
| `mini-game` | Minesweeper, or whack-a-mole when you want zero thinking: hit the mole before it hides, three misses and the round ends, and it speeds up as you score. Both keep a high score. Switch games with the tabs in its pane | `/mini-game [mines \| mole]` |
|
|
31
|
+
| `tool-timeline` | The last ten tool calls, one line each, with their durations, in a pane that keeps one height. Click a call to see it in full in the box under them. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
|
|
32
32
|
| `you-should-know` | A card under long replies with their key point, a headline and one supporting line. It costs one small Haiku call per reply of 1500 characters or more | none |
|
|
33
33
|
|
|
34
34
|
For `agents-panel`, `context-bar` and `tool-timeline`, the command opens the pane and running it again closes it.
|
|
@@ -55,9 +55,22 @@ mods/<name>/
|
|
|
55
55
|
├── .claude-plugin/plugin.json # name, version, description
|
|
56
56
|
├── hooks/hooks.json # { "modules": ["./register.tsx"] }
|
|
57
57
|
├── hooks/register.tsx # exports `register`
|
|
58
|
-
|
|
58
|
+
├── types/index.d.ts
|
|
59
|
+
└── view/ # optional: how the VS Code viewer draws it
|
|
60
|
+
├── view.ts # exports `mount(root, api)`
|
|
61
|
+
└── view.css
|
|
59
62
|
```
|
|
60
63
|
|
|
64
|
+
### Drawing a mod with HTML and CSS
|
|
65
|
+
|
|
66
|
+
On every surface but the VS Code viewer, a mod draws with the engine's elements (`Box`, `Text`, `Button`, `Svg`). In the viewer it can draw with its own HTML and CSS instead, as `tool-timeline`, `agents-panel` and `custom-crab-agents` do:
|
|
67
|
+
|
|
68
|
+
1. **The hook hands over data.** On `e.surface === 'vscode'`, the render hook returns a view box: a `Box` keyed `g3n-view:<mod>` holding the data as JSON in one `Text`, and a hidden `Input` keyed `g3n-act:<mod>` that receives the view's actions.
|
|
69
|
+
2. **The view draws it.** `view/view.ts` exports `mount(root, api)`. `root` is a shadow root of the mod's own, so `view.css` applies only there and the editor's theme variables still reach it. `mount` returns `{ update(data) }`, which gets the data each time the pane is drawn. Keep elements across updates, and animations and anything typed in the view keep going.
|
|
70
|
+
3. **Actions go back to the hook.** `api.act(name, arg)` arrives at the hidden Input's `onSubmit` as JSON of `{ name, arg }`.
|
|
71
|
+
|
|
72
|
+
The installer bundles `view/view.ts` into `view/view.js` where the mod is installed, so the mod's source stays TypeScript. A view may import plain modules of its mod, such as `hooks/sprite.ts`, but not `claude-code`.
|
|
73
|
+
|
|
61
74
|
The folder name must match `name` in `plugin.json`. Mods are TypeScript only, and the installer refuses a mod that contains any `.js`, `.mjs`, `.cjs` or `.jsx` file. To share your own mods, put them in a git repository, npm package or local folder with the same layout and register it with `bunx @g3nomaly/mods source add`.
|
|
62
75
|
|
|
63
76
|
## Development
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import { atom, read, update } from 'claude-code'
|
|
3
3
|
import type { EngineInterface, Register, RenderElement } from 'claude-code'
|
|
4
4
|
|
|
5
|
-
import type { AgentDef, RunningAgent } from '../types'
|
|
5
|
+
import type { AgentDef, AgentsViewData, RunningAgent } from '../types'
|
|
6
6
|
|
|
7
7
|
const PANE = 'agents-panel'
|
|
8
8
|
const TITLE = 'Agents'
|
|
9
9
|
const agents = atom({ plugin: 'agents-panel', key: 'agents' } as const, [])
|
|
10
10
|
const running = atom({ plugin: 'agents-panel', key: 'running' } as const, [])
|
|
11
|
-
const task = atom({ plugin: 'agents-panel', key: 'task' } as const, '')
|
|
12
11
|
|
|
13
12
|
const AGENTS_DIR = '.claude/agents'
|
|
14
13
|
const DEFAULT_TASK = 'Do the job your instructions describe for this project, then report what you did and what you found.'
|
|
@@ -113,15 +112,14 @@ async function loadAgents($: EngineInterface): Promise<void> {
|
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
/**
|
|
116
|
-
* Starts one agent
|
|
115
|
+
* Starts one agent on its standing job: what its own instructions describe. A specific task goes through the chat.
|
|
117
116
|
*
|
|
118
117
|
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
119
118
|
* @param agent - Definition of the agent to start.
|
|
120
119
|
*/
|
|
121
120
|
async function runAgent($: EngineInterface, agent: AgentDef): Promise<void> {
|
|
122
|
-
const typed = (await read($, task)).trim()
|
|
123
121
|
const started = await $.agent.spawn({
|
|
124
|
-
prompt:
|
|
122
|
+
prompt: DEFAULT_TASK,
|
|
125
123
|
subagentType: agent.name,
|
|
126
124
|
description: `Run ${agent.name} from the agents panel`,
|
|
127
125
|
})
|
|
@@ -169,6 +167,26 @@ async function reportAnswer($: EngineInterface, run: RunningAgent, answer: strin
|
|
|
169
167
|
await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `${run.name} finished:\n\n${text}` }] } })
|
|
170
168
|
}
|
|
171
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Carries out an action the panel's view sent: `run` an agent by name.
|
|
172
|
+
*
|
|
173
|
+
* @param $ - Engine handle the action came through.
|
|
174
|
+
* @param value - The action as the view sent it, JSON of `{ name, arg }`.
|
|
175
|
+
*/
|
|
176
|
+
async function act($: EngineInterface, value: string): Promise<void> {
|
|
177
|
+
let action: { name?: unknown; arg?: unknown }
|
|
178
|
+
try {
|
|
179
|
+
action = JSON.parse(value) as { name?: unknown; arg?: unknown }
|
|
180
|
+
} catch {
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const arg = typeof action.arg === 'object' && action.arg !== null ? (action.arg as Record<string, unknown>) : {}
|
|
184
|
+
if (action.name === 'run' && typeof arg.name === 'string') {
|
|
185
|
+
const agent = (await read($, agents)).find(one => one.name === arg.name)
|
|
186
|
+
if (agent !== undefined) await runAgent($, agent)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
/**
|
|
173
191
|
* Registers this mod's `agents-panel` command, its session and turn hooks, and its pane.
|
|
174
192
|
*
|
|
@@ -217,6 +235,32 @@ export const register: Register = on => {
|
|
|
217
235
|
const now = await $.clock.now()
|
|
218
236
|
const frame = SPINNER[Math.floor(now / 250) % SPINNER.length] ?? '◐'
|
|
219
237
|
|
|
238
|
+
// The viewer draws the panel with the mod's own view: hand it the agents as data, inside the key an add-on looks for.
|
|
239
|
+
if (e.surface === 'vscode') {
|
|
240
|
+
const { Input } = $.ui.resolve(e)
|
|
241
|
+
const data: AgentsViewData = {
|
|
242
|
+
agents: list.map((agent, index) => {
|
|
243
|
+
const run = runs.find(one => one.name === agent.name)
|
|
244
|
+
return {
|
|
245
|
+
name: agent.name,
|
|
246
|
+
description: agent.description,
|
|
247
|
+
model: agent.model,
|
|
248
|
+
color: dotColor(agent, index),
|
|
249
|
+
scope: agent.scope,
|
|
250
|
+
...(run === undefined ? {} : { runningFor: Math.max(0, Math.round((now - run.startedAt) / 1000)) }),
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
}
|
|
254
|
+
return withAddOn(
|
|
255
|
+
<Box key={`${OWN_KEY}body`}>
|
|
256
|
+
<Box key="g3n-view:agents-panel">
|
|
257
|
+
<Text>{JSON.stringify(data)}</Text>
|
|
258
|
+
<Input key="g3n-act:agents-panel" onSubmit={value => act($, value)} />
|
|
259
|
+
</Box>
|
|
260
|
+
</Box>,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
220
264
|
const header = (
|
|
221
265
|
<Box flexDirection="row" justifyContent="space-between">
|
|
222
266
|
<Text>
|
|
@@ -276,25 +320,9 @@ export const register: Register = on => {
|
|
|
276
320
|
)
|
|
277
321
|
}
|
|
278
322
|
|
|
279
|
-
const input = e.surface === 'mobile'
|
|
280
|
-
? ''
|
|
281
|
-
: (() => {
|
|
282
|
-
const { Input } = $.ui.resolve(e)
|
|
283
|
-
return (
|
|
284
|
-
<Input
|
|
285
|
-
key="task"
|
|
286
|
-
label="Task"
|
|
287
|
-
placeholder="what the next run should do (optional)"
|
|
288
|
-
onInput={value => update($, task, () => value)}
|
|
289
|
-
onSubmit={value => update($, task, () => value)}
|
|
290
|
-
/>
|
|
291
|
-
)
|
|
292
|
-
})()
|
|
293
|
-
|
|
294
323
|
return withAddOn(
|
|
295
324
|
<Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
|
|
296
325
|
{header}
|
|
297
|
-
{input}
|
|
298
326
|
{section('project')}
|
|
299
327
|
{section('user')}
|
|
300
328
|
<Text dimColor>click ▶ run to start one · /agents-panel to hide</Text>
|
|
@@ -17,8 +17,23 @@ export type RunningAgent = {
|
|
|
17
17
|
startedAt: number
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** One agent as the panel's view draws it; `runningFor` is set while a run started from the panel goes on, in seconds. */
|
|
21
|
+
export type AgentView = {
|
|
22
|
+
name: string
|
|
23
|
+
description: string
|
|
24
|
+
model: string
|
|
25
|
+
color: string
|
|
26
|
+
scope: 'project' | 'user'
|
|
27
|
+
runningFor?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the panel's view gets: the agents. */
|
|
31
|
+
export type AgentsViewData = {
|
|
32
|
+
agents: AgentView[]
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
declare module 'claude-code' {
|
|
21
36
|
interface PluginState {
|
|
22
|
-
'agents-panel': { agents: AgentDef[]; running: RunningAgent[]
|
|
37
|
+
'agents-panel': { agents: AgentDef[]; running: RunningAgent[] }
|
|
23
38
|
}
|
|
24
39
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* packages/g3n-mods/mods/agents-panel/view/view.css */
|
|
2
|
+
.ap {
|
|
3
|
+
--dim: var(--vscode-descriptionForeground, #9d9d9d);
|
|
4
|
+
--line: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 12px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.ap-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
|
11
|
+
.ap-title { display: inline-flex; align-items: baseline; gap: 6px; font-weight: 600; font-size: 14px; }
|
|
12
|
+
.ap-mark { color: #d97757; }
|
|
13
|
+
.ap-sub { color: var(--dim); font-weight: 400; font-size: 13px; }
|
|
14
|
+
.ap-busy { color: var(--dim); font-size: 12px; }
|
|
15
|
+
.ap-busy.is-on::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #d97757; animation: ap-pulse 1.2s ease-in-out infinite; }
|
|
16
|
+
@keyframes ap-pulse { 50% { opacity: 0.3; } }
|
|
17
|
+
|
|
18
|
+
.ap-sections { display: flex; flex-direction: column; gap: 12px; }
|
|
19
|
+
.ap-label { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
|
20
|
+
.ap-scope { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #8fa1d6; }
|
|
21
|
+
.ap-path { color: var(--dim); font-size: 12px; }
|
|
22
|
+
.ap-list { display: flex; flex-direction: column; }
|
|
23
|
+
|
|
24
|
+
.ap-row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; column-gap: 10px; padding: 6px 8px; border-radius: 8px; }
|
|
25
|
+
.ap-row:hover { background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent); }
|
|
26
|
+
.ap-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
27
|
+
.ap-text { min-width: 0; }
|
|
28
|
+
.ap-line { display: flex; align-items: baseline; gap: 8px; }
|
|
29
|
+
.ap-name { font-weight: 600; }
|
|
30
|
+
.ap-model { color: var(--dim); font-size: 12px; }
|
|
31
|
+
.ap-desc { color: var(--dim); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
32
|
+
|
|
33
|
+
.ap-run {
|
|
34
|
+
height: 26px;
|
|
35
|
+
padding: 0 12px;
|
|
36
|
+
border: 1px solid var(--line);
|
|
37
|
+
border-radius: 13px;
|
|
38
|
+
background: transparent;
|
|
39
|
+
color: var(--vscode-foreground);
|
|
40
|
+
font: inherit;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
transition: background-color 100ms ease, border-color 100ms ease;
|
|
45
|
+
}
|
|
46
|
+
.ap-run:hover:not(:disabled) { background: #d97757; border-color: #d97757; color: #fff; }
|
|
47
|
+
.ap-run:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; }
|
|
48
|
+
.ap-run:disabled { cursor: default; color: var(--dim); border-style: dashed; }
|
|
49
|
+
.ap-run[data-running="pending"] { opacity: 0.6; }
|
|
50
|
+
|
|
51
|
+
.ap-empty { margin: 0; color: var(--dim); font-size: 12px; }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/agents-panel/view/view.ts
|
|
2
|
+
import type { AgentView, AgentsViewData } from '../types'
|
|
3
|
+
|
|
4
|
+
/** What the viewer hands a view: a way to send the mod an action and to open a link. */
|
|
5
|
+
type ViewApi = { act: (name: string, arg?: unknown) => void; openLink: (href: string) => void }
|
|
6
|
+
|
|
7
|
+
const AGENTS_DIR = '.claude/agents'
|
|
8
|
+
|
|
9
|
+
/** Makes an element with a class, and text when given. */
|
|
10
|
+
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
|
|
11
|
+
const node = document.createElement(tag)
|
|
12
|
+
node.className = className
|
|
13
|
+
if (text !== undefined) node.textContent = text
|
|
14
|
+
return node
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Sets an element's text only when it changed. */
|
|
18
|
+
function setText(node: Element, text: string): void {
|
|
19
|
+
if (node.textContent !== text) node.textContent = text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @brief Mounts the agents panel: the agents of the project and the user with a run button each. Rows are kept by
|
|
24
|
+
* name across updates, so a running spinner keeps turning.
|
|
25
|
+
*
|
|
26
|
+
* @param root - The shadow root the viewer gives this view.
|
|
27
|
+
* @param api - Sends `run` to the mod.
|
|
28
|
+
* @returns The instance the viewer hands each update to.
|
|
29
|
+
*/
|
|
30
|
+
export function mount(root: ShadowRoot, api: ViewApi): { update: (data: unknown) => void } {
|
|
31
|
+
const frame = el('div', 'ap')
|
|
32
|
+
const head = el('header', 'ap-head')
|
|
33
|
+
const title = el('span', 'ap-title')
|
|
34
|
+
title.append(el('span', 'ap-mark', '◆'), el('span', '', 'Agents'), el('span', 'ap-sub', 'in this project'))
|
|
35
|
+
const busy = el('span', 'ap-busy')
|
|
36
|
+
head.append(title, busy)
|
|
37
|
+
|
|
38
|
+
const sections = el('div', 'ap-sections')
|
|
39
|
+
const empty = el('p', 'ap-empty', `No agents in ${AGENTS_DIR} or ~/${AGENTS_DIR}. Add one with /agents.`)
|
|
40
|
+
frame.append(head, sections)
|
|
41
|
+
root.append(frame)
|
|
42
|
+
|
|
43
|
+
const rows = new Map<string, HTMLElement>()
|
|
44
|
+
|
|
45
|
+
/** Builds an agent's row once: dot, name, model, description, and its run button. */
|
|
46
|
+
const makeRow = (name: string): HTMLElement => {
|
|
47
|
+
const row = el('div', 'ap-row')
|
|
48
|
+
const dot = el('span', 'ap-dot')
|
|
49
|
+
const text = el('div', 'ap-text')
|
|
50
|
+
const line = el('div', 'ap-line')
|
|
51
|
+
line.append(el('span', 'ap-name', name), el('span', 'ap-model'))
|
|
52
|
+
text.append(line, el('div', 'ap-desc'))
|
|
53
|
+
const run = el('button', 'ap-run')
|
|
54
|
+
run.type = 'button'
|
|
55
|
+
run.addEventListener('click', () => {
|
|
56
|
+
if (run.dataset.running === 'true') return
|
|
57
|
+
run.dataset.running = 'pending'
|
|
58
|
+
api.act('run', { name })
|
|
59
|
+
})
|
|
60
|
+
row.append(dot, text, run)
|
|
61
|
+
return row
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Brings an agent's row up to date. */
|
|
65
|
+
const fill = (row: HTMLElement, agent: AgentView): void => {
|
|
66
|
+
const [dot, text, run] = Array.from(row.children) as HTMLElement[]
|
|
67
|
+
if (dot === undefined || text === undefined || run === undefined) return
|
|
68
|
+
dot.style.background = agent.color
|
|
69
|
+
const model = text.querySelector('.ap-model')
|
|
70
|
+
if (model !== null) setText(model, agent.model)
|
|
71
|
+
const desc = text.querySelector('.ap-desc')
|
|
72
|
+
if (desc !== null) setText(desc, agent.description)
|
|
73
|
+
row.title = agent.description
|
|
74
|
+
const isRunning = agent.runningFor !== undefined
|
|
75
|
+
run.dataset.running = String(isRunning)
|
|
76
|
+
if (run instanceof HTMLButtonElement) run.disabled = isRunning
|
|
77
|
+
setText(run, isRunning ? `running · ${agent.runningFor}s` : '▶ Run')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
update: (data: unknown) => {
|
|
82
|
+
const view = data as AgentsViewData | null
|
|
83
|
+
const agents = view?.agents ?? []
|
|
84
|
+
const running = agents.filter(agent => agent.runningFor !== undefined).length
|
|
85
|
+
setText(busy, running > 0 ? `${running} running` : '')
|
|
86
|
+
busy.classList.toggle('is-on', running > 0)
|
|
87
|
+
|
|
88
|
+
if (agents.length === 0) {
|
|
89
|
+
if (sections.firstChild !== empty) sections.replaceChildren(empty)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const groups: HTMLElement[] = []
|
|
93
|
+
for (const scope of ['project', 'user'] as const) {
|
|
94
|
+
const members = agents.filter(agent => agent.scope === scope)
|
|
95
|
+
if (members.length === 0) continue
|
|
96
|
+
let group = sections.querySelector<HTMLElement>(`.ap-group[data-scope="${scope}"]`)
|
|
97
|
+
if (group === null) {
|
|
98
|
+
group = el('section', 'ap-group')
|
|
99
|
+
group.dataset.scope = scope
|
|
100
|
+
const label = el('div', 'ap-label')
|
|
101
|
+
label.append(el('span', 'ap-scope', scope === 'project' ? 'Project' : 'User'), el('span', 'ap-path'))
|
|
102
|
+
group.append(label, el('div', 'ap-list'))
|
|
103
|
+
}
|
|
104
|
+
const path = group.querySelector('.ap-path')
|
|
105
|
+
if (path !== null) setText(path, `${scope === 'project' ? AGENTS_DIR : `~/${AGENTS_DIR}`} · ${members.length}`)
|
|
106
|
+
const list = group.querySelector('.ap-list') as HTMLElement
|
|
107
|
+
members.forEach((agent, index) => {
|
|
108
|
+
const key = `${scope}:${agent.name}`
|
|
109
|
+
let row = rows.get(key)
|
|
110
|
+
if (row === undefined) {
|
|
111
|
+
row = makeRow(agent.name)
|
|
112
|
+
rows.set(key, row)
|
|
113
|
+
}
|
|
114
|
+
fill(row, agent)
|
|
115
|
+
if (list.children[index] !== row) list.insertBefore(row, list.children[index] ?? null)
|
|
116
|
+
})
|
|
117
|
+
while (list.children.length > members.length) list.lastElementChild?.remove()
|
|
118
|
+
groups.push(group)
|
|
119
|
+
}
|
|
120
|
+
groups.forEach((group, index) => {
|
|
121
|
+
if (sections.children[index] !== group) sections.insertBefore(group, sections.children[index] ?? null)
|
|
122
|
+
})
|
|
123
|
+
while (sections.children.length > groups.length) sections.lastElementChild?.remove()
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|