@g3nomaly/g3n-mods 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
30
  | `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
31
- | `tool-timeline` | Each tool call with its duration. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
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
- └── types/index.d.ts
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 with the task typed in the panel, or its standing job when none was typed.
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: typed.length > 0 ? typed : DEFAULT_TASK,
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[]; task: string }
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
+ }