gwqadd 0.2.0 → 0.3.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 (3) hide show
  1. package/README.md +34 -38
  2. package/bin/gwqadd.mjs +151 -160
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ Create a branch and its [gwq](https://github.com/d-kuro/gwq) worktree in the rep
16
16
  One command instead of: `git checkout -b` → realise you wanted a worktree →
17
17
  `gwq add -b` → find where it landed → `cd`.
18
18
 
19
- Run it with no branch name and it helps you pick one:
19
+ Run it with no branch name and it asks one question:
20
20
 
21
21
  ```console
22
22
  ~/ghq/github.com/you/api $ gwqadd
@@ -24,26 +24,16 @@ Run it with no branch name and it helps you pick one:
24
24
  │ repo api /Users/you/ghq/github.com/you/api
25
25
  │ base main 8f2c1a9
26
26
 
27
- type?
28
- 1) feat/ (12) 2) fix/ (7) 3) chore/ (2)
29
- │ 4) docs/ 5) refactor/ 6) test/
30
- │ 7) perf/
31
- │ 0) no prefix o) type your own
32
- │ > 1
27
+ what do you want to do? (any language)
28
+ > セッションが期限切れでも通ってしまう不具合を直す
33
29
 
34
- what are you doing? (any language — Enter alone to name it yourself)
35
- > ログイン画面で連打するとセッションが壊れる
36
-
37
- │ 1) feat/login-rapid-click-session
38
- │ 2) feat/login-double-submit-guard
39
- │ 3) feat/prevent-session-corruption
40
- │ e) type it yourself r) regenerate
41
- │ > 1
42
- └ ✓ feat/login-rapid-click-session → …
30
+ bugfix/expired-session-accepted off main
31
+ create it? [Y]es · [n]o, describe again · [e]dit the name
32
+ └ ✓ bugfix/expired-session-accepted → …
43
33
  ```
44
34
 
45
- The type list is built from the prefixes **this repository actually uses**, so a
46
- repo that says `feature/` is never offered `feat/`.
35
+ No type menu, no list to choose from. Note the prefix: this repository uses
36
+ `bugfix/`, not `fix/`, and the suggestion followed it without being told.
47
37
 
48
38
  ## Install
49
39
 
@@ -107,37 +97,43 @@ gwqadd feat/logout --from main
107
97
 
108
98
  ## Naming help
109
99
 
110
- Two layers, and the first needs no network at all.
100
+ One question, one confirmation:
101
+
102
+ | key | |
103
+ | --- | --- |
104
+ | `Y` / Enter | create it |
105
+ | `n` | describe the work again — the rejected name is excluded next time |
106
+ | `e` | edit the suggested name in place |
107
+ | Esc | give up, create nothing |
111
108
 
112
- **The type menu** is derived from `git for-each-ref` the prefixes this
113
- repository already uses come first, with their counts, and the Conventional
114
- Commits types fill out the rest. Pick `o` to type a prefix of your own, or `0`
115
- for none.
109
+ **Why the suggestions fit.** The prompt is not just your sentence. It carries:
116
110
 
117
- **The suggestions** come from whichever AI CLI is already on your `PATH`:
111
+ - this repository's branch prefixes with their counts, so the AI picks the one
112
+ you actually use — `feature/` over `feat/`, `bugfix/` over `fix/`;
113
+ - up to 20 existing branch names, for wording and length;
114
+ - the repository name and the ref being branched from;
115
+ - the paths you have already modified, if the working tree is dirty — often the
116
+ clearest signal about what the work is.
117
+
118
+ **The AI** is whichever of these is on your `PATH`:
118
119
 
119
120
  ```
120
121
  claude -p → codex exec → opencode run → gemini -p
121
122
  ```
122
123
 
123
- There is no API key to obtain and no account to create — it uses what you
124
- already have, and bills to whatever you already pay for. Expect 6–8 seconds,
125
- almost all of it the CLI's own start-up; an elapsed counter runs while it works.
126
-
127
- Because the prompt includes a sample of the repository's existing branch names,
128
- the suggestions match your house style rather than a generic template. Describe
129
- the work in any language; the names come back in ASCII.
124
+ No API key to obtain, no account to create — it uses what you already have.
125
+ Expect 6–8 seconds, almost all of it the CLI's own start-up; an elapsed counter
126
+ runs while it works.
130
127
 
131
128
  | | |
132
129
  | --- | --- |
133
130
  | pick a different CLI | `--ai 'gemini -p'`, or `GWQADD_AI='gemini -p'` |
134
- | turn it off | `--no-ai`, or `GWQADD_AI=off` |
135
- | skip it for one run | press Enter on an empty description |
131
+ | turn it off | `--no-ai`, or `GWQADD_AI=off` — leaves a plain ASCII-name prompt |
136
132
 
137
- **What is sent, and when.** Nothing leaves your machine until you type a
138
- description. At that point the description, the chosen prefix and up to 15
139
- existing branch names from the repository are passed to that CLI. A suggestion
140
- is never accepted for you — you always pick, edit or regenerate.
133
+ **What is sent, and when.** Nothing leaves your machine until you answer the
134
+ question. At that point the sentence, the repository name, its branch names and
135
+ your modified file *paths* (never contents) go to that CLI. Nothing is created
136
+ until you confirm.
141
137
 
142
138
  None of this happens when you pass a branch name on the command line, or when
143
139
  there is no terminal. Scripts and agents keep the plain, silent contract.
@@ -184,7 +180,7 @@ gwqadd [options] [<branch>]
184
180
  | `-h`, `--help` | show help |
185
181
  | `-V`, `--version` | show version |
186
182
 
187
- Run it with no branch name and it walks you through the naming help above.
183
+ Run it with no branch name and it asks what you want to do, then confirms once.
188
184
 
189
185
  ## For scripts and AI agents
190
186
 
package/bin/gwqadd.mjs CHANGED
@@ -42,20 +42,25 @@ EXAMPLES
42
42
  ${PKG} feat/login branch off HEAD, worktree, cd
43
43
  ${PKG} feat/login --from main branch off main wherever you happen to be
44
44
  ${PKG} hotfix/x --expires 1d gwq will mark it expired after a day
45
- ${PKG} picks a type, then helps you name it
45
+ ${PKG} describe the work, confirm, done
46
46
  ${PKG} -n --json feat/login machine-readable, shell stays put
47
47
 
48
48
  NAMING HELP
49
- Run ${PKG} with no branch name and it asks for a type (the list is built from
50
- the prefixes this repository actually uses), then for a one-line description
51
- in any language. Describe the work and an AI CLI turns it into three
52
- candidates that match the repo's existing branch style; press Enter on an
53
- empty description to skip that and type the name yourself.
49
+ Run ${PKG} with no branch name and it asks one question what you want to do,
50
+ in any language. An AI CLI turns that into a branch name and you confirm once:
51
+
52
+ Y create it n describe it again e edit the name
53
+
54
+ There is no type menu. The prompt carries this repository's own prefixes and
55
+ their counts, a sample of its branch names, and the paths you have already
56
+ modified, so the AI picks the prefix and the wording that match the repo. A
57
+ rejected suggestion is passed back as an exclusion, so "n" does not return it.
54
58
 
55
59
  The AI is whichever of these is on PATH — claude, codex, opencode, gemini —
56
- invoked headlessly. Nothing is sent anywhere until you type a description,
57
- and the suggestion is never accepted for you. Override with --ai '<cmd>' or
58
- GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off.
60
+ invoked headlessly. Nothing is sent anywhere until you answer the question,
61
+ and nothing is created until you confirm. Override with --ai '<cmd>' or
62
+ GWQADD_AI='<cmd>'; disable with --no-ai or GWQADD_AI=off, which leaves a plain
63
+ prompt for an ASCII name.
59
64
 
60
65
  None of this happens with a branch name on the command line, or without a
61
66
  terminal — scripts and agents keep the plain, silent contract.
@@ -466,12 +471,16 @@ async function confirmYesNo(question) {
466
471
  return c === 0x0d || c === 0x0a || c === 0x79 || c === 0x59; // Enter, y, Y
467
472
  }
468
473
 
469
- // A line, not a keypress — the branch name needs editing and history. The
470
- // prompt goes to stderr so stdout stays the path channel (I1).
471
- async function askLine(question) {
474
+ // A line, not a keypress — a description or a branch name needs editing. The
475
+ // prompt goes to stderr so stdout stays the path channel (I1). `initial`
476
+ // pre-fills the line so "edit this suggestion" starts from the suggestion
477
+ // rather than from an empty prompt.
478
+ async function askLine(question, initial = '') {
472
479
  const rl = createInterface({ input: process.stdin, output: stderr, terminal: true });
473
480
  try {
474
- return (await rl.question(question)).trim();
481
+ const answer = rl.question(question);
482
+ if (initial) rl.write(initial);
483
+ return (await answer).trim();
475
484
  } finally {
476
485
  rl.close();
477
486
  }
@@ -550,25 +559,22 @@ function defaultBranch(dir) {
550
559
  return head ? head.replace(/^refs\/remotes\/origin\//, '') : '';
551
560
  }
552
561
 
553
- // ── naming help (interactive only) ───────────────────────────────────────────
562
+ // ── naming (interactive only) ────────────────────────────────────────────────
554
563
 
555
- // Conventional Commits types, used to fill out the menu when the repository is
556
- // new or has no prefix habit of its own.
557
- const CONVENTIONAL = ['feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'perf'];
558
-
559
- // What this repository actually calls things. A repo that says `feature/` must
560
- // not be offered `feat/` first — house style beats the spec.
561
- function branchPrefixes(dir) {
562
- const out = gitOut(dir, [
564
+ // Everything about the repository worth telling a model that is choosing a
565
+ // branch name. The point of gathering it here is that the model picks the
566
+ // prefix too there is no menu, so the convention has to travel in the prompt.
567
+ function repoContext(dir, repo, base) {
568
+ const refs = gitOut(dir, [
563
569
  'for-each-ref', '--format=%(refname:short)', '--sort=-committerdate',
564
570
  '--count=300', 'refs/heads', 'refs/remotes/origin',
565
571
  ]);
566
572
  const names = new Set();
567
- for (let b of out.split('\n')) {
573
+ for (let b of refs.split('\n')) {
568
574
  b = b.trim();
569
575
  if (!b || b === 'HEAD' || b === 'origin/HEAD') continue;
570
576
  if (b.startsWith('origin/')) b = b.slice('origin/'.length);
571
- names.add(b); // dedupe: a branch present locally and on origin counts once
577
+ names.add(b); // a branch on both local and origin must count once
572
578
  }
573
579
  const counts = new Map();
574
580
  for (const b of names) {
@@ -577,55 +583,55 @@ function branchPrefixes(dir) {
577
583
  const p = b.slice(0, i);
578
584
  counts.set(p, (counts.get(p) ?? 0) + 1);
579
585
  }
580
- return { counts: [...counts.entries()].sort((a, b) => b[1] - a[1]), names: [...names] };
581
- }
582
-
583
- function typeMenu(prefixes) {
584
- const items = [];
585
- const seen = new Set();
586
- for (const [prefix, count] of prefixes) {
587
- if (items.length >= 6) break;
588
- seen.add(prefix);
589
- items.push({ prefix, count });
590
- }
591
- for (const prefix of CONVENTIONAL) {
592
- if (items.length >= 8) break;
593
- if (seen.has(prefix)) continue;
594
- seen.add(prefix);
595
- items.push({ prefix, count: 0 });
596
- }
597
- return items;
586
+ const prefixes = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
587
+
588
+ // Work already in progress is the strongest hint there is about what the
589
+ // branch is for. Paths only — never contents.
590
+ // Not gitOut(): it trims the whole output, which eats the leading space of
591
+ // porcelain's first line and takes the first character of that path with it.
592
+ // Every entry is `XY<space>path`, and a rename is `R <space>old -> new`.
593
+ const status = git(dir, ['status', '--porcelain', '--untracked-files=no']);
594
+ const dirty = (status.status === 0 ? status.stdout ?? '' : '')
595
+ .split('\n')
596
+ .filter((l) => l.length > 3)
597
+ .map((l) => l.slice(3))
598
+ .map((l) => (l.includes(' -> ') ? l.slice(l.indexOf(' -> ') + 4) : l))
599
+ .map((l) => l.replace(/^"(.*)"$/, '$1').trim()) // porcelain quotes odd paths
600
+ .filter(Boolean)
601
+ .slice(0, 20);
602
+
603
+ return {
604
+ name: repo.name,
605
+ base: base.ref,
606
+ prefixes,
607
+ examples: [...names].filter((n) => n.includes('/')).slice(0, 20),
608
+ dirty,
609
+ };
598
610
  }
599
611
 
600
- // Numbers, not mnemonic letters: `feat` and `fix` both want `f`, and the menu
601
- // is built from the repository so the entries are not known in advance.
602
- async function pickType(items) {
603
- log(`${dim('')}`);
604
- log(`${dim('│')} ${bold('type?')}`);
605
- const cols = [];
606
- for (const [i, it] of items.entries()) {
607
- const n = cyan(String(i + 1));
608
- const used = it.count ? dim(` (${it.count})`) : '';
609
- cols.push(`${n}) ${it.prefix}/${used}`);
610
- }
611
- for (let i = 0; i < cols.length; i += 3) {
612
- log(`${dim('│')} ${cols.slice(i, i + 3).join(' ')}`);
613
- }
614
- log(`${dim('│')} ${cyan('0')}) no prefix ${cyan('o')}) type your own`);
615
- stderr.write(`${dim('│')} ${dim('>')} `);
612
+ function namingPrompt(ctx, description, rejected) {
613
+ const conventions = ctx.prefixes.length
614
+ ? `This repository's branch prefixes, most used first — pick one of these unless the work clearly does not fit:\n${
615
+ ctx.prefixes.map(([p, n]) => ` ${p}/ (${n} branches)`).join('\n')}`
616
+ : 'This repository has no prefix convention yet. Use a Conventional Commits type: feat, fix, docs, refactor, test, chore or perf.';
616
617
 
617
- const buf = await waitForKey();
618
- stderr.write('\n');
619
- if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
620
- const c = buf[0];
621
- if (c === 0x30) return ''; // 0
622
- if (c === 0x6f || c === 0x4f) { // o / O
623
- const own = (await askLine(`${dim('│')} prefix (no slash): `)).replace(/\/+$/, '');
624
- return own;
625
- }
626
- const idx = c - 0x31; // 1..8
627
- if (idx >= 0 && idx < items.length) return items[idx].prefix;
628
- return items[0]?.prefix ?? '';
618
+ return [
619
+ 'You name git branches. Reply with exactly 3 candidate names, one per line.',
620
+ 'No numbering, no bullets, no quotes, no code fences, no explanation.',
621
+ `Repository: ${ctx.name}. The branch will be cut from: ${ctx.base}.`,
622
+ conventions,
623
+ ctx.examples.length
624
+ ? `Existing branch names — match their wording and length:\n${ctx.examples.map((e) => ` ${e}`).join('\n')}`
625
+ : '',
626
+ ctx.dirty.length
627
+ ? `Files already modified in the working tree, which is what the work touches:\n${ctx.dirty.map((f) => ` ${f}`).join('\n')}`
628
+ : '',
629
+ 'Include the prefix and a slash. After it use lowercase ASCII words joined by hyphens. Under 40 characters. Be specific about this change, not generic.',
630
+ rejected.length
631
+ ? `These were rejected — do not propose them or close variants:\n${rejected.map((r) => ` ${r}`).join('\n')}`
632
+ : '',
633
+ `The work to name, written in the author's own language:\n${description}`,
634
+ ].filter(Boolean).join('\n\n');
629
635
  }
630
636
 
631
637
  // ── the AI layer ─────────────────────────────────────────────────────────────
@@ -641,6 +647,9 @@ const AI_CLIS = [
641
647
  { bin: 'gemini', args: ['-p'] },
642
648
  ];
643
649
 
650
+ // GWQADD_AI may be an absolute path; only the command name is worth showing.
651
+ const aiLabel = (ai) => ai.bin.split('/').pop();
652
+
644
653
  function detectAi() {
645
654
  if (values['no-ai']) return null;
646
655
  const override = values.ai ?? process.env.GWQADD_AI;
@@ -653,21 +662,6 @@ function detectAi() {
653
662
  return AI_CLIS.find((c) => commandExists(c.bin)) ?? null;
654
663
  }
655
664
 
656
- function namingPrompt(prefix, description, examples) {
657
- return [
658
- 'You name git branches. Reply with exactly 3 candidate names, one per line.',
659
- 'No numbering, no bullets, no quotes, no code fences, no explanation.',
660
- prefix
661
- ? `Every candidate MUST begin with "${prefix}/".`
662
- : 'Do not add a type prefix.',
663
- 'After the prefix use lowercase ASCII words joined by hyphens. Keep each candidate under 40 characters. Be specific, not generic.',
664
- examples.length
665
- ? `Existing branches in this repository — match their style:\n${examples.join('\n')}`
666
- : '',
667
- `The work to name, written in the author's own language:\n${description}`,
668
- ].filter(Boolean).join('\n\n');
669
- }
670
-
671
665
  // spawnSync would freeze the terminal for the 6-8 seconds these CLIs take to
672
666
  // boot, with no sign of life. Async plus an elapsed counter is the difference
673
667
  // between "working" and "hung".
@@ -696,31 +690,33 @@ function runAi(ai, prompt) {
696
690
  }
697
691
 
698
692
  async function askAi(ai, prompt) {
699
- const label = `${ai.bin}${ai.args.length ? ' ' + ai.args.join(' ') : ''}`;
700
693
  const started = Date.now();
701
694
  let tick;
702
695
  if (stderrTTY) {
703
696
  tick = setInterval(() => {
704
697
  const s = Math.round((Date.now() - started) / 1000);
705
- stderr.write(`\r${dim('│')} asking ${cyan(ai.bin)} ${dim(`${s}s`)} `);
698
+ stderr.write(`\r${dim('│')} ${dim('thinking')} ${dim(`(${aiLabel(ai)}, ${s}s)`)} `);
706
699
  }, 250);
707
700
  } else {
708
- log(`${dim('│')} asking ${label}…`);
701
+ log(`${dim('│')} asking ${aiLabel(ai)}…`);
709
702
  }
710
703
  try {
711
704
  return await runAi(ai, prompt);
712
705
  } finally {
713
706
  if (tick) {
714
707
  clearInterval(tick);
715
- stderr.write(`\r${' '.repeat(40)}\r`);
708
+ stderr.write(`\r${' '.repeat(48)}\r`);
716
709
  }
717
710
  }
718
711
  }
719
712
 
713
+ const validBranchName = (name) =>
714
+ !!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
715
+
720
716
  // Models add bullets, backticks and commentary no matter how firmly asked not
721
717
  // to. Keep only things that could actually be branch names, and let git have
722
718
  // the final say on each one.
723
- function parseCandidates(text, prefix) {
719
+ function parseCandidates(text) {
724
720
  const cleaned = text.split('\n')
725
721
  .map((l) => l.trim())
726
722
  .map((l) => l.replace(/^[-*•]\s*/, ''))
@@ -730,109 +726,104 @@ function parseCandidates(text, prefix) {
730
726
  .filter((l) => !/\s/.test(l))
731
727
  .filter((l) => /^[A-Za-z0-9._/-]+$/.test(l));
732
728
 
733
- const take = (list) => {
734
- const seen = new Set();
735
- const out = [];
736
- for (const name of list) {
737
- if (seen.has(name) || !validBranchName(name)) continue;
738
- seen.add(name);
739
- out.push(name);
740
- if (out.length === 3) break;
741
- }
742
- return out;
743
- };
744
-
745
- if (!prefix) return take(cleaned);
746
- const onPrefix = take(cleaned.filter((l) => l.startsWith(`${prefix}/`)));
747
- if (onPrefix.length) return onPrefix;
748
- // The model ignored the prefix. Its words are still useful; the shape is ours.
749
- return take(cleaned.map((l) => `${prefix}/${l.replace(/^[^/]+\//, '')}`));
729
+ const seen = new Set();
730
+ const out = [];
731
+ for (const name of cleaned) {
732
+ if (seen.has(name) || !validBranchName(name)) continue;
733
+ seen.add(name);
734
+ out.push(name);
735
+ if (out.length === 3) break;
736
+ }
737
+ return out;
750
738
  }
751
739
 
752
- const validBranchName = (name) =>
753
- !!name && spawnSync('git', ['check-ref-format', '--branch', name], { stdio: 'ignore' }).status === 0;
754
-
755
- // Only ASCII is reshaped. A description in Japanese is what the AI is for, and
756
- // mangling it into hyphens here would produce a worse name than leaving it be.
757
740
  function slugify(s) {
758
- const t = s.trim();
759
- if (/[^\x00-\x7F]/.test(t)) return t.replace(/\s+/g, '-');
760
- return t.toLowerCase()
741
+ return s.trim().toLowerCase()
761
742
  .replace(/[\s_]+/g, '-')
762
743
  .replace(/[^a-z0-9./-]/g, '-')
763
744
  .replace(/-{2,}/g, '-')
764
745
  .replace(/(^[-/]+)|([-/]+$)/g, '');
765
746
  }
766
747
 
767
- const join = (prefix, rest) => (prefix ? `${prefix}/${rest}` : rest);
768
-
769
- async function typeItYourself(prefix) {
748
+ // This prompt is where the AI path lands when it is unavailable or declined, so
749
+ // a user who was mid-way through describing the work in their own language will
750
+ // type that language here. Slugifying it would silently create a branch named
751
+ // in Japanese; git allows it, but no CI, URL or tab-completion wants it. Say so
752
+ // and point at the two things that do work.
753
+ async function typeItYourself(initial = '') {
770
754
  for (;;) {
771
- const raw = await askLine(`${dim('│')} ${prefix ? `${prefix}/` : 'branch: '}`);
755
+ const raw = await askLine(`${dim('│')} branch name ${dim('(ascii)')}: `, initial);
772
756
  if (!raw) die('E_INTERRUPTED', 'cancelled');
773
- const name = join(prefix, slugify(raw));
757
+ if (/[^\x00-\x7F]/.test(raw)) {
758
+ warn('this prompt takes an ASCII name. Describe the work at the previous prompt to have it translated, or pass a name as an argument to use it verbatim.');
759
+ initial = '';
760
+ continue;
761
+ }
762
+ const name = slugify(raw);
774
763
  if (validBranchName(name)) return name;
775
764
  warn(`'${name}' is not a valid branch name — try again`);
765
+ initial = raw;
776
766
  }
777
767
  }
778
768
 
779
- async function chooseFromCandidates(candidates, prefix) {
769
+ // The single checkpoint before anything is created. `n` sends the user back to
770
+ // describing the work, which is what they asked for; `e` is there because a
771
+ // suggestion that is one word off should not cost another round trip.
772
+ async function confirmCreate(name, base) {
773
+ log(`${dim('│')}`);
774
+ log(`${dim('│')} ${bold(cyan(name))} ${dim(`off ${base}`)}`);
775
+ stderr.write(
776
+ `${dim('│')} create it? ${dim('[Y]es')} ${dim('·')} ${dim('[n]o, describe again')} ` +
777
+ `${dim('·')} ${dim('[e]dit the name')} `,
778
+ );
780
779
  for (;;) {
781
- log(`${dim('│')}`);
782
- for (const [i, c] of candidates.entries()) {
783
- log(`${dim('│')} ${cyan(String(i + 1))}) ${c}`);
784
- }
785
- log(`${dim('│')} ${cyan('e')}) type it yourself ${cyan('r')}) regenerate`);
786
- stderr.write(`${dim('│')} ${dim('>')} `);
787
780
  const buf = await waitForKey();
788
- stderr.write('\n');
789
- if (buf.includes(0x03) || buf[0] === 0x1b) die('E_INTERRUPTED', 'cancelled');
781
+ if (buf.includes(0x03) || buf[0] === 0x1b) { stderr.write('\n'); die('E_INTERRUPTED', 'cancelled'); }
790
782
  const c = buf[0];
791
- if (c === 0x65 || c === 0x45) return { pick: await typeItYourself(prefix) };
792
- if (c === 0x72 || c === 0x52) return { regenerate: true };
793
- const idx = c - 0x31;
794
- if (idx >= 0 && idx < candidates.length) return { pick: candidates[idx] };
795
- // Enter, or anything unrecognised, takes the first suggestion.
796
- if (c === 0x0d || c === 0x0a) return { pick: candidates[0] };
783
+ if (c === 0x79 || c === 0x59 || c === 0x0d || c === 0x0a) { stderr.write('\n'); return { create: true }; }
784
+ if (c === 0x6e || c === 0x4e) { stderr.write('\n'); return { again: true }; }
785
+ if (c === 0x65 || c === 0x45) { stderr.write('\n'); return { edit: true }; }
797
786
  }
798
787
  }
799
788
 
800
- // The whole interactive path. Returns a branch name that git has already
801
- // accepted; never runs unless there is a terminal and no positional was given.
802
- async function composeBranchName(dir) {
803
- const { counts, names } = branchPrefixes(dir);
804
- const prefix = await pickType(typeMenu(counts));
805
-
789
+ // The whole interactive path. Returns a branch name git has already accepted;
790
+ // never runs unless there is a terminal and no positional was given.
791
+ async function composeBranchName(dir, repo, base) {
806
792
  const ai = detectAi();
807
- const hint = ai
808
- ? `${dim('(any language — Enter alone to name it yourself)')}`
809
- : `${dim('(Enter alone to name it yourself)')}`;
810
- log(`${dim('│')}`);
811
- const description = ai
812
- ? await askLine(`${dim('│')} what are you doing? ${hint}\n${dim('│')} ${dim('>')} `)
813
- : '';
793
+ if (!ai) return typeItYourself();
814
794
 
815
- if (!description) return typeItYourself(prefix);
795
+ const ctx = repoContext(dir, repo, base);
796
+ const rejected = [];
816
797
 
817
- const examples = names.filter((n) => n.includes('/')).slice(0, 15);
818
798
  for (;;) {
819
- const res = await askAi(ai, namingPrompt(prefix, description, examples));
799
+ log(`${dim('│')}`);
800
+ const description = await askLine(
801
+ `${dim('│')} what do you want to do? ${dim('(any language)')}\n${dim('│')} ${dim('>')} `,
802
+ );
803
+ // An empty answer is the escape hatch out of the AI entirely.
804
+ if (!description) return typeItYourself();
805
+
806
+ const res = await askAi(ai, namingPrompt(ctx, description, rejected));
820
807
  if (!res.ok) {
821
- warn(`${ai.bin} failed — naming it yourself instead`);
808
+ warn(`${aiLabel(ai)} failed — name it yourself instead`);
822
809
  const first = (res.err || '').trim().split('\n')[0];
823
810
  if (first) log(`${dim('│')} ${dim(first.slice(0, 120))}`);
824
- return typeItYourself(prefix);
811
+ return typeItYourself();
825
812
  }
826
- const candidates = parseCandidates(res.out, prefix);
813
+ const candidates = parseCandidates(res.out);
827
814
  if (candidates.length === 0) {
828
- warn(`${ai.bin} returned nothing usable — naming it yourself instead`);
829
- return typeItYourself(prefix);
815
+ warn(`${aiLabel(ai)} returned nothing usable — name it yourself instead`);
816
+ return typeItYourself();
830
817
  }
831
- const choice = await chooseFromCandidates(candidates, prefix);
832
- if (choice.pick) return choice.pick;
818
+
819
+ const choice = await confirmCreate(candidates[0], ctx.base);
820
+ if (choice.create) return candidates[0];
821
+ if (choice.edit) return typeItYourself(candidates[0]);
822
+ // Rejected: remember every suggestion from this round so the next prompt
823
+ // cannot come back with a near-identical name.
824
+ rejected.push(...candidates);
833
825
  }
834
826
  }
835
-
836
827
  // ── width / box ──────────────────────────────────────────────────────────────
837
828
 
838
829
  // Rough East Asian Width: 全角 CJK + 全角ラテン + half-symbols treated as wide.
@@ -974,7 +965,7 @@ async function main() {
974
965
  die('E_VALIDATION', 'a branch name is required — `gwqadd <branch>`');
975
966
  }
976
967
  // composeBranchName only ever returns a name git has already accepted.
977
- branch = await composeBranchName(cwd);
968
+ branch = await composeBranchName(cwd, repo, base);
978
969
  }
979
970
 
980
971
  const branchExisted = hasLocalBranch(cwd, branch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gwqadd",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Create a branch and its gwq worktree in the repository you are in, and cd there.",
5
5
  "type": "module",
6
6
  "bin": {