@g3nomaly/g3n-mods 0.1.3 → 0.1.5

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.
@@ -8,8 +8,17 @@ const PANE = 'focus-mode'
8
8
  const TITLE = 'Focus'
9
9
  const block = atom({ plugin: 'focus-mode', key: 'block' } as const, null)
10
10
  const finished = atom({ plugin: 'focus-mode', key: 'finished' } as const, 0)
11
+ const length = atom({ plugin: 'focus-mode', key: 'length' } as const, 0)
11
12
 
12
13
  const DEFAULT_MINUTES = 25
14
+
15
+ /** Minutes the pane's − and + buttons move the block length by, and its bounds. */
16
+ const STEP_MINUTES = 5
17
+ const MIN_MINUTES = 5
18
+ const MAX_MINUTES = 240
19
+
20
+ /** Key in the plugin's store that remembers the chosen block length between sessions. */
21
+ const LENGTH_KEY = 'minutes'
13
22
  const MAX_SQUARES = 30
14
23
  const FILLED = '#e6b450'
15
24
  const EMPTY = '#3a3f4b'
@@ -59,6 +68,18 @@ function squaresSvg(count: number, filled: number): string {
59
68
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
60
69
  }
61
70
 
71
+ /**
72
+ * Sets the block length the pane's start button uses, for this session and later ones.
73
+ *
74
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
75
+ * @param minutes - New length in minutes, kept within `MIN_MINUTES` and `MAX_MINUTES`.
76
+ */
77
+ async function setLength($: EngineInterface, minutes: number): Promise<void> {
78
+ const kept = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, Math.round(minutes)))
79
+ await update($, length, () => kept)
80
+ await $.store.set(LENGTH_KEY, kept)
81
+ }
82
+
62
83
  /**
63
84
  * Starts a block of `minutes`, replacing one already running, and shows the pane.
64
85
  *
@@ -107,7 +128,20 @@ async function tick($: EngineInterface): Promise<void> {
107
128
  }
108
129
 
109
130
  /**
110
- * Registers this mod's `focus-mode` command, its session hook, its tick, and its pane.
131
+ * Finds the running block, if any: while one runs the person is away from the keyboard.
132
+ *
133
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
134
+ * @returns The block, or `null` when none runs or its time is up.
135
+ */
136
+ async function awayBlock($: EngineInterface): Promise<FocusBlock | null> {
137
+ const current = await read($, block)
138
+ if (current === null) return null
139
+ return (await $.clock.now()) < current.endsAt ? current : null
140
+ }
141
+
142
+ /**
143
+ * Registers this mod's `focus-mode` command, its session hook, its tick, its pane, and the hooks that keep Claude
144
+ * from asking anything during a block: questions are refused, and so are permission prompts.
111
145
  *
112
146
  * @param on - Hook registration function the engine passes in.
113
147
  * @param options - Plugin options; `minutes` sets the default block length.
@@ -117,9 +151,11 @@ export const register: Register = (on, options) => {
117
151
  const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
118
152
 
119
153
  on('session.start', async ($, e, next) => {
154
+ const remembered = Number(await $.store.get(LENGTH_KEY).catch(() => undefined))
155
+ await update($, length, () => (Number.isFinite(remembered) && remembered > 0 ? remembered : defaultMinutes))
120
156
  await $.command.register({
121
157
  name: 'focus-mode',
122
- description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
158
+ description: 'Start a focus block in which Claude asks you nothing (the length you set, or the minutes given), or end the running one',
123
159
  argumentHint: '[minutes] | end',
124
160
  })
125
161
  void $.ui.open({ id: PANE, title: TITLE })
@@ -133,24 +169,52 @@ export const register: Register = (on, options) => {
133
169
  await endFocus($, false)
134
170
  return { text: 'Focus block ended.' }
135
171
  }
136
- const minutes = arg === '' ? defaultMinutes : Number(arg)
172
+ const chosen = (await read($, length)) || defaultMinutes
173
+ const minutes = arg === '' ? chosen : Number(arg)
137
174
  if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 600) {
138
175
  return { text: `"${arg}" is not a number of minutes. Use /focus-mode 25, or /focus-mode end.` }
139
176
  }
177
+ // A length typed with the command becomes the one the pane starts with next time.
178
+ if (arg !== '') await setLength($, minutes)
140
179
  await startFocus($, minutes)
141
180
  return { text: `Focus block started: ${minutes} min.` }
142
181
  })
143
182
 
183
+ // While a block runs the person is away: a question Claude puts to them is refused, so Claude decides on its own.
184
+ on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
185
+ const away = await awayBlock($)
186
+ if (away === null) return next(e)
187
+ return { deny: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot answer. Pick the option you would recommend, say in your reply what you assumed, and carry on.` }
188
+ }).catch(($, e, next) => next(e))
189
+
190
+ // A permission prompt would wait for the person too: refuse it, so Claude works around the step or leaves it for later.
191
+ on('classic.PermissionRequest', async ($, e, next) => {
192
+ const away = await awayBlock($)
193
+ if (away === null) return next(e)
194
+ return {
195
+ decision: {
196
+ behavior: 'deny',
197
+ message: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot approve ${e.tool_name}. Do what you can without it, and list this step at the end of your reply for the user to approve later.`,
198
+ },
199
+ }
200
+ }).catch(($, e, next) => next(e))
201
+
144
202
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145
203
  const { Box, Text, Button } = $.ui.resolve(e)
146
204
  const current = await read($, block)
147
205
 
148
206
  if (current === null) {
149
207
  const done = await read($, finished)
208
+ const minutes = (await read($, length)) || defaultMinutes
150
209
  return (
151
- <Box flexDirection="row" justifyContent="space-between" alignItems="center">
152
- <Text dimColor>{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
153
- <Button key="start" label={`start ${defaultMinutes} min`} variant="primary" onPress={() => startFocus($, defaultMinutes)} />
210
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
211
+ <Text dimColor wrap="truncate-end">{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
212
+ <Box flexDirection="row" alignItems="center" columnGap={1}>
213
+ <Button key="shorter" label="−" onPress={() => setLength($, minutes - STEP_MINUTES)} />
214
+ <Text bold>{minutes} min</Text>
215
+ <Button key="longer" label="+" onPress={() => setLength($, minutes + STEP_MINUTES)} />
216
+ <Button key="start" label="start" variant="primary" onPress={() => startFocus($, minutes)} />
217
+ </Box>
154
218
  </Box>
155
219
  )
156
220
  }
@@ -176,7 +240,7 @@ export const register: Register = (on, options) => {
176
240
  <Text bold>{clock(remaining)}</Text>
177
241
  <Box flexDirection="column" flexGrow={1} gap={0}>
178
242
  {squares}
179
- <Text dimColor>focus block · {current.minutes} min · ends {wallTime(current.endsAt)}</Text>
243
+ <Text dimColor>away until {wallTime(current.endsAt)} · Claude asks you nothing</Text>
180
244
  </Box>
181
245
  <Button key="end" label="end focus" onPress={() => endFocus($, false)} />
182
246
  </Box>
@@ -9,6 +9,6 @@ export type FocusBlock = {
9
9
 
10
10
  declare module 'claude-code' {
11
11
  interface PluginState {
12
- 'focus-mode': { block: FocusBlock | null; finished: number }
12
+ 'focus-mode': { block: FocusBlock | null; finished: number; length: number }
13
13
  }
14
14
  }
@@ -14,6 +14,9 @@ const MINES = 10
14
14
  const BEST_KEY = 'best-seconds'
15
15
  /** How long a turn runs before the mod suggests a game, once per session. */
