spinnerverb 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/index.mjs +471 -0
  4. package/package.json +39 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ideademic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # spinnerverb
2
+
3
+ Interactive TUI to customize [Claude Code](https://claude.ai/code) spinner verbs.
4
+
5
+ ```
6
+ npx spinnerverb
7
+ ```
8
+
9
+ ## What it does
10
+
11
+ Claude Code shows animated verbs while working ("Thinking", "Reasoning", "Pondering", etc.). This tool lets you customize them through an interactive terminal UI.
12
+
13
+ - **Append** your verbs alongside the defaults, or **replace** them entirely
14
+ - Edit globally (`~/.claude/settings.json`) or per-project (`.claude/settings.json`)
15
+ - Add, edit, delete, and clear verbs with instant save
16
+
17
+ ## Screenshots
18
+
19
+ ```
20
+ ███ spinnerverb
21
+ Scope: Global (~/.claude) │ ~/.claude/settings.json
22
+
23
+ ╭──────────────────────────────────────────────────────╮
24
+ │ APPEND — your verbs are added alongside the defaults │
25
+ ╰──────────────────────────────────────────────────────╯
26
+ ▶ Mode: append
27
+ ───
28
+ Vibing
29
+ Manifesting
30
+ Yapping
31
+ ───
32
+ + Add verb
33
+ Clear all verbs
34
+
35
+ ↑↓/jk navigate enter select/edit d delete q quit
36
+ ```
37
+
38
+ ## Controls
39
+
40
+ | Key | Action |
41
+ |---|---|
42
+ | `↑↓` / `jk` | Navigate |
43
+ | `Enter` / `Space` | Select / edit |
44
+ | `d` | Delete verb |
45
+ | `q` / `Esc` | Quit |
46
+
47
+ ## How it works
48
+
49
+ Reads and writes the `spinnerVerbs` field in your Claude Code `settings.json`:
50
+
51
+ ```json
52
+ {
53
+ "spinnerVerbs": {
54
+ "mode": "append",
55
+ "verbs": ["Vibing", "Manifesting", "Yapping"]
56
+ }
57
+ }
58
+ ```
59
+
60
+ - `"append"` — your verbs are added to Claude Code's built-in set
61
+ - `"replace"` — only your verbs are used
62
+
63
+ ## Settings file locations
64
+
65
+ | Scope | File | Committed |
66
+ |---|---|---|
67
+ | Global | `~/.claude/settings.json` | N/A |
68
+ | Project | `.claude/settings.json` | Yes |
69
+ | Project (local) | `.claude/settings.local.json` | No |
70
+
71
+ ## License
72
+
73
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env node
2
+ import React, { useState, useEffect } from "react";
3
+ import { render, Box, Text, useInput, useApp } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+
9
+ const GLOBAL_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
10
+ const LOCAL_SETTINGS = path.join(process.cwd(), ".claude", "settings.json");
11
+ const LOCAL_SETTINGS_LOCAL = path.join(
12
+ process.cwd(),
13
+ ".claude",
14
+ "settings.local.json"
15
+ );
16
+
17
+ const DEFAULT_VERBS = [
18
+ "Thinking",
19
+ "Reasoning",
20
+ "Considering",
21
+ "Analyzing",
22
+ "Synthesizing",
23
+ "Evaluating",
24
+ "Processing",
25
+ "Computing",
26
+ "Reflecting",
27
+ "Examining",
28
+ "Contemplating",
29
+ "Deliberating",
30
+ "Pondering",
31
+ "Working",
32
+ ];
33
+
34
+ function readSettings(filePath) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ function writeSettings(filePath, settings) {
43
+ const dir = path.dirname(filePath);
44
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + "\n");
46
+ }
47
+
48
+ function getSpinnerVerbs(settings) {
49
+ return settings.spinnerVerbs || null;
50
+ }
51
+
52
+ // ── Screens ──
53
+
54
+ const SCREEN = {
55
+ SCOPE: "scope",
56
+ MAIN: "main",
57
+ ADD: "add",
58
+ CONFIRM_DELETE: "confirm_delete",
59
+ CONFIRM_CLEAR: "confirm_clear",
60
+ };
61
+
62
+ function Header({ scope, filePath }) {
63
+ return React.createElement(
64
+ Box,
65
+ { flexDirection: "column", marginBottom: 1 },
66
+ React.createElement(
67
+ Text,
68
+ { bold: true, color: "cyan" },
69
+ "\u2588\u2588\u2588 spinnerverb"
70
+ ),
71
+ React.createElement(
72
+ Text,
73
+ { dimColor: true },
74
+ `Scope: ${scope === "global" ? "Global (~/.claude)" : "Project (./.claude)"} \u2502 ${filePath}`
75
+ )
76
+ );
77
+ }
78
+
79
+ function ScopeScreen() {
80
+ const { exit } = useApp();
81
+ const [selected, setSelected] = useState(0);
82
+
83
+ const globalExists = fs.existsSync(GLOBAL_SETTINGS);
84
+ const localExists = fs.existsSync(LOCAL_SETTINGS);
85
+ const localLocalExists = fs.existsSync(LOCAL_SETTINGS_LOCAL);
86
+
87
+ const options = [
88
+ {
89
+ label: "Global",
90
+ desc: `~/.claude/settings.json ${globalExists ? "(exists)" : "(will create)"}`,
91
+ value: "global",
92
+ file: GLOBAL_SETTINGS,
93
+ },
94
+ {
95
+ label: "Project",
96
+ desc: `.claude/settings.json ${localExists ? "(exists)" : "(will create)"}`,
97
+ value: "local",
98
+ file: LOCAL_SETTINGS,
99
+ },
100
+ {
101
+ label: "Project (local)",
102
+ desc: `.claude/settings.local.json ${localLocalExists ? "(exists)" : "(will create)"}`,
103
+ value: "local-local",
104
+ file: LOCAL_SETTINGS_LOCAL,
105
+ },
106
+ ];
107
+
108
+ useInput((input, key) => {
109
+ if (key.upArrow || input === "k")
110
+ setSelected((s) => (s - 1 + options.length) % options.length);
111
+ if (key.downArrow || input === "j")
112
+ setSelected((s) => (s + 1) % options.length);
113
+ if (key.return) {
114
+ const opt = options[selected];
115
+ renderApp(opt.value, opt.file);
116
+ }
117
+ if (input === "q" || key.escape) exit();
118
+ });
119
+
120
+ return React.createElement(
121
+ Box,
122
+ { flexDirection: "column" },
123
+ React.createElement(
124
+ Box,
125
+ { marginBottom: 1, flexDirection: "column" },
126
+ React.createElement(
127
+ Text,
128
+ { bold: true, color: "cyan" },
129
+ "\u2588\u2588\u2588 spinnerverb"
130
+ ),
131
+ React.createElement(
132
+ Text,
133
+ { dimColor: true },
134
+ "Choose which settings file to edit"
135
+ )
136
+ ),
137
+ ...options.map((opt, i) =>
138
+ React.createElement(
139
+ Box,
140
+ { key: opt.value },
141
+ React.createElement(
142
+ Text,
143
+ {
144
+ color: i === selected ? "cyan" : undefined,
145
+ bold: i === selected,
146
+ },
147
+ `${i === selected ? "\u25b6 " : " "}${opt.label}`
148
+ ),
149
+ React.createElement(Text, { dimColor: true }, ` ${opt.desc}`)
150
+ )
151
+ ),
152
+ React.createElement(
153
+ Box,
154
+ { marginTop: 1 },
155
+ React.createElement(
156
+ Text,
157
+ { dimColor: true },
158
+ "\u2191\u2193/jk navigate enter select q quit"
159
+ )
160
+ )
161
+ );
162
+ }
163
+
164
+ function MainApp({ scope, filePath }) {
165
+ const { exit } = useApp();
166
+ const [settings, setSettings] = useState(() => readSettings(filePath));
167
+ const [screen, setScreen] = useState(SCREEN.MAIN);
168
+ const [cursor, setCursor] = useState(0);
169
+ const [newVerb, setNewVerb] = useState("");
170
+ const [deleteIdx, setDeleteIdx] = useState(-1);
171
+ const [saved, setSaved] = useState(false);
172
+ const [editIdx, setEditIdx] = useState(-1);
173
+ const [editValue, setEditValue] = useState("");
174
+
175
+ const sv = getSpinnerVerbs(settings);
176
+ const mode = sv?.mode || "append";
177
+ const verbs = sv?.verbs || [];
178
+
179
+ function persist(newSettings) {
180
+ setSettings(newSettings);
181
+ writeSettings(filePath, newSettings);
182
+ setSaved(true);
183
+ setTimeout(() => setSaved(false), 1500);
184
+ }
185
+
186
+ function updateSpinnerVerbs(newMode, newVerbs) {
187
+ const updated = { ...settings };
188
+ if (newVerbs.length === 0) {
189
+ delete updated.spinnerVerbs;
190
+ } else {
191
+ updated.spinnerVerbs = { mode: newMode, verbs: newVerbs };
192
+ }
193
+ persist(updated);
194
+ }
195
+
196
+ function toggleMode() {
197
+ const newMode = mode === "append" ? "replace" : "append";
198
+ updateSpinnerVerbs(newMode, verbs);
199
+ }
200
+
201
+ // ── Main screen ──
202
+ if (screen === SCREEN.MAIN) {
203
+ const menuItems = [
204
+ { type: "toggle", label: `Mode: ${mode}` },
205
+ { type: "separator" },
206
+ ...verbs.map((v, i) => ({ type: "verb", label: v, index: i })),
207
+ { type: "separator" },
208
+ { type: "add", label: "+ Add verb" },
209
+ { type: "clear", label: "Clear all verbs" },
210
+ ];
211
+
212
+ // Filter separators for cleanliness if no verbs
213
+ const items =
214
+ verbs.length === 0
215
+ ? menuItems.filter(
216
+ (m) => m.type !== "separator" && m.type !== "clear"
217
+ )
218
+ : menuItems;
219
+
220
+ useInput((input, key) => {
221
+ if (screen !== SCREEN.MAIN) return;
222
+ if (key.upArrow || input === "k")
223
+ setCursor((c) => {
224
+ let next = (c - 1 + items.length) % items.length;
225
+ while (items[next].type === "separator")
226
+ next = (next - 1 + items.length) % items.length;
227
+ return next;
228
+ });
229
+ if (key.downArrow || input === "j")
230
+ setCursor((c) => {
231
+ let next = (c + 1) % items.length;
232
+ while (items[next].type === "separator")
233
+ next = (next + 1) % items.length;
234
+ return next;
235
+ });
236
+ if (key.return || input === " ") {
237
+ const item = items[cursor];
238
+ if (item.type === "toggle") toggleMode();
239
+ if (item.type === "add") {
240
+ setNewVerb("");
241
+ setScreen(SCREEN.ADD);
242
+ }
243
+ if (item.type === "clear") setScreen(SCREEN.CONFIRM_CLEAR);
244
+ if (item.type === "verb") {
245
+ setEditIdx(item.index);
246
+ setEditValue(item.label);
247
+ setScreen(SCREEN.ADD);
248
+ }
249
+ }
250
+ if (input === "d" || key.delete) {
251
+ const item = items[cursor];
252
+ if (item.type === "verb") {
253
+ setDeleteIdx(item.index);
254
+ setScreen(SCREEN.CONFIRM_DELETE);
255
+ }
256
+ }
257
+ if (input === "q" || key.escape) exit();
258
+ });
259
+
260
+ // Clamp cursor
261
+ useEffect(() => {
262
+ if (cursor >= items.length) setCursor(Math.max(0, items.length - 1));
263
+ if (items[cursor]?.type === "separator")
264
+ setCursor((c) => Math.min(c + 1, items.length - 1));
265
+ }, [items.length]);
266
+
267
+ return React.createElement(
268
+ Box,
269
+ { flexDirection: "column" },
270
+ React.createElement(Header, { scope, filePath }),
271
+
272
+ // Mode info box
273
+ React.createElement(
274
+ Box,
275
+ {
276
+ borderStyle: "round",
277
+ borderColor: mode === "replace" ? "yellow" : "green",
278
+ paddingX: 1,
279
+ marginBottom: 1,
280
+ },
281
+ React.createElement(
282
+ Text,
283
+ null,
284
+ mode === "append"
285
+ ? React.createElement(
286
+ React.Fragment,
287
+ null,
288
+ React.createElement(
289
+ Text,
290
+ { color: "green", bold: true },
291
+ "APPEND"
292
+ ),
293
+ React.createElement(
294
+ Text,
295
+ null,
296
+ ` \u2014 your verbs are added alongside the ${DEFAULT_VERBS.length} defaults`
297
+ )
298
+ )
299
+ : React.createElement(
300
+ React.Fragment,
301
+ null,
302
+ React.createElement(
303
+ Text,
304
+ { color: "yellow", bold: true },
305
+ "REPLACE"
306
+ ),
307
+ React.createElement(
308
+ Text,
309
+ null,
310
+ " \u2014 only your verbs are used, defaults are hidden"
311
+ )
312
+ )
313
+ )
314
+ ),
315
+
316
+ // Menu items
317
+ ...items.map((item, i) => {
318
+ if (item.type === "separator")
319
+ return React.createElement(
320
+ Text,
321
+ { key: `sep-${i}`, dimColor: true },
322
+ " \u2500\u2500\u2500"
323
+ );
324
+ const sel = i === cursor;
325
+ const pointer = sel ? "\u25b6 " : " ";
326
+ let color = undefined;
327
+ if (item.type === "add") color = "green";
328
+ if (item.type === "clear") color = "red";
329
+ if (item.type === "toggle") color = "cyan";
330
+ return React.createElement(
331
+ Text,
332
+ { key: `item-${i}`, bold: sel, color: sel ? color || "cyan" : color },
333
+ `${pointer}${item.label}`
334
+ );
335
+ }),
336
+
337
+ // Footer
338
+ React.createElement(
339
+ Box,
340
+ { marginTop: 1, flexDirection: "column" },
341
+ saved
342
+ ? React.createElement(Text, { color: "green" }, "\u2713 Saved!")
343
+ : null,
344
+ React.createElement(
345
+ Text,
346
+ { dimColor: true },
347
+ "\u2191\u2193/jk navigate enter select/edit d delete q quit"
348
+ )
349
+ )
350
+ );
351
+ }
352
+
353
+ // ── Add / Edit screen ──
354
+ if (screen === SCREEN.ADD) {
355
+ const isEdit = editIdx >= 0;
356
+
357
+ useInput((input, key) => {
358
+ if (screen !== SCREEN.ADD) return;
359
+ if (key.escape) {
360
+ setEditIdx(-1);
361
+ setEditValue("");
362
+ setScreen(SCREEN.MAIN);
363
+ }
364
+ });
365
+
366
+ return React.createElement(
367
+ Box,
368
+ { flexDirection: "column" },
369
+ React.createElement(Header, { scope, filePath }),
370
+ React.createElement(
371
+ Text,
372
+ { bold: true },
373
+ isEdit ? `Edit verb (was "${verbs[editIdx]}"):` : "Enter new verb:"
374
+ ),
375
+ React.createElement(
376
+ Box,
377
+ null,
378
+ React.createElement(Text, null, "> "),
379
+ React.createElement(TextInput, {
380
+ value: isEdit ? editValue : newVerb,
381
+ onChange: isEdit ? setEditValue : setNewVerb,
382
+ onSubmit: (val) => {
383
+ const trimmed = val.trim();
384
+ if (trimmed) {
385
+ const newVerbs = [...verbs];
386
+ if (isEdit) {
387
+ newVerbs[editIdx] = trimmed;
388
+ } else {
389
+ newVerbs.push(trimmed);
390
+ }
391
+ updateSpinnerVerbs(mode, newVerbs);
392
+ }
393
+ setEditIdx(-1);
394
+ setEditValue("");
395
+ setNewVerb("");
396
+ setScreen(SCREEN.MAIN);
397
+ },
398
+ })
399
+ ),
400
+ React.createElement(
401
+ Text,
402
+ { dimColor: true, marginTop: 1 },
403
+ "enter confirm esc cancel"
404
+ )
405
+ );
406
+ }
407
+
408
+ // ── Confirm delete ──
409
+ if (screen === SCREEN.CONFIRM_DELETE) {
410
+ useInput((input, key) => {
411
+ if (screen !== SCREEN.CONFIRM_DELETE) return;
412
+ if (input === "y" || input === "Y") {
413
+ const newVerbs = verbs.filter((_, i) => i !== deleteIdx);
414
+ updateSpinnerVerbs(mode, newVerbs);
415
+ setScreen(SCREEN.MAIN);
416
+ }
417
+ if (input === "n" || input === "N" || key.escape) setScreen(SCREEN.MAIN);
418
+ });
419
+
420
+ return React.createElement(
421
+ Box,
422
+ { flexDirection: "column" },
423
+ React.createElement(Header, { scope, filePath }),
424
+ React.createElement(
425
+ Text,
426
+ { color: "yellow" },
427
+ `Delete "${verbs[deleteIdx]}"? (y/n)`
428
+ )
429
+ );
430
+ }
431
+
432
+ // ── Confirm clear ──
433
+ if (screen === SCREEN.CONFIRM_CLEAR) {
434
+ useInput((input, key) => {
435
+ if (screen !== SCREEN.CONFIRM_CLEAR) return;
436
+ if (input === "y" || input === "Y") {
437
+ updateSpinnerVerbs(mode, []);
438
+ setCursor(0);
439
+ setScreen(SCREEN.MAIN);
440
+ }
441
+ if (input === "n" || input === "N" || key.escape) setScreen(SCREEN.MAIN);
442
+ });
443
+
444
+ return React.createElement(
445
+ Box,
446
+ { flexDirection: "column" },
447
+ React.createElement(Header, { scope, filePath }),
448
+ React.createElement(
449
+ Text,
450
+ { color: "red" },
451
+ `Remove all ${verbs.length} custom verbs? (y/n)`
452
+ )
453
+ );
454
+ }
455
+
456
+ return null;
457
+ }
458
+
459
+ // ── Entrypoint ──
460
+
461
+ let activeInstance = null;
462
+
463
+ function renderApp(scope, filePath) {
464
+ if (activeInstance) activeInstance.unmount();
465
+ activeInstance = render(
466
+ React.createElement(MainApp, { scope, filePath })
467
+ );
468
+ }
469
+
470
+ // Start with scope picker
471
+ activeInstance = render(React.createElement(ScopeScreen));
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "spinnerverb",
3
+ "version": "1.0.0",
4
+ "description": "Interactive TUI to customize Claude Code spinner verbs",
5
+ "bin": {
6
+ "spinnerverb": "./index.mjs"
7
+ },
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ideademic/spinnerverb.git"
13
+ },
14
+ "author": "Ideademic",
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "spinner",
19
+ "spinner-verbs",
20
+ "customization",
21
+ "tui",
22
+ "cli",
23
+ "anthropic",
24
+ "settings"
25
+ ],
26
+ "files": [
27
+ "index.mjs",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "ink": "^5.1.0",
36
+ "ink-text-input": "^6.0.0",
37
+ "react": "^18.3.1"
38
+ }
39
+ }