ultrahope 0.1.4 → 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
@@ -41,16 +41,49 @@ git diff --staged | ultrahope translate --target vcs-commit-message --models mis
41
41
 
42
42
  ### Guide context for commit/message generation
43
43
 
44
- `git ultrahope commit` `ultrahope jj describe` では `--guide <text>` を使って、差分だけでは分からない生成意図を補足できます。
44
+ In `git ultrahope commit` and `ultrahope jj describe`, you can use `--guide <text>` to provide intent that is not obvious from the diff alone.
45
45
 
46
46
  ```bash
47
- # git commit の生成補足
47
+ # Additional guidance for git commit generation
48
48
  git add -A && git ultrahope commit --guide "GHSA-gq3j-xvxp-8hrf: override reason"
49
49
 
50
- # jj describe の生成補足
50
+ # Additional guidance for jj describe generation
51
51
  jj ultrahope describe --guide "GHSA-gq3j-xvxp-8hrf: override reason"
52
52
  ```
53
53
 
54
+ If you run `git ultrahope commit` with no staged files, interactive mode now asks whether to stage all changes:
55
+
56
+ ```bash
57
+ # With staged changes:
58
+ git add packages/cli/commands/commit.ts
59
+ git ultrahope commit
60
+
61
+ # Without staged changes:
62
+ git ultrahope commit
63
+ # prompts: No staged changes. Stage all files with `git add -A` and continue? (y/N)
64
+ ```
65
+
66
+ If you answer `y`, Ultrahope runs `git add -A` and continues with staged changes.
67
+ If you answer `n` (or leave the default), it exits with the existing staged-changes error.
68
+ In `--no-interactive` mode, no prompt is shown and it exits immediately when no staged changes exist.
69
+
70
+ In interactive mode for `git ultrahope commit`, `ultrahope jj describe`, and `ultrahope translate --target vcs-commit-message`, use `r` to reroll and `R` (Shift+r) to reroll with additional instructions.
71
+
72
+ #### Difference Between `guide` And Refine Instructions
73
+
74
+ - `--guide`:
75
+ - Supplemental intent outside the diff (for example: ticket ID, background, change intent)
76
+ - `R refine`:
77
+ - Review generated results and enter inline instructions for the next reroll
78
+ - Examples: "more formal", "shorter"
79
+ - Press `Enter` with empty input to clear the previous refine instructions
80
+ - If specified multiple times, the last one overwrites previous values
81
+ - `R` means reroll with additional instructions (`refine`)
82
+ - At request time, refine instructions are merged into `guide` and sent to the API:
83
+ - `--guide` only: `guide = "<guide>"`
84
+ - `R refine` only: `guide = "<refine>"`
85
+ - both: `guide = "<guide>\n\nRefinement: <refine>"`
86
+
54
87
  #### Targets
55
88
 
56
89
  - `vcs-commit-message` - Generate a commit message
@@ -2,6 +2,9 @@
2
2
 
3
3
  // commands/commit.ts
4
4
  import { execSync as execSync2 } from "child_process";
5
+ import * as fs3 from "fs";
6
+ import * as readline4 from "readline";
7
+ import * as tty3 from "tty";
5
8
 
6
9
  // lib/api-client.ts
7
10
  import createClient from "openapi-fetch";