16
16
  const HINT_AFTER_MS = 60_000
17
+ /** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`) on any surface. */
18
+ const CELL_WIDTH = 2
19
+
17
20
  const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
18
21
 
19
22
  /**
@@ -224,21 +227,21 @@ export const register: Register = on => {
224
227
  const isOver = board.status === 'won' || board.status === 'lost'
225
228
 
226
229
  /**
227
- * Draws one cell: a number, a flag, a mine, or a button that reveals or flags it.
230
+ * Draws what a cell shows: a number, a flag, a mine, or a button that reveals or flags it.
228
231
  *
229
232
  * @param cell - Cell number to draw.
230
- * @returns The cell element.
233
+ * @returns The cell's content.
231
234
  */
232
- const cellView = (cell: number) => {
235
+ const cellFace = (cell: number) => {
233
236
  const isRevealed = board.revealed.includes(cell)
234
237
  const isMine = board.mines.includes(cell)
235
238
  if (isRevealed || (isOver && isMine)) {
236
- if (isMine) return <Text color="#e06c6c">✹ </Text>
239
+ if (isMine) return <Text color="#e06c6c">✹</Text>
237
240
  const n = adjacentMines(board, cell)
238
- return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : `${n} `}</Text>
241
+ return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
239
242
  }
240
243
  const label = board.flagged.includes(cell) ? '⚑' : '■'
241
- if (isOver) return <Text dimColor>{label} </Text>
244
+ if (isOver) return <Text dimColor>{label}</Text>
242
245
  return (
243
246
  <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
244
247
  <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
@@ -246,6 +249,11 @@ export const register: Register = on => {
246
249
  )
247
250
  }
248
251
 
252
+ // Every cell sits in a box of one fixed width, so revealing a cell never moves the others: glyphs differ in width.
253
+ const cellView = (cell: number) => (
254
+ <Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{cellFace(cell)}</Box>
255
+ )
256
+
249
257
  const rows = Array.from({ length: board.size }, (_, r) => (
250
258
  <Box flexDirection="row" columnGap={1}>
251
259
  {Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
@@ -37,6 +37,9 @@ const IDLE_MS = 200
37
37
  /** Most characters of a call's input the hover card keeps. */
38
38
  const DETAIL_MAX = 2000
39
39
 
40
+ /** Most characters of a call's label kept; the pane cuts it to one line anyway. */
41
+ const LABEL_MAX = 200
42
+
40
43
  /** Input fields that say most about a call, tried in order. */
41
44
  const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
42
45
 
@@ -62,10 +65,10 @@ export function labelOf(input: Record<string, unknown>): string {
62
65
  const value = input[field]
63
66
  if (typeof value !== 'string' || value.length === 0) continue
64
67
  const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
65
- return text.replace(/\s+/g, ' ').trim()
68
+ return text.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX)
66
69
  }
67
70
  const first = Object.values(input).find((value): value is string => typeof value === 'string')
68
- return first?.replace(/\s+/g, ' ').trim() ?? ''
71
+ return first?.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) ?? ''
69
72
  }
70
73
 
71
74
  /**
@@ -331,7 +334,7 @@ export const register: Register = on => {
331
334
  // One line per call; the keyed row reveals a card with the call's whole input while the pointer is on it.
332
335
  const rows = shown.map((call, index) => (
333
336
  <Box key={call.id} flexDirection="row" columnGap={1}>
334
- <Text bold color={CARD} backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
337
+ <Text bold color={CARD} backgroundColor={colorOf(call.tool)}>{e.surface === 'terminal' ? ` ${badgeOf(call.tool)} ` : badgeOf(call.tool)}</Text>
335
338
  <Box width={labelCells}>
336
339
  <Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
337
340
  {call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g3nomaly/g3n-mods",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
5
5
  "type": "module",
6
6
  "files": [
@@ -15,7 +15,7 @@
15
15
  "types": "bun scripts/sync-types.ts"
16
16
  },
17
17
  "devDependencies": {
18
- "@g3nomaly/core": "^0.3.1",
18
+ "@g3nomaly/core": "^0.3.3",
19
19
  "@types/bun": "^1.4.0",
20
20
  "typescript": "~7.0.2",
21
21
  "zod": "^4.4.3"
@@ -1,151 +0,0 @@
1
- // packages/g3n-mods/mods/delete-guard/hooks/register.ts
2
- import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
4
-
5
- import type { Deletion } from '../types'
6
-
7
- const isOff = atom({ plugin: 'delete-guard', key: 'isOff' } as const, false)
8
-
9
- /** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
10
- const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
11
-
12
- /** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
13
- const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
14
-
15
- const DELETERS = new Set(['rm', 'shred', 'unlink', 'rimraf', 'srm'])
16
-
17
- /** Trash commands in order of preference, with how each one is spelled. */
18
- const TRASH_COMMANDS = [
19
- { probe: 'trash', spell: 'trash' },
20
- { probe: 'trash-put', spell: 'trash-put' },
21
- { probe: 'gio', spell: 'gio trash' },
22
- ] as const
23
-
24
- /**
25
- * Splits a shell command line into its commands, each trimmed.
26
- *
27
- * @param command - Shell command line, e.g. `cd build && rm -rf dist`.
28
- * @returns The non-empty commands in order.
29
- */
30
- function segments(command: string): string[] {
31
- return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
32
- }
33
-
34
- /**
35
- * Splits one command into words, dropping its prefixes and the program's directory.
36
- *
37
- * @param segment - One command, as `segments` returns it.
38
- * @returns The program name first, then its arguments.
39
- */
40
- function words(segment: string): string[] {
41
- const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
42
- return [program.split('/').pop() ?? program, ...rest]
43
- }
44
-
45
- /**
46
- * Picks the operands of a command: every word that is not a flag, and every word after `--`.
47
- *
48
- * @param args - Words after the program name.
49
- * @returns The operands in order.
50
- */
51
- function operands(args: readonly string[]): string[] {
52
- const end = args.indexOf('--')
53
- const flagged = end === -1 ? args : args.slice(0, end)
54
- const after = end === -1 ? [] : args.slice(end + 1)
55
- return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
56
- }
57
-
58
- /**
59
- * @brief Finds the first permanent delete in a shell command line.
60
- *
61
- * Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
62
- * and a forced `git clean`.
63
- *
64
- * @param command - Full shell command line, as the Bash tool received it.
65
- * @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
66
- */
67
- export function findDeletion(command: string): Deletion | undefined {
68
- for (const segment of segments(command)) {
69
- const [program = '', ...args] = words(segment)
70
- if (DELETERS.has(program)) {
71
- return { segment, kind: 'files', paths: operands(args) }
72
- }
73
- if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
74
- return { segment, kind: 'find', paths: [] }
75
- }
76
- if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
77
- return { segment, kind: 'git-clean', paths: [] }
78
- }
79
- }
80
- return undefined
81
- }
82
-
83
- /**
84
- * Builds the command Claude should run instead of a permanent delete.
85
- *
86
- * @param deletion - Delete to replace, see `Deletion`.
87
- * @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
88
- * @returns The suggested command, with `<paths>` where no path was named.
89
- */
90
- function insteadOf(deletion: Deletion, trash: string): string {
91
- switch (deletion.kind) {
92
- case 'files':
93
- return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
94
- case 'find':
95
- return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
96
- case 'git-clean':
97
- return `git clean -n to list the files, then ${trash} <paths>`
98
- }
99
- }
100
-
101
- /**
102
- * Finds the first trash command on this machine's PATH.
103
- *
104
- * @param $ - Engine handle the hook received, see `EngineInterface`.
105
- * @returns The command as it should be spelled, or `undefined` when none is installed.
106
- */
107
- async function detectTrash($: EngineInterface): Promise<string | undefined> {
108
- for (const candidate of TRASH_COMMANDS) {
109
- const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
110
- if (found?.exitCode === 0) return candidate.spell
111
- }
112
- return undefined
113
- }
114
-
115
- /**
116
- * Registers this mod's `delete-guard` command, its session hook, and its Bash tool hook.
117
- *
118
- * @param on - Hook registration function the engine passes in.
119
- */
120
- export const register: Register = on => {
121
- let trash: string | undefined
122
-
123
- on('session.start', async ($, e, next) => {
124
- trash = await detectTrash($)
125
- await $.command.register({
126
- name: 'delete-guard',
127
- description: 'Turn the permanent-delete guard on or off for this session',
128
- argumentHint: 'on | off',
129
- })
130
- return next(e)
131
- })
132
-
133
- on('command.run', { command: 'delete-guard' }, async ($, e) => {
134
- const wanted = e.args.trim()
135
- if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
136
- const off = await read($, isOff)
137
- return { text: `delete-guard is ${off ? 'off: permanent deletes run' : 'on: permanent deletes are denied'}.` }
138
- })
139
-
140
- on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
141
- if (await read($, isOff)) return next(e)
142
- const deletion = findDeletion(e.command)
143
- if (deletion === undefined) return next(e)
144
-
145
- const spelled = trash ?? 'trash'
146
- const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
147
- return {
148
- deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: \`${insteadOf(deletion, spelled)}\`${install}.`,
149
- }
150
- }).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
151
- }