@synmux/claude-commit 1.0.0 → 1.0.1

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
@@ -151,6 +151,7 @@ keys are valid at every level:
151
151
  "interactive": true,
152
152
  "interactiveCount": 3,
153
153
  "interactiveTemperature": 1,
154
+ "spinner": "bouncingBall",
154
155
  "models": {
155
156
  "summary": "sonnet",
156
157
  "final": "sonnet"
@@ -162,6 +163,13 @@ keys are valid at every level:
162
163
  }
163
164
  ```
164
165
 
166
+ `spinner` chooses the progress animation: any name from the
167
+ [cli-spinners](https://github.com/sindresorhus/cli-spinners) set bundled with
168
+ [ora](https://github.com/sindresorhus/ora) (`"dots"`, `"moon"`, `"pong"`,
169
+ `"bouncingBall"`, ...). Unknown names are ignored and the default
170
+ `bouncingBall` is used. `--no-spinner` disables the animated spinner, but final
171
+ status lines still print.
172
+
165
173
  `maxChunkTokens` is a cap, not a promise: at run time it is clamped to the
166
174
  summary model's context window minus a fixed reserve (1M-window models such as
167
175
  current Sonnet/Opus keep the full budget; Haiku, older pinned model ids, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synmux/claude-commit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Generate git commit messages with Claude, using your Claude Code subscription.",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -24,6 +24,7 @@
24
24
  "interactive": false,
25
25
  "interactiveCount": 3,
26
26
  "interactiveTemperature": 1,
27
+ "spinner": "bouncingBall",
27
28
  "models": {
28
29
  "summary": "sonnet",
29
30
  "final": "sonnet"
@@ -45,7 +46,9 @@
45
46
  "dependencies": {
46
47
  "@anthropic-ai/claude-agent-sdk": "^0.3.218",
47
48
  "@opentui/core": "^0.4.5",
48
- "commander": "^15.0.0"
49
+ "cli-spinners": "^3.4.0",
50
+ "commander": "^15.0.0",
51
+ "ora": "^9.4.1"
49
52
  },
50
53
  "repository": {
51
54
  "type": "git",
package/src/cli.ts CHANGED
@@ -256,7 +256,7 @@ async function runNonInteractive(
256
256
  abortController: AbortController,
257
257
  ): Promise<number> {
258
258
  const useSpinner = opts.spinner !== false && process.stderr.isTTY;
259
- const spinner = new Spinner(useSpinner);
259
+ const spinner = new Spinner(useSpinner, config.spinner);
260
260
 
261
261
  spinner.start("Reading diff");
262
262
  let result;
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { dirname, isAbsolute, join, resolve } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { ClaudeCommitError } from "./errors";
13
+ import { DEFAULT_SPINNER, isSpinnerName } from "./ui/spinner";
13
14
  import type { Config, ModelConfig, PartialConfig } from "./types";
14
15
 
15
16
  export const DEFAULT_CONFIG: Config = {
@@ -21,6 +22,7 @@ export const DEFAULT_CONFIG: Config = {
21
22
  interactive: false,
22
23
  interactiveCount: 3,
23
24
  interactiveTemperature: 1,
25
+ spinner: DEFAULT_SPINNER,
24
26
  models: {
25
27
  summary: "sonnet",
26
28
  final: "sonnet",
@@ -116,6 +118,9 @@ export function sanitizePartial(raw: unknown): PartialConfig {
116
118
  Math.max(0, obj.interactiveTemperature),
117
119
  );
118
120
  }
121
+ if (typeof obj.spinner === "string" && isSpinnerName(obj.spinner)) {
122
+ out.spinner = obj.spinner;
123
+ }
119
124
  if (typeof obj.maxChunkTokens === "number" && obj.maxChunkTokens > 0) {
120
125
  out.maxChunkTokens = Math.floor(obj.maxChunkTokens);
121
126
  }
package/src/types.ts CHANGED
@@ -40,6 +40,12 @@ export interface Config {
40
40
  * at its default. Only applied in interactive mode.
41
41
  */
42
42
  interactiveTemperature: number | null;
43
+ /**
44
+ * Name of the progress spinner animation: any spinner from the cli-spinners
45
+ * set bundled with ora (e.g. `"dots"`, `"moon"`, `"bouncingBall"`). Unknown
46
+ * names are ignored and the default is used instead.
47
+ */
48
+ spinner: string;
43
49
  /** Models for each pipeline stage. */
44
50
  models: ModelConfig;
45
51
  /**
@@ -30,7 +30,7 @@ export async function runInteractive(
30
30
  opts: InteractiveOptions,
31
31
  ): Promise<number> {
32
32
  const count = Math.max(1, config.interactiveCount);
33
- const spinner = new Spinner(process.stderr.isTTY);
33
+ const spinner = new Spinner(process.stderr.isTTY, config.spinner);
34
34
  spinner.start(`Generating ${count} option${count === 1 ? "" : "s"}`);
35
35
  let result;
36
36
  try {
package/src/ui/spinner.ts CHANGED
@@ -1,48 +1,71 @@
1
1
  /**
2
- * A minimal, dependency-free progress spinner for non-interactive runs.
2
+ * The progress spinner for non-interactive runs, rendered by `ora`.
3
3
  *
4
- * It renders to stderr (so stdout stays clean for piping) and only animates when
5
- * stderr is a TTY; otherwise `start`/`update` are silent no-ops. This keeps the
6
- * read-only progress indicator out of the way of `cco --dry-run | git commit -F -`.
4
+ * It writes to stderr (so stdout stays clean for piping) and only animates when
5
+ * explicitly enabled: callers gate on `process.stderr.isTTY` and `--no-spinner`,
6
+ * and ora's own TTY/CI detection is bypassed so that decision lives in one
7
+ * place. When disabled, `start`/`update` are silent no-ops but final status
8
+ * lines (`succeed`/`fail`) still print. This keeps the progress indicator out
9
+ * of the way of `cco --dry-run | git commit -F -`.
10
+ *
11
+ * The animation is chosen by the `spinner` config key: any name from the
12
+ * cli-spinners set bundled with ora, defaulting to {@link DEFAULT_SPINNER}.
7
13
  */
14
+ import ora, { type Ora } from "ora";
15
+ import spinners, { type Spinner as SpinnerAnimation } from "cli-spinners";
8
16
  import { color } from "./colors";
9
17
 
10
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
- const INTERVAL_MS = 80;
18
+ /** The spinner used when none (or an unknown one) is configured. */
19
+ export const DEFAULT_SPINNER = "bouncingBall";
20
+
21
+ /** Whether `name` is one of the cli-spinners animations bundled with ora. */
22
+ export function isSpinnerName(name: string): boolean {
23
+ return Object.hasOwn(spinners, name);
24
+ }
25
+
26
+ /**
27
+ * Look up a spinner animation by name, falling back to {@link DEFAULT_SPINNER}
28
+ * for unknown names (ora itself throws on those, and a cosmetic option must
29
+ * never be able to break a commit).
30
+ */
31
+ export function resolveSpinner(name: string): SpinnerAnimation {
32
+ const known = (spinners as Record<string, SpinnerAnimation | undefined>)[
33
+ name
34
+ ];
35
+ return known ?? spinners[DEFAULT_SPINNER];
36
+ }
12
37
 
13
38
  export class Spinner {
14
- private timer: ReturnType<typeof setInterval> | null = null;
15
- private frame = 0;
16
- private label = "";
39
+ private instance: Ora | null = null;
17
40
  private readonly enabled: boolean;
41
+ private readonly animation: SpinnerAnimation;
18
42
 
19
- constructor(enabled = process.stderr.isTTY) {
43
+ constructor(enabled = process.stderr.isTTY, spinnerName = DEFAULT_SPINNER) {
20
44
  this.enabled = Boolean(enabled);
45
+ this.animation = resolveSpinner(spinnerName);
21
46
  }
22
47
 
23
48
  start(label: string): void {
24
- this.label = label;
25
49
  if (!this.enabled) return;
26
- this.stopTimer();
27
- process.stderr.write("\x1b[?25l"); // hide cursor
28
- this.render();
29
- this.timer = setInterval(() => {
30
- this.frame = (this.frame + 1) % FRAMES.length;
31
- this.render();
32
- }, INTERVAL_MS);
50
+ this.instance?.stop();
51
+ this.instance = ora({
52
+ text: label,
53
+ spinner: this.animation,
54
+ stream: process.stderr,
55
+ // The caller already decided (TTY check + --no-spinner); don't let ora's
56
+ // own TTY/CI detection silently disagree.
57
+ isEnabled: true,
58
+ }).start();
33
59
  }
34
60
 
35
61
  update(label: string): void {
36
- this.label = label;
37
- if (this.enabled && this.timer) this.render();
62
+ if (this.instance) this.instance.text = label;
38
63
  }
39
64
 
40
65
  /** Stop and clear the spinner line, optionally printing a final status line. */
41
66
  stop(finalLine?: string): void {
42
- this.stopTimer();
43
- if (this.enabled) {
44
- process.stderr.write("\r\x1b[2K\x1b[?25h"); // clear line, show cursor
45
- }
67
+ this.instance?.stop();
68
+ this.instance = null;
46
69
  if (finalLine !== undefined) process.stderr.write(finalLine + "\n");
47
70
  }
48
71
 
@@ -53,19 +76,4 @@ export class Spinner {
53
76
  fail(label: string): void {
54
77
  this.stop(`${color("31", "✖")} ${label}`);
55
78
  }
56
-
57
- private render(): void {
58
- // `\r\x1b[2K` (cursor return + clear line) only runs when enabled (a TTY);
59
- // the frame color additionally respects NO_COLOR via the helper.
60
- process.stderr.write(
61
- `\r\x1b[2K${color("36", FRAMES[this.frame]!)} ${this.label}`,
62
- );
63
- }
64
-
65
- private stopTimer(): void {
66
- if (this.timer) {
67
- clearInterval(this.timer);
68
- this.timer = null;
69
- }
70
- }
71
79
  }