@@ -921,7 +924,19 @@ async function* generateCommitMessages(options) {
921
924
  try {
922
925
  if (signal?.aborted) return;
923
926
  let lastCommitMessage = "";
927
+ const generationStartedAtMs = Date.now();
928
+ let lastCommitMessageAtMs;
929
+ let providerMetadataAtMs;
924
930
  let providerMetadata;
931
+ const parseEventAtMs = (event) => {
932
+ if (typeof event.atMs !== "number" || !Number.isFinite(event.atMs)) {
933
+ return void 0;
934
+ }
935
+ if (event.atMs < 0) {
936
+ return 0;
937
+ }
938
+ return Math.round(event.atMs);
939
+ };
925
940
  for await (const event of generateWithRetry({
926
941
  cliSessionId: requiredCliSessionId,
927
942
  input: diff,
@@ -930,6 +945,7 @@ async function* generateCommitMessages(options) {
930
945
  })) {
931
946
  if (event.type === "commit-message") {
932
947
  lastCommitMessage = event.commitMessage;
948
+ lastCommitMessageAtMs = parseEventAtMs(event) ?? lastCommitMessageAtMs;
933
949
  if (useStream) {
934
950
  yield {
935
951
  content: lastCommitMessage,
@@ -940,6 +956,7 @@ async function* generateCommitMessages(options) {
940
956
  };
941
957
  }
942
958
  } else if (event.type === "provider-metadata") {
959
+ providerMetadataAtMs = parseEventAtMs(event) ?? providerMetadataAtMs;
943
960
  providerMetadata = event.providerMetadata;
944
961
  } else if (event.type === "error") {
945
962
  throw new Error(event.message);
@@ -947,11 +964,13 @@ async function* generateCommitMessages(options) {
947
964
  }
948
965
  if (lastCommitMessage) {
949
966
  const { generationId, cost } = extractGatewayMetadata(providerMetadata);
967
+ const generationMs = (providerMetadataAtMs ?? lastCommitMessageAtMs) != null ? providerMetadataAtMs ?? lastCommitMessageAtMs : Math.max(0, Date.now() - generationStartedAtMs);
950
968
  yield {
951
969
  content: lastCommitMessage,
952
970
  slotId: model,
953
971
  model,
954
972
  cost,
973
+ generationMs,
955
974
  generationId,
956
975
  ...useStream ? { isPartial: false } : {},
957
976
  slotIndex
@@ -1141,7 +1160,7 @@ import {
1141
1160
  mkdtempSync,
1142
1161
  openSync as openSync2,
1143
1162
  readFileSync as readFileSync2,
1144
- unlinkSync,
1163
+ rmSync,
1145
1164
  writeFileSync
1146
1165
  } from "fs";
1147
1166
  import { tmpdir } from "os";
@@ -1275,7 +1294,17 @@ function formatSlot(slot, selected) {
1275
1294
  }
1276
1295
  const candidate = slot.candidate;
1277
1296
  const title = candidate.content.split("\n")[0]?.trim() || "";
1278
- const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
1297
+ const formatDuration = (ms) => {
1298
+ const safeMs = Math.max(0, Math.round(ms));
1299
+ if (safeMs < 1e3) {
1300
+ return `${safeMs}ms`;
1301
+ }
1302
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1303
+ return `${seconds}s`;
1304
+ };
1305
+ const formattedModel = candidate.model ? formatModelName(candidate.model) : "";
1306
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1307
+ const modelInfo = candidate.model ? candidate.cost ? `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}` : `${formattedModel}${formattedDuration}` : "";
1279
1308
  if (selected) {
1280
1309
  const radio2 = "\u25CF";
1281
1310
  const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
@@ -1287,7 +1316,7 @@ function formatSlot(slot, selected) {
1287
1316
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1288
1317
  return meta ? [line, meta] : [line];
1289
1318
  }
1290
- function renderSelector(state, nowMs, renderer) {
1319
+ function renderSelector(state, nowMs, renderer, editedSelections) {
1291
1320
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
1292
1321
  const lines = [];
1293
1322
  const readyCount = getReadyCount(slots);
@@ -1304,10 +1333,18 @@ function renderSelector(state, nowMs, renderer) {
1304
1333
  const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1305
1334
  lines.push(ui.success(label));
1306
1335
  }
1336
+ const selectedSlot = slots[selectedIndex];
1337
+ const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1307
1338
  const hasReady = readyCount > 0;
1308
1339
  if (hasReady) {
1309
- const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
1310
- lines.push(ui.prompt(`Select a commit message ${hint}`));
1340
+ if (isEditedSelection) {
1341
+ lines.push(ui.success("Select a commit message"));
1342
+ } else {
1343
+ const hint = ui.hint(
1344
+ "\u2191\u2193 navigate \u23CE confirm e edit r reroll R refine q quit"
1345
+ );
1346
+ lines.push(ui.prompt(`Select a commit message ${hint}`));
1347
+ }
1311
1348
  } else {
1312
1349
  lines.push(ui.hint(" q quit"));
1313
1350
  }
@@ -1321,6 +1358,14 @@ function renderSelector(state, nowMs, renderer) {
1321
1358
  lines.push("");
1322
1359
  }
1323
1360
  }
1361
+ if (selectedSlot?.status === "ready") {
1362
+ const edited = editedSelections?.get(selectedSlot.candidate.slotId);
1363
+ if (edited) {
1364
+ const editedSummary = edited.split("\n")[0]?.slice(0, 120);
1365
+ lines.push(ui.success(`Edited: ${editedSummary}`));
1366
+ lines.push("");
1367
+ }
1368
+ }
1324
1369
  renderer.render(`${lines.join("\n")}
1325
1370
  `);
1326
1371
  }
@@ -1342,25 +1387,40 @@ function openEditor(content) {
1342
1387
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1343
1388
  const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1344
1389
  const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
1345
- writeFileSync(tmpFile, content);
1346
- const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1347
- child.on("close", (code) => {
1348
- if (code !== 0) {
1349
- unlinkSync(tmpFile);
1350
- reject(new Error(`Editor exited with code ${code}`));
1351
- return;
1352
- }
1353
- const result = readFileSync2(tmpFile, "utf-8").trim();
1354
- unlinkSync(tmpFile);
1355
- resolve3(result);
1356
- });
1357
- child.on("error", (err) => {
1390
+ let cleanupDone = false;
1391
+ const cleanupTempArtifacts = () => {
1392
+ if (cleanupDone) return;
1393
+ cleanupDone = true;
1358
1394
  try {
1359
- unlinkSync(tmpFile);
1395
+ rmSync(tmpDir, { recursive: true, force: true });
1360
1396
  } catch {
1361
1397
  }
1398
+ };
1399
+ try {
1400
+ writeFileSync(tmpFile, content);
1401
+ const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1402
+ child.on("close", (code) => {
1403
+ try {
1404
+ if (code !== 0) {
1405
+ reject(new Error(`Editor exited with code ${code}`));
1406
+ return;
1407
+ }
1408
+ const result = readFileSync2(tmpFile, "utf-8").trim();
1409
+ resolve3(result);
1410
+ } catch (err) {
1411
+ reject(err);
1412
+ } finally {
1413
+ cleanupTempArtifacts();
1414
+ }
1415
+ });
1416
+ child.on("error", (err) => {
1417
+ cleanupTempArtifacts();
1418
+ reject(err);
1419
+ });
1420
+ } catch (err) {
1421
+ cleanupTempArtifacts();
1362
1422
  reject(err);
1363
- });
1423
+ }
1364
1424
  });
1365
1425
  }
1366
1426
  async function selectCandidate(options) {
@@ -1380,8 +1440,12 @@ async function selectCandidate(options) {
1380
1440
  let ttyOutput = null;
1381
1441
  try {
1382
1442
  accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
1383
- const inputFd = openSync2(TTY_PATH, "r");
1384
- ttyInput = new tty2.ReadStream(inputFd);
1443
+ if (process.stdin.isTTY) {
1444
+ ttyInput = process.stdin;
1445
+ } else {
1446
+ const inputFd = openSync2(TTY_PATH, "r");
1447
+ ttyInput = new tty2.ReadStream(inputFd);
1448
+ }
1385
1449
  if (process.stdout.isTTY) {
1386
1450
  ttyOutput = process.stdout;
1387
1451
  } else {
@@ -1420,6 +1484,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1420
1484
  resolve3(result);
1421
1485
  };
1422
1486
  const slots = [...initialSlots];
1487
+ const editedSelections = /* @__PURE__ */ new Map();
1423
1488
  const state = {
1424
1489
  slots,
1425
1490
  selectedIndex: 0,
@@ -1428,19 +1493,21 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1428
1493
  };
1429
1494
  let renderInterval = null;
1430
1495
  let cleanedUp = false;
1496
+ let isEditorOpen = false;
1431
1497
  const ttyInput = ttyIo.input;
1432
1498
  const ttyOutput = ttyIo.output;
1433
1499
  const renderer = createRenderer(ttyOutput);
1434
- const rl = readline3.createInterface({
1435
- input: ttyInput,
1436
- output: ttyOutput,
1437
- terminal: true
1438
- });
1439
- readline3.emitKeypressEvents(ttyInput, rl);
1500
+ readline3.emitKeypressEvents(ttyInput);
1440
1501
  ttyInput.setRawMode(true);
1502
+ const setRawModeSafe = (enabled) => {
1503
+ try {
1504
+ ttyInput.setRawMode(enabled);
1505
+ } catch {
1506
+ }
1507
+ };
1441
1508
  const doRender = () => {
1442
- if (!cleanedUp) {
1443
- renderSelector(state, Date.now(), renderer);
1509
+ if (!cleanedUp && !isEditorOpen) {
1510
+ renderSelector(state, Date.now(), renderer, editedSelections);
1444
1511
  }
1445
1512
  };
1446
1513
  const updateState = (update) => {
@@ -1470,22 +1537,36 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1470
1537
  if (clearOutput) {
1471
1538
  renderer.clearAll();
1472
1539
  }
1473
- ttyInput.setRawMode(false);
1474
- rl.close();
1475
- ttyInput.destroy();
1476
- ttyOutput.destroy();
1540
+ ttyInput.removeAllListeners("keypress");
1541
+ setRawModeSafe(false);
1542
+ ttyInput.pause();
1543
+ if (ttyInput !== process.stdin && !ttyInput.destroyed) {
1544
+ ttyInput.destroy();
1545
+ }
1546
+ if (ttyOutput !== process.stdout && ttyOutput !== process.stderr && !ttyOutput.destroyed) {
1547
+ ttyOutput.destroy();
1548
+ }
1477
1549
  };
1478
1550
  const nextCandidate = async (iterator) => {
1551
+ if (!asyncCtx?.abortController) {
1552
+ return iterator.next();
1553
+ }
1554
+ const signal = asyncCtx.abortController.signal;
1555
+ if (signal.aborted) {
1556
+ return { done: true, value: void 0 };
1557
+ }
1558
+ let cleanup2 = () => {
1559
+ };
1479
1560
  const abortPromise = new Promise(
1480
1561
  (resolve4) => {
1481
- asyncCtx?.abortController.signal.addEventListener(
1482
- "abort",
1483
- () => resolve4({ done: true, value: void 0 }),
1484
- { once: true }
1485
- );
1562
+ const onAbort = () => resolve4({ done: true, value: void 0 });
1563
+ signal.addEventListener("abort", onAbort);
1564
+ cleanup2 = () => signal.removeEventListener("abort", onAbort);
1486
1565
  }
1487
1566
  );
1488
- return Promise.race([iterator.next(), abortPromise]);
1567
+ return Promise.race([iterator.next(), abortPromise]).finally(() => {
1568
+ cleanup2();
1569
+ });
1489
1570
  };
1490
1571
  const finalizeGeneration = () => {
1491
1572
  collapseToReady(slots);
@@ -1544,21 +1625,22 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1544
1625
  }
1545
1626
  })();
1546
1627
  }
1547
- const confirmSelection = () => {
1628
+ const confirmSelection = (clearOutput = true) => {
1548
1629
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1549
1630
  if (!candidate) return;
1631
+ const selectedContent = editedSelections.get(candidate.slotId) ?? candidate.content;
1550
1632
  cancelGeneration();
1551
1633
  const totalCost = getTotalCost(slots);
1552
1634
  const quota = getLatestQuota(slots);
1553
1635
  resolveOnce({
1554
1636
  action: "confirm",
1555
- selected: candidate.content,
1637
+ selected: selectedContent,
1556
1638
  selectedIndex: state.selectedIndex,
1557
1639
  selectedCandidate: candidate,
1558
1640
  totalCost: totalCost > 0 ? totalCost : void 0,
1559
1641
  quota
1560
1642
  });
1561
- cleanup();
1643
+ cleanup(clearOutput);
1562
1644
  };
1563
1645
  const rerollSelection = () => {
1564
1646
  if (!hasReadySlot(slots)) return;
@@ -1574,23 +1656,68 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1574
1656
  const editSelection = async () => {
1575
1657
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1576
1658
  if (!candidate) return;
1577
- renderer.flush();
1578
- ttyInput.setRawMode(false);
1579
- let edited = null;
1659
+ stopRenderLoop();
1660
+ cancelGeneration();
1661
+ isEditorOpen = true;
1662
+ if (state.isGenerating) {
1663
+ finalizeGeneration();
1664
+ }
1665
+ ttyInput.removeListener("keypress", handleKeypress);
1666
+ setRawModeSafe(false);
1667
+ ttyInput.pause();
1668
+ let edited = candidate.content;
1580
1669
  try {
1581
1670
  const result = await openEditor(candidate.content);
1582
- edited = result ? result : null;
1671
+ edited = result || candidate.content;
1583
1672
  } catch {
1673
+ } finally {
1674
+ isEditorOpen = false;
1584
1675
  }
1585
- ttyInput.setRawMode(true);
1676
+ editedSelections.set(candidate.slotId, edited);
1586
1677
  renderer.reset();
1587
- updateState(() => {
1588
- if (!edited) return;
1589
- slots[state.selectedIndex] = {
1590
- status: "ready",
1591
- candidate: { ...candidate, content: edited }
1678
+ doRender();
1679
+ renderer.flush();
1680
+ setImmediate(() => {
1681
+ if (!cleanedUp) {
1682
+ confirmSelection(false);
1683
+ }
1684
+ });
1685
+ };
1686
+ const refineSelection = async () => {
1687
+ if (!hasReadySlot(slots)) return;
1688
+ ttyInput.off("keypress", handleKeypress);
1689
+ renderer.flush();
1690
+ ttyInput.setRawMode(false);
1691
+ const guide = await new Promise((resolve4) => {
1692
+ const prompt = `${ui.prompt(
1693
+ `Enter refine instructions (e.g., more formal / shorter / Enter to clear): `
1694
+ )}`;
1695
+ const promptReader = readline3.createInterface({
1696
+ input: ttyInput,
1697
+ output: ttyOutput,
1698
+ terminal: true
1699
+ });
1700
+ let resolved2 = false;
1701
+ const finish = (value) => {
1702
+ if (resolved2) return;
1703
+ resolved2 = true;
1704
+ promptReader.close();
1705
+ resolve4(value);
1592
1706
  };
1707
+ promptReader.on("SIGINT", () => {
1708
+ finish(null);
1709
+ });
1710
+ promptReader.question(prompt, (input) => {
1711
+ finish(input.trim());
1712
+ });
1593
1713
  });
1714
+ ttyInput.setRawMode(true);
1715
+ ttyInput.on("keypress", handleKeypress);
1716
+ renderer.reset();
1717
+ if (guide === null) return;
1718
+ cancelGeneration();
1719
+ resolveOnce({ action: "refine", guide });
1720
+ cleanup();
1594
1721
  };
1595
1722
  const handleKeypress = async (_str, key) => {
1596
1723
  if (!key) return;
@@ -1602,10 +1729,14 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1602
1729
  confirmSelection();
1603
1730
  return;
1604
1731
  }
1605
- if (key.name === "r") {
1732
+ if (key.name === "r" && !key.shift) {
1606
1733
  rerollSelection();
1607
1734
  return;
1608
1735
  }
1736
+ if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
1737
+ await refineSelection();
1738
+ return;
1739
+ }
1609
1740
  if (key.name === "e") {
1610
1741
  await editSelection();
1611
1742
  return;
@@ -1807,6 +1938,16 @@ function normalizeGuide(value) {
1807
1938
  if (!trimmed) return void 0;
1808
1939
  return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1809
1940
  }
1941
+ function composeGuidance(baseGuide, guideHint) {
1942
+ const normalizedBase = baseGuide?.trim() ?? "";
1943
+ const normalizedGuideHint = guideHint?.trim() ?? "";
1944
+ if (!normalizedBase && !normalizedGuideHint) return void 0;
1945
+ if (!normalizedBase) return normalizedGuideHint;
1946
+ if (!normalizedGuideHint) return normalizedBase;
1947
+ return `${normalizedBase}
1948
+
1949
+ Refinement: ${normalizedGuideHint}`;
1950
+ }
1810
1951
  function exitWithInvalidModelError(error) {
1811
1952
  console.error(`Error: Model '${error.model}' is not supported.`);
1812
1953
  if (error.allowedModels.length > 0) {
@@ -1872,6 +2013,47 @@ function getStagedDiff() {
1872
2013
  process.exit(1);
1873
2014
  }
1874
2015
  }
2016
+ function canPromptForStagedChanges() {
2017
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2018
+ return false;
2019
+ }
2020
+ try {
2021
+ fs3.accessSync("/dev/tty", fs3.constants.R_OK);
2022
+ return true;
2023
+ } catch {
2024
+ return false;
2025
+ }
2026
+ }
2027
+ async function promptStageAllChanges() {
2028
+ return new Promise((resolve3) => {
2029
+ const fd = fs3.openSync("/dev/tty", "r");
2030
+ const ttyInput = new tty3.ReadStream(fd);
2031
+ const rl = readline4.createInterface({
2032
+ input: ttyInput,
2033
+ output: process.stdout,
2034
+ terminal: true
2035
+ });
2036
+ rl.question(
2037
+ ui.prompt(
2038
+ "No staged changes. Stage all files with `git add -A` and continue? (y/N) "
2039
+ ),
2040
+ (answer) => {
2041
+ rl.close();
2042
+ ttyInput.destroy();
2043
+ const normalized = answer.trim().toLowerCase();
2044
+ resolve3(normalized === "y");
2045
+ }
2046
+ );
2047
+ });
2048
+ }
2049
+ function stageAllChanges() {
2050
+ try {
2051
+ execSync2("git add -A", { stdio: "inherit" });
2052
+ } catch {
2053
+ console.error("Error: Failed to stage changes with `git add -A`.");
2054
+ process.exit(1);
2055
+ }
2056
+ }
1875
2057
  function commitWithMessage(message) {
1876
2058
  try {
1877
2059
  execSync2(`git commit -m ${JSON.stringify(message)}`, { stdio: "inherit" });
@@ -1888,12 +2070,29 @@ async function commit(args2) {
1888
2070
  apiPath: "/v1/commit-message/stream"
1889
2071
  });
1890
2072
  const models = resolveModels(options.cliModels);
1891
- const diff = getStagedDiff();
2073
+ let diff = getStagedDiff();
1892
2074
  if (!diff.trim()) {
1893
- console.error(
1894
- "Error: No staged changes. Stage files with `git add` first."
1895
- );
1896
- process.exit(1);
2075
+ if (!options.interactive || !canPromptForStagedChanges()) {
2076
+ console.error(
2077
+ "Error: No staged changes. Stage files with `git add` first."
2078
+ );
2079
+ process.exit(1);
2080
+ }
2081
+ const shouldStage = await promptStageAllChanges();
2082
+ if (!shouldStage) {
2083
+ console.error(
2084
+ "Error: No staged changes. Stage files with `git add` first."
2085
+ );
2086
+ process.exit(1);
2087
+ }
2088
+ stageAllChanges();
2089
+ diff = getStagedDiff();
2090
+ if (!diff.trim()) {
2091
+ console.error(
2092
+ "Error: No staged changes. Stage files with `git add` first."
2093
+ );
2094
+ process.exit(1);
2095
+ }
1897
2096
  }
1898
2097
  try {
1899
2098
  const token = await getToken();
@@ -1922,6 +2121,7 @@ async function commit(args2) {
1922
2121
  const commandExecutionSignal = abortController.signal;
1923
2122
  const commandExecutionPromise = promise;
1924
2123
  const apiClient = api;
2124
+ let guideHint;
1925
2125
  commandExecutionPromise.catch(async (error) => {
1926
2126
  abortController.abort(abortReasonForError(error));
1927
2127
  await handleCommandExecutionError(error, {
@@ -1946,7 +2146,7 @@ async function commit(args2) {
1946
2146
  const createCandidates = (signal) => generateCommitMessages({
1947
2147
  diff,
1948
2148
  models,
1949
- guide: options.guide,
2149
+ guide: composeGuidance(options.guide, guideHint),
1950
2150
  signal: mergeAbortSignals(signal, commandExecutionSignal),
1951
2151
  cliSessionId,
1952
2152
  commandExecutionPromise,
@@ -1956,7 +2156,7 @@ async function commit(args2) {
1956
2156
  const gen = generateCommitMessages({
1957
2157
  diff,
1958
2158
  models: models.slice(0, 1),
1959
- guide: options.guide,
2159
+ guide: composeGuidance(options.guide, guideHint),
1960
2160
  signal: commandExecutionSignal,
1961
2161
  cliSessionId,
1962
2162
  commandExecutionPromise,
@@ -1995,6 +2195,10 @@ async function commit(args2) {
1995
2195
  if (result.action === "reroll") {
1996
2196
  continue;
1997
2197
  }
2198
+ if (result.action === "refine" && result.guide !== void 0) {
2199
+ guideHint = result.guide.trim() || void 0;
2200
+ continue;
2201
+ }
1998
2202
  if (result.action === "confirm" && result.selected) {
1999
2203
  await recordSelection(result.selectedCandidate?.generationId);
2000
2204
  const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
@@ -2045,6 +2249,8 @@ Commit options:
2045
2249
  --guide <text> Additional context to guide message generation
2046
2250
  --models <list> Comma-separated model list (overrides config)
2047
2251
  --capture-stream <path> Save commit-message stream as replay JSON
2252
+ When no staged changes are found in interactive mode, you will be prompted
2253
+ to run \`git add -A\` first.
2048
2254
 
2049
2255
  Examples:
2050
2256
  git ultrahope commit # interactive selector (default)
package/dist/index.js CHANGED
@@ -547,9 +547,6 @@ var theme = {
547
547
  };
548
548
 
549
549
  // lib/ui.ts
550
- function formatTotalCost(cost) {
551
- return `$${cost.toFixed(6)}`;
552
- }
553
550
  var ui = {
554
551
  success: (msg) => `${theme.success}\u2714${theme.reset} ${theme.primary}${msg}${theme.reset}`,
555
552
  progress: (msg) => `${theme.progress}\u25B6${theme.reset} ${theme.primary}${msg}${theme.reset}`,
@@ -931,7 +928,19 @@ async function* generateCommitMessages(options) {
931
928
  try {
932
929
  if (signal?.aborted) return;
933
930
  let lastCommitMessage = "";
931
+ const generationStartedAtMs = Date.now();
932
+ let lastCommitMessageAtMs;
933
+ let providerMetadataAtMs;
934
934
  let providerMetadata;
935
+ const parseEventAtMs = (event) => {
936
+ if (typeof event.atMs !== "number" || !Number.isFinite(event.atMs)) {
937
+ return void 0;
938
+ }
939
+ if (event.atMs < 0) {
940
+ return 0;
941
+ }
942
+ return Math.round(event.atMs);
943
+ };
935
944
  for await (const event of generateWithRetry({
936
945
  cliSessionId: requiredCliSessionId,
937
946
  input: diff,
@@ -940,6 +949,7 @@ async function* generateCommitMessages(options) {
940
949
  })) {
941
950
  if (event.type === "commit-message") {
942
951
  lastCommitMessage = event.commitMessage;
952
+ lastCommitMessageAtMs = parseEventAtMs(event) ?? lastCommitMessageAtMs;
943
953
  if (useStream) {
944
954
  yield {
945
955
  content: lastCommitMessage,
@@ -950,6 +960,7 @@ async function* generateCommitMessages(options) {
950
960
  };
951
961
  }
952
962
  } else if (event.type === "provider-metadata") {
963
+ providerMetadataAtMs = parseEventAtMs(event) ?? providerMetadataAtMs;
953
964
  providerMetadata = event.providerMetadata;
954
965
  } else if (event.type === "error") {
955
966
  throw new Error(event.message);
@@ -957,11 +968,13 @@ async function* generateCommitMessages(options) {
957
968
  }
958
969
  if (lastCommitMessage) {
959
970
  const { generationId, cost } = extractGatewayMetadata(providerMetadata);
971
+ const generationMs = (providerMetadataAtMs ?? lastCommitMessageAtMs) != null ? providerMetadataAtMs ?? lastCommitMessageAtMs : Math.max(0, Date.now() - generationStartedAtMs);
960
972
  yield {
961
973
  content: lastCommitMessage,
962
974
  slotId: model,
963
975
  model,
964
976
  cost,
977
+ generationMs,
965
978
  generationId,
966
979
  ...useStream ? { isPartial: false } : {},
967
980
  slotIndex
@@ -1166,7 +1179,7 @@ import {
1166
1179
  mkdtempSync,
1167
1180
  openSync as openSync2,
1168
1181
  readFileSync as readFileSync2,
1169
- unlinkSync,
1182
+ rmSync,
1170
1183
  writeFileSync
1171
1184
  } from "fs";
1172
1185
  import { tmpdir } from "os";
@@ -1300,7 +1313,17 @@ function formatSlot(slot, selected) {
1300
1313
  }
1301
1314
  const candidate = slot.candidate;
1302
1315
  const title = candidate.content.split("\n")[0]?.trim() || "";
1303
- const modelInfo = candidate.model ? candidate.cost ? `${formatModelName(candidate.model)} ${formatCost(candidate.cost)}` : formatModelName(candidate.model) : "";
1316
+ const formatDuration = (ms) => {
1317
+ const safeMs = Math.max(0, Math.round(ms));
1318
+ if (safeMs < 1e3) {
1319
+ return `${safeMs}ms`;
1320
+ }
1321
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1322
+ return `${seconds}s`;
1323
+ };
1324
+ const formattedModel = candidate.model ? formatModelName(candidate.model) : "";
1325
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1326
+ const modelInfo = candidate.model ? candidate.cost ? `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}` : `${formattedModel}${formattedDuration}` : "";
1304
1327
  if (selected) {
1305
1328
  const radio2 = "\u25CF";
1306
1329
  const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
@@ -1312,7 +1335,7 @@ function formatSlot(slot, selected) {
1312
1335
  const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1313
1336
  return meta ? [line, meta] : [line];
1314
1337
  }
1315
- function renderSelector(state, nowMs, renderer) {
1338
+ function renderSelector(state, nowMs, renderer, editedSelections) {
1316
1339
  const { slots, selectedIndex, isGenerating, totalSlots } = state;
1317
1340
  const lines = [];
1318
1341
  const readyCount = getReadyCount(slots);
@@ -1329,10 +1352,18 @@ function renderSelector(state, nowMs, renderer) {
1329
1352
  const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1330
1353
  lines.push(ui.success(label));
1331
1354
  }
1355
+ const selectedSlot = slots[selectedIndex];
1356
+ const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1332
1357
  const hasReady = readyCount > 0;
1333
1358
  if (hasReady) {
1334
- const hint = ui.hint("\u2191\u2193 navigate \u23CE confirm e edit r reroll q quit");
1335
- lines.push(ui.prompt(`Select a commit message ${hint}`));
1359
+ if (isEditedSelection) {
1360
+ lines.push(ui.success("Select a commit message"));
1361
+ } else {
1362
+ const hint = ui.hint(
1363
+ "\u2191\u2193 navigate \u23CE confirm e edit r reroll R refine q quit"
1364
+ );
1365
+ lines.push(ui.prompt(`Select a commit message ${hint}`));
1366
+ }
1336
1367
  } else {
1337
1368
  lines.push(ui.hint(" q quit"));
1338
1369
  }
@@ -1346,6 +1377,14 @@ function renderSelector(state, nowMs, renderer) {
1346
1377
  lines.push("");
1347
1378
  }
1348
1379
  }
1380
+ if (selectedSlot?.status === "ready") {
1381
+ const edited = editedSelections?.get(selectedSlot.candidate.slotId);
1382
+ if (edited) {
1383
+ const editedSummary = edited.split("\n")[0]?.slice(0, 120);
1384
+ lines.push(ui.success(`Edited: ${editedSummary}`));
1385
+ lines.push("");
1386
+ }
1387
+ }
1349
1388
  renderer.render(`${lines.join("\n")}
1350
1389
  `);
1351
1390
  }
@@ -1367,25 +1406,40 @@ function openEditor(content) {
1367
1406
  const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
1368
1407
  const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
1369
1408
  const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
1370
- writeFileSync(tmpFile, content);
1371
- const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1372
- child.on("close", (code) => {
1373
- if (code !== 0) {
1374
- unlinkSync(tmpFile);
1375
- reject(new Error(`Editor exited with code ${code}`));
1376
- return;
1377
- }
1378
- const result = readFileSync2(tmpFile, "utf-8").trim();
1379
- unlinkSync(tmpFile);
1380
- resolve3(result);
1381
- });
1382
- child.on("error", (err) => {
1409
+ let cleanupDone = false;
1410
+ const cleanupTempArtifacts = () => {
1411
+ if (cleanupDone) return;
1412
+ cleanupDone = true;
1383
1413
  try {
1384
- unlinkSync(tmpFile);
1414
+ rmSync(tmpDir, { recursive: true, force: true });
1385
1415
  } catch {
1386
1416
  }
1417
+ };
1418
+ try {
1419
+ writeFileSync(tmpFile, content);
1420
+ const child = spawn(editor, [tmpFile], { stdio: "inherit" });
1421
+ child.on("close", (code) => {
1422
+ try {
1423
+ if (code !== 0) {
1424
+ reject(new Error(`Editor exited with code ${code}`));
1425
+ return;
1426
+ }
1427
+ const result = readFileSync2(tmpFile, "utf-8").trim();
1428
+ resolve3(result);
1429
+ } catch (err) {
1430
+ reject(err);
1431
+ } finally {
1432
+ cleanupTempArtifacts();
1433
+ }
1434
+ });
1435
+ child.on("error", (err) => {
1436
+ cleanupTempArtifacts();
1437
+ reject(err);
1438
+ });
1439
+ } catch (err) {
1440
+ cleanupTempArtifacts();
1387
1441
  reject(err);
1388
- });
1442
+ }
1389
1443
  });
1390
1444
  }
1391
1445
  async function selectCandidate(options) {
@@ -1405,8 +1459,12 @@ async function selectCandidate(options) {
1405
1459
  let ttyOutput = null;
1406
1460
  try {
1407
1461
  accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
1408
- const inputFd = openSync2(TTY_PATH, "r");
1409
- ttyInput = new tty2.ReadStream(inputFd);
1462
+ if (process.stdin.isTTY) {
1463
+ ttyInput = process.stdin;
1464
+ } else {
1465
+ const inputFd = openSync2(TTY_PATH, "r");
1466
+ ttyInput = new tty2.ReadStream(inputFd);
1467
+ }
1410
1468
  if (process.stdout.isTTY) {
1411
1469
  ttyOutput = process.stdout;
1412
1470
  } else {
@@ -1445,6 +1503,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1445
1503
  resolve3(result);
1446
1504
  };
1447
1505
  const slots = [...initialSlots];
1506
+ const editedSelections = /* @__PURE__ */ new Map();
1448
1507
  const state = {
1449
1508
  slots,
1450
1509
  selectedIndex: 0,
@@ -1453,19 +1512,21 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1453
1512
  };
1454
1513
  let renderInterval = null;
1455
1514
  let cleanedUp = false;
1515
+ let isEditorOpen = false;
1456
1516
  const ttyInput = ttyIo.input;
1457
1517
  const ttyOutput = ttyIo.output;
1458
1518
  const renderer = createRenderer(ttyOutput);
1459
- const rl = readline3.createInterface({
1460
- input: ttyInput,
1461
- output: ttyOutput,
1462
- terminal: true
1463
- });
1464
- readline3.emitKeypressEvents(ttyInput, rl);
1519
+ readline3.emitKeypressEvents(ttyInput);
1465
1520
  ttyInput.setRawMode(true);
1521
+ const setRawModeSafe = (enabled) => {
1522
+ try {
1523
+ ttyInput.setRawMode(enabled);
1524
+ } catch {
1525
+ }
1526
+ };
1466
1527
  const doRender = () => {
1467
- if (!cleanedUp) {
1468
- renderSelector(state, Date.now(), renderer);
1528
+ if (!cleanedUp && !isEditorOpen) {
1529
+ renderSelector(state, Date.now(), renderer, editedSelections);
1469
1530
  }
1470
1531
  };
1471
1532
  const updateState = (update) => {
@@ -1495,22 +1556,36 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1495
1556
  if (clearOutput) {
1496
1557
  renderer.clearAll();
1497
1558
  }
1498
- ttyInput.setRawMode(false);
1499
- rl.close();
1500
- ttyInput.destroy();
1501
- ttyOutput.destroy();
1559
+ ttyInput.removeAllListeners("keypress");
1560
+ setRawModeSafe(false);
1561
+ ttyInput.pause();
1562
+ if (ttyInput !== process.stdin && !ttyInput.destroyed) {
1563
+ ttyInput.destroy();
1564
+ }
1565
+ if (ttyOutput !== process.stdout && ttyOutput !== process.stderr && !ttyOutput.destroyed) {
1566
+ ttyOutput.destroy();
1567
+ }
1502
1568
  };
1503
1569
  const nextCandidate = async (iterator) => {
1570
+ if (!asyncCtx?.abortController) {
1571
+ return iterator.next();
1572
+ }
1573
+ const signal = asyncCtx.abortController.signal;
1574
+ if (signal.aborted) {
1575
+ return { done: true, value: void 0 };
1576
+ }
1577
+ let cleanup2 = () => {
1578
+ };
1504
1579
  const abortPromise = new Promise(
1505
1580
  (resolve4) => {
1506
- asyncCtx?.abortController.signal.addEventListener(
1507
- "abort",
1508
- () => resolve4({ done: true, value: void 0 }),
1509
- { once: true }
1510
- );
1581
+ const onAbort = () => resolve4({ done: true, value: void 0 });
1582
+ signal.addEventListener("abort", onAbort);
1583
+ cleanup2 = () => signal.removeEventListener("abort", onAbort);
1511
1584
  }
1512
1585
  );
1513
- return Promise.race([iterator.next(), abortPromise]);
1586
+ return Promise.race([iterator.next(), abortPromise]).finally(() => {
1587
+ cleanup2();
1588
+ });
1514
1589
  };
1515
1590
  const finalizeGeneration = () => {
1516
1591
  collapseToReady(slots);
@@ -1569,21 +1644,22 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1569
1644
  }
1570
1645
  })();
1571
1646
  }
1572
- const confirmSelection = () => {
1647
+ const confirmSelection = (clearOutput = true) => {
1573
1648
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1574
1649
  if (!candidate) return;
1650
+ const selectedContent = editedSelections.get(candidate.slotId) ?? candidate.content;
1575
1651
  cancelGeneration();
1576
1652
  const totalCost = getTotalCost(slots);
1577
1653
  const quota = getLatestQuota(slots);
1578
1654
  resolveOnce({
1579
1655
  action: "confirm",
1580
- selected: candidate.content,
1656
+ selected: selectedContent,
1581
1657
  selectedIndex: state.selectedIndex,
1582
1658
  selectedCandidate: candidate,
1583
1659
  totalCost: totalCost > 0 ? totalCost : void 0,
1584
1660
  quota
1585
1661
  });
1586
- cleanup();
1662
+ cleanup(clearOutput);
1587
1663
  };
1588
1664
  const rerollSelection = () => {
1589
1665
  if (!hasReadySlot(slots)) return;
@@ -1599,23 +1675,68 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1599
1675
  const editSelection = async () => {
1600
1676
  const candidate = getSelectedCandidate(slots, state.selectedIndex);
1601
1677
  if (!candidate) return;
1602
- renderer.flush();
1603
- ttyInput.setRawMode(false);
1604
- let edited = null;
1678
+ stopRenderLoop();
1679
+ cancelGeneration();
1680
+ isEditorOpen = true;
1681
+ if (state.isGenerating) {
1682
+ finalizeGeneration();
1683
+ }
1684
+ ttyInput.removeListener("keypress", handleKeypress);
1685
+ setRawModeSafe(false);
1686
+ ttyInput.pause();
1687
+ let edited = candidate.content;
1605
1688
  try {
1606
1689
  const result = await openEditor(candidate.content);
1607
- edited = result ? result : null;
1690
+ edited = result || candidate.content;
1608
1691
  } catch {
1692
+ } finally {
1693
+ isEditorOpen = false;
1609
1694
  }
1610
- ttyInput.setRawMode(true);
1695
+ editedSelections.set(candidate.slotId, edited);
1611
1696
  renderer.reset();
1612
- updateState(() => {
1613
- if (!edited) return;
1614
- slots[state.selectedIndex] = {
1615
- status: "ready",
1616
- candidate: { ...candidate, content: edited }
1697
+ doRender();
1698
+ renderer.flush();
1699
+ setImmediate(() => {
1700
+ if (!cleanedUp) {
1701
+ confirmSelection(false);
1702
+ }
1703
+ });
1704
+ };
1705
+ const refineSelection = async () => {
1706
+ if (!hasReadySlot(slots)) return;
1707
+ ttyInput.off("keypress", handleKeypress);
1708
+ renderer.flush();
1709
+ ttyInput.setRawMode(false);
1710
+ const guide = await new Promise((resolve4) => {
1711
+ const prompt = `${ui.prompt(
1712
+ `Enter refine instructions (e.g., more formal / shorter / Enter to clear): `
1713
+ )}`;
1714
+ const promptReader = readline3.createInterface({
1715
+ input: ttyInput,
1716
+ output: ttyOutput,
1717
+ terminal: true
1718
+ });
1719
+ let resolved2 = false;
1720
+ const finish = (value) => {
1721
+ if (resolved2) return;
1722
+ resolved2 = true;
1723
+ promptReader.close();
1724
+ resolve4(value);
1617
1725
  };
1726
+ promptReader.on("SIGINT", () => {
1727
+ finish(null);
1728
+ });
1729
+ promptReader.question(prompt, (input) => {
1730
+ finish(input.trim());
1731
+ });
1618
1732
  });
1733
+ ttyInput.setRawMode(true);
1734
+ ttyInput.on("keypress", handleKeypress);
1735
+ renderer.reset();
1736
+ if (guide === null) return;
1737
+ cancelGeneration();
1738
+ resolveOnce({ action: "refine", guide });
1739
+ cleanup();
1619
1740
  };
1620
1741
  const handleKeypress = async (_str, key) => {
1621
1742
  if (!key) return;
@@ -1627,10 +1748,14 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1627
1748
  confirmSelection();
1628
1749
  return;
1629
1750
  }
1630
- if (key.name === "r") {
1751
+ if (key.name === "r" && !key.shift) {
1631
1752
  rerollSelection();
1632
1753
  return;
1633
1754
  }
1755
+ if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
1756
+ await refineSelection();
1757
+ return;
1758
+ }
1634
1759
  if (key.name === "e") {
1635
1760
  await editSelection();
1636
1761
  return;
@@ -1848,6 +1973,16 @@ function normalizeGuide(value) {
1848
1973
  if (!trimmed) return void 0;
1849
1974
  return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
1850
1975
  }
1976
+ function composeGuidance(baseGuide, guideHint) {
1977
+ const normalizedBase = baseGuide?.trim() ?? "";
1978
+ const normalizedGuideHint = guideHint?.trim() ?? "";
1979
+ if (!normalizedBase && !normalizedGuideHint) return void 0;
1980
+ if (!normalizedBase) return normalizedGuideHint;
1981
+ if (!normalizedGuideHint) return normalizedBase;
1982
+ return `${normalizedBase}
1983
+
1984
+ Refinement: ${normalizedGuideHint}`;
1985
+ }
1851
1986
  function parseDescribeArgs(args2) {
1852
1987
  let revision = "@";
1853
1988
  let interactive = true;
@@ -1962,11 +2097,11 @@ async function recordSelection(apiClient, generationId) {
1962
2097
  console.error(`Warning: Failed to record selection. ${message}`);
1963
2098
  }
1964
2099
  }
1965
- function createCandidateFactory(diff, models, context, captureRecorder, guide) {
2100
+ function createCandidateFactory(diff, models, context, captureRecorder, getGuide) {
1966
2101
  return (signal) => generateCommitMessages({
1967
2102
  diff,
1968
2103
  models,
1969
- guide,
2104
+ guide: getGuide(),
1970
2105
  signal: mergeAbortSignals(signal, context.commandExecutionSignal),
1971
2106
  cliSessionId: context.cliSessionId,
1972
2107
  commandExecutionPromise: context.commandExecutionPromise,
@@ -1995,7 +2130,7 @@ async function runNonInteractiveDescribe(revision, models, diff, context, captur
1995
2130
  const message = first.value?.content ?? "";
1996
2131
  describeRevision(revision, message);
1997
2132
  }
1998
- async function runInteractiveDescribe(options, models, createCandidates, context) {
2133
+ async function runInteractiveDescribe(options, models, createCandidates, context, onGuideHintChange) {
1999
2134
  const stats = getJjDiffStats(options.revision);
2000
2135
  console.log(ui.success(`Found ${formatDiffStats(stats)}`));
2001
2136
  while (true) {
@@ -2018,13 +2153,16 @@ async function runInteractiveDescribe(options, models, createCandidates, context
2018
2153
  if (result.action === "reroll") {
2019
2154
  continue;
2020
2155
  }
2156
+ if (result.action === "refine" && result.guide !== void 0) {
2157
+ const nextGuideHint = result.guide.trim() || void 0;
2158
+ onGuideHintChange(nextGuideHint);
2159
+ continue;
2160
+ }
2021
2161
  if (result.action === "confirm" && result.selected) {
2022
2162
  await recordSelection(
2023
2163
  context.apiClient,
2024
2164
  result.selectedCandidate?.generationId
2025
2165
  );
2026
- const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
2027
- console.log(ui.success(`Message selected${costLabel}`));
2028
2166
  console.log(
2029
2167
  `${ui.success(`Running jj describe -r ${options.revision}`)}
2030
2168
  `
@@ -2049,18 +2187,20 @@ async function describe(args2) {
2049
2187
  apiPath: "/v1/commit-message/stream"
2050
2188
  });
2051
2189
  try {
2190
+ let guideHint;
2052
2191
  const context = await initCommandExecutionContext(
2053
2192
  args2,
2054
2193
  models,
2055
2194
  diff,
2056
2195
  options.guide
2057
2196
  );
2197
+ const resolveGuide = () => composeGuidance(options.guide, guideHint);
2058
2198
  const createCandidates = createCandidateFactory(
2059
2199
  diff,
2060
2200
  models,
2061
2201
  context,
2062
2202
  captureRecorder,
2063
- options.guide
2203
+ resolveGuide
2064
2204
  );
2065
2205
  if (!options.interactive) {
2066
2206
  await runNonInteractiveDescribe(
@@ -2069,11 +2209,19 @@ async function describe(args2) {
2069
2209
  diff,
2070
2210
  context,
2071
2211
  captureRecorder,
2072
- options.guide
2212
+ resolveGuide()
2073
2213
  );
2074
2214
  return;
2075
2215
  }
2076
- await runInteractiveDescribe(options, models, createCandidates, context);
2216
+ await runInteractiveDescribe(
2217
+ options,
2218
+ models,
2219
+ createCandidates,
2220
+ context,
2221
+ (value) => {
2222
+ guideHint = value;
2223
+ }
2224
+ );
2077
2225
  } finally {
2078
2226
  const capturePath = captureRecorder.flush();
2079
2227
  if (capturePath) {
@@ -2220,6 +2368,10 @@ function exitWithInvalidModelError2(error) {
2220
2368
  }
2221
2369
  process.exit(1);
2222
2370
  }
2371
+ function composeGuidance2(guideHint) {
2372
+ const normalizedGuideHint = guideHint?.trim() ?? "";
2373
+ return normalizedGuideHint || void 0;
2374
+ }
2223
2375
  async function translate(args2) {
2224
2376
  const options = parseArgs(args2);
2225
2377
  const input = await stdin();
@@ -2271,6 +2423,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2271
2423
  const commandExecutionSignal = abortController.signal;
2272
2424
  const commandExecutionPromise = promise;
2273
2425
  const apiClient = api;
2426
+ let guideHint;
2274
2427
  commandExecutionPromise.catch(async (error) => {
2275
2428
  abortController.abort(abortReasonForError(error));
2276
2429
  await handleCommandExecutionError(error, {
@@ -2295,6 +2448,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2295
2448
  const createCandidates = (signal) => generateCommitMessages({
2296
2449
  diff: input,
2297
2450
  models,
2451
+ guide: composeGuidance2(guideHint),
2298
2452
  signal: mergeAbortSignals(signal, commandExecutionSignal),
2299
2453
  cliSessionId,
2300
2454
  commandExecutionPromise,
@@ -2304,6 +2458,7 @@ async function handleVcsCommitMessage(input, options, args2) {
2304
2458
  const gen = generateCommitMessages({
2305
2459
  diff: input,
2306
2460
  models: models.slice(0, 1),
2461
+ guide: composeGuidance2(guideHint),
2307
2462
  signal: commandExecutionSignal,
2308
2463
  cliSessionId,
2309
2464
  commandExecutionPromise,
@@ -2336,6 +2491,10 @@ async function handleVcsCommitMessage(input, options, args2) {
2336
2491
  console.error("Aborted.");
2337
2492
  process.exit(1);
2338
2493
  }
2494
+ if (result.action === "refine" && result.guide !== void 0) {
2495
+ guideHint = result.guide.trim() || void 0;
2496
+ continue;
2497
+ }
2339
2498
  if (result.action === "reroll") {
2340
2499
  continue;
2341
2500
  }
@@ -2571,7 +2730,7 @@ function parseArgs(args2) {
2571
2730
  // package.json
2572
2731
  var package_default = {
2573
2732
  name: "ultrahope",
2574
- version: "0.1.4",
2733
+ version: "0.1.6",
2575
2734
  description: "LLM-powered development workflow assistant",
2576
2735
  type: "module",
2577
2736
  license: "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultrahope",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "LLM-powered development workflow assistant",
5
5
  "type": "module",
6
6
  "license": "MIT",