ultrahope 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/dist/git-ultrahope.js +199 -56
- package/dist/index.js +225 -66
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,16 +41,33 @@ 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`
|
|
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
|
+
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.
|
|
55
|
+
|
|
56
|
+
#### Difference Between `guide` And Refine Instructions
|
|
57
|
+
|
|
58
|
+
- `--guide`:
|
|
59
|
+
- Supplemental intent outside the diff (for example: ticket ID, background, change intent)
|
|
60
|
+
- `R refine`:
|
|
61
|
+
- Review generated results and enter inline instructions for the next reroll
|
|
62
|
+
- Examples: "more formal", "shorter"
|
|
63
|
+
- Press `Enter` with empty input to clear the previous refine instructions
|
|
64
|
+
- If specified multiple times, the last one overwrites previous values
|
|
65
|
+
- `R` means reroll with additional instructions (`refine`)
|
|
66
|
+
- At request time, refine instructions are merged into `guide` and sent to the API:
|
|
67
|
+
- `--guide` only: `guide = "<guide>"`
|
|
68
|
+
- `R refine` only: `guide = "<refine>"`
|
|
69
|
+
- both: `guide = "<guide>\n\nRefinement: <refine>"`
|
|
70
|
+
|
|
54
71
|
#### Targets
|
|
55
72
|
|
|
56
73
|
- `vcs-commit-message` - Generate a commit message
|
package/dist/git-ultrahope.js
CHANGED
|
@@ -921,7 +921,19 @@ async function* generateCommitMessages(options) {
|
|
|
921
921
|
try {
|
|
922
922
|
if (signal?.aborted) return;
|
|
923
923
|
let lastCommitMessage = "";
|
|
924
|
+
const generationStartedAtMs = Date.now();
|
|
925
|
+
let lastCommitMessageAtMs;
|
|
926
|
+
let providerMetadataAtMs;
|
|
924
927
|
let providerMetadata;
|
|
928
|
+
const parseEventAtMs = (event) => {
|
|
929
|
+
if (typeof event.atMs !== "number" || !Number.isFinite(event.atMs)) {
|
|
930
|
+
return void 0;
|
|
931
|
+
}
|
|
932
|
+
if (event.atMs < 0) {
|
|
933
|
+
return 0;
|
|
934
|
+
}
|
|
935
|
+
return Math.round(event.atMs);
|
|
936
|
+
};
|
|
925
937
|
for await (const event of generateWithRetry({
|
|
926
938
|
cliSessionId: requiredCliSessionId,
|
|
927
939
|
input: diff,
|
|
@@ -930,6 +942,7 @@ async function* generateCommitMessages(options) {
|
|
|
930
942
|
})) {
|
|
931
943
|
if (event.type === "commit-message") {
|
|
932
944
|
lastCommitMessage = event.commitMessage;
|
|
945
|
+
lastCommitMessageAtMs = parseEventAtMs(event) ?? lastCommitMessageAtMs;
|
|
933
946
|
if (useStream) {
|
|
934
947
|
yield {
|
|
935
948
|
content: lastCommitMessage,
|
|
@@ -940,6 +953,7 @@ async function* generateCommitMessages(options) {
|
|
|
940
953
|
};
|
|
941
954
|
}
|
|
942
955
|
} else if (event.type === "provider-metadata") {
|
|
956
|
+
providerMetadataAtMs = parseEventAtMs(event) ?? providerMetadataAtMs;
|
|
943
957
|
providerMetadata = event.providerMetadata;
|
|
944
958
|
} else if (event.type === "error") {
|
|
945
959
|
throw new Error(event.message);
|
|
@@ -947,11 +961,13 @@ async function* generateCommitMessages(options) {
|
|
|
947
961
|
}
|
|
948
962
|
if (lastCommitMessage) {
|
|
949
963
|
const { generationId, cost } = extractGatewayMetadata(providerMetadata);
|
|
964
|
+
const generationMs = (providerMetadataAtMs ?? lastCommitMessageAtMs) != null ? providerMetadataAtMs ?? lastCommitMessageAtMs : Math.max(0, Date.now() - generationStartedAtMs);
|
|
950
965
|
yield {
|
|
951
966
|
content: lastCommitMessage,
|
|
952
967
|
slotId: model,
|
|
953
968
|
model,
|
|
954
969
|
cost,
|
|
970
|
+
generationMs,
|
|
955
971
|
generationId,
|
|
956
972
|
...useStream ? { isPartial: false } : {},
|
|
957
973
|
slotIndex
|
|
@@ -1141,7 +1157,7 @@ import {
|
|
|
1141
1157
|
mkdtempSync,
|
|
1142
1158
|
openSync as openSync2,
|
|
1143
1159
|
readFileSync as readFileSync2,
|
|
1144
|
-
|
|
1160
|
+
rmSync,
|
|
1145
1161
|
writeFileSync
|
|
1146
1162
|
} from "fs";
|
|
1147
1163
|
import { tmpdir } from "os";
|
|
@@ -1275,7 +1291,17 @@ function formatSlot(slot, selected) {
|
|
|
1275
1291
|
}
|
|
1276
1292
|
const candidate = slot.candidate;
|
|
1277
1293
|
const title = candidate.content.split("\n")[0]?.trim() || "";
|
|
1278
|
-
const
|
|
1294
|
+
const formatDuration = (ms) => {
|
|
1295
|
+
const safeMs = Math.max(0, Math.round(ms));
|
|
1296
|
+
if (safeMs < 1e3) {
|
|
1297
|
+
return `${safeMs}ms`;
|
|
1298
|
+
}
|
|
1299
|
+
const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
|
|
1300
|
+
return `${seconds}s`;
|
|
1301
|
+
};
|
|
1302
|
+
const formattedModel = candidate.model ? formatModelName(candidate.model) : "";
|
|
1303
|
+
const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
|
|
1304
|
+
const modelInfo = candidate.model ? candidate.cost ? `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}` : `${formattedModel}${formattedDuration}` : "";
|
|
1279
1305
|
if (selected) {
|
|
1280
1306
|
const radio2 = "\u25CF";
|
|
1281
1307
|
const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
|
|
@@ -1287,7 +1313,7 @@ function formatSlot(slot, selected) {
|
|
|
1287
1313
|
const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
|
|
1288
1314
|
return meta ? [line, meta] : [line];
|
|
1289
1315
|
}
|
|
1290
|
-
function renderSelector(state, nowMs, renderer) {
|
|
1316
|
+
function renderSelector(state, nowMs, renderer, editedSelections) {
|
|
1291
1317
|
const { slots, selectedIndex, isGenerating, totalSlots } = state;
|
|
1292
1318
|
const lines = [];
|
|
1293
1319
|
const readyCount = getReadyCount(slots);
|
|
@@ -1304,10 +1330,18 @@ function renderSelector(state, nowMs, renderer) {
|
|
|
1304
1330
|
const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
|
|
1305
1331
|
lines.push(ui.success(label));
|
|
1306
1332
|
}
|
|
1333
|
+
const selectedSlot = slots[selectedIndex];
|
|
1334
|
+
const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
|
|
1307
1335
|
const hasReady = readyCount > 0;
|
|
1308
1336
|
if (hasReady) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1337
|
+
if (isEditedSelection) {
|
|
1338
|
+
lines.push(ui.success("Select a commit message"));
|
|
1339
|
+
} else {
|
|
1340
|
+
const hint = ui.hint(
|
|
1341
|
+
"\u2191\u2193 navigate \u23CE confirm e edit r reroll R refine q quit"
|
|
1342
|
+
);
|
|
1343
|
+
lines.push(ui.prompt(`Select a commit message ${hint}`));
|
|
1344
|
+
}
|
|
1311
1345
|
} else {
|
|
1312
1346
|
lines.push(ui.hint(" q quit"));
|
|
1313
1347
|
}
|
|
@@ -1321,6 +1355,14 @@ function renderSelector(state, nowMs, renderer) {
|
|
|
1321
1355
|
lines.push("");
|
|
1322
1356
|
}
|
|
1323
1357
|
}
|
|
1358
|
+
if (selectedSlot?.status === "ready") {
|
|
1359
|
+
const edited = editedSelections?.get(selectedSlot.candidate.slotId);
|
|
1360
|
+
if (edited) {
|
|
1361
|
+
const editedSummary = edited.split("\n")[0]?.slice(0, 120);
|
|
1362
|
+
lines.push(ui.success(`Edited: ${editedSummary}`));
|
|
1363
|
+
lines.push("");
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1324
1366
|
renderer.render(`${lines.join("\n")}
|
|
1325
1367
|
`);
|
|
1326
1368
|
}
|
|
@@ -1342,25 +1384,40 @@ function openEditor(content) {
|
|
|
1342
1384
|
const editor = process.env.GIT_EDITOR || process.env.EDITOR || "vi";
|
|
1343
1385
|
const tmpDir = mkdtempSync(join4(tmpdir(), "ultrahope-"));
|
|
1344
1386
|
const tmpFile = join4(tmpDir, "EDIT_MESSAGE");
|
|
1345
|
-
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
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) => {
|
|
1387
|
+
let cleanupDone = false;
|
|
1388
|
+
const cleanupTempArtifacts = () => {
|
|
1389
|
+
if (cleanupDone) return;
|
|
1390
|
+
cleanupDone = true;
|
|
1358
1391
|
try {
|
|
1359
|
-
|
|
1392
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
1360
1393
|
} catch {
|
|
1361
1394
|
}
|
|
1395
|
+
};
|
|
1396
|
+
try {
|
|
1397
|
+
writeFileSync(tmpFile, content);
|
|
1398
|
+
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
|
|
1399
|
+
child.on("close", (code) => {
|
|
1400
|
+
try {
|
|
1401
|
+
if (code !== 0) {
|
|
1402
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const result = readFileSync2(tmpFile, "utf-8").trim();
|
|
1406
|
+
resolve3(result);
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
reject(err);
|
|
1409
|
+
} finally {
|
|
1410
|
+
cleanupTempArtifacts();
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
child.on("error", (err) => {
|
|
1414
|
+
cleanupTempArtifacts();
|
|
1415
|
+
reject(err);
|
|
1416
|
+
});
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
cleanupTempArtifacts();
|
|
1362
1419
|
reject(err);
|
|
1363
|
-
}
|
|
1420
|
+
}
|
|
1364
1421
|
});
|
|
1365
1422
|
}
|
|
1366
1423
|
async function selectCandidate(options) {
|
|
@@ -1380,8 +1437,12 @@ async function selectCandidate(options) {
|
|
|
1380
1437
|
let ttyOutput = null;
|
|
1381
1438
|
try {
|
|
1382
1439
|
accessSync2(TTY_PATH, constants2.R_OK | constants2.W_OK);
|
|
1383
|
-
|
|
1384
|
-
|
|
1440
|
+
if (process.stdin.isTTY) {
|
|
1441
|
+
ttyInput = process.stdin;
|
|
1442
|
+
} else {
|
|
1443
|
+
const inputFd = openSync2(TTY_PATH, "r");
|
|
1444
|
+
ttyInput = new tty2.ReadStream(inputFd);
|
|
1445
|
+
}
|
|
1385
1446
|
if (process.stdout.isTTY) {
|
|
1386
1447
|
ttyOutput = process.stdout;
|
|
1387
1448
|
} else {
|
|
@@ -1420,6 +1481,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1420
1481
|
resolve3(result);
|
|
1421
1482
|
};
|
|
1422
1483
|
const slots = [...initialSlots];
|
|
1484
|
+
const editedSelections = /* @__PURE__ */ new Map();
|
|
1423
1485
|
const state = {
|
|
1424
1486
|
slots,
|
|
1425
1487
|
selectedIndex: 0,
|
|
@@ -1428,19 +1490,21 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1428
1490
|
};
|
|
1429
1491
|
let renderInterval = null;
|
|
1430
1492
|
let cleanedUp = false;
|
|
1493
|
+
let isEditorOpen = false;
|
|
1431
1494
|
const ttyInput = ttyIo.input;
|
|
1432
1495
|
const ttyOutput = ttyIo.output;
|
|
1433
1496
|
const renderer = createRenderer(ttyOutput);
|
|
1434
|
-
|
|
1435
|
-
input: ttyInput,
|
|
1436
|
-
output: ttyOutput,
|
|
1437
|
-
terminal: true
|
|
1438
|
-
});
|
|
1439
|
-
readline3.emitKeypressEvents(ttyInput, rl);
|
|
1497
|
+
readline3.emitKeypressEvents(ttyInput);
|
|
1440
1498
|
ttyInput.setRawMode(true);
|
|
1499
|
+
const setRawModeSafe = (enabled) => {
|
|
1500
|
+
try {
|
|
1501
|
+
ttyInput.setRawMode(enabled);
|
|
1502
|
+
} catch {
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1441
1505
|
const doRender = () => {
|
|
1442
|
-
if (!cleanedUp) {
|
|
1443
|
-
renderSelector(state, Date.now(), renderer);
|
|
1506
|
+
if (!cleanedUp && !isEditorOpen) {
|
|
1507
|
+
renderSelector(state, Date.now(), renderer, editedSelections);
|
|
1444
1508
|
}
|
|
1445
1509
|
};
|
|
1446
1510
|
const updateState = (update) => {
|
|
@@ -1470,22 +1534,36 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1470
1534
|
if (clearOutput) {
|
|
1471
1535
|
renderer.clearAll();
|
|
1472
1536
|
}
|
|
1473
|
-
ttyInput.
|
|
1474
|
-
|
|
1475
|
-
ttyInput.
|
|
1476
|
-
|
|
1537
|
+
ttyInput.removeAllListeners("keypress");
|
|
1538
|
+
setRawModeSafe(false);
|
|
1539
|
+
ttyInput.pause();
|
|
1540
|
+
if (ttyInput !== process.stdin && !ttyInput.destroyed) {
|
|
1541
|
+
ttyInput.destroy();
|
|
1542
|
+
}
|
|
1543
|
+
if (ttyOutput !== process.stdout && ttyOutput !== process.stderr && !ttyOutput.destroyed) {
|
|
1544
|
+
ttyOutput.destroy();
|
|
1545
|
+
}
|
|
1477
1546
|
};
|
|
1478
1547
|
const nextCandidate = async (iterator) => {
|
|
1548
|
+
if (!asyncCtx?.abortController) {
|
|
1549
|
+
return iterator.next();
|
|
1550
|
+
}
|
|
1551
|
+
const signal = asyncCtx.abortController.signal;
|
|
1552
|
+
if (signal.aborted) {
|
|
1553
|
+
return { done: true, value: void 0 };
|
|
1554
|
+
}
|
|
1555
|
+
let cleanup2 = () => {
|
|
1556
|
+
};
|
|
1479
1557
|
const abortPromise = new Promise(
|
|
1480
1558
|
(resolve4) => {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
{ once: true }
|
|
1485
|
-
);
|
|
1559
|
+
const onAbort = () => resolve4({ done: true, value: void 0 });
|
|
1560
|
+
signal.addEventListener("abort", onAbort);
|
|
1561
|
+
cleanup2 = () => signal.removeEventListener("abort", onAbort);
|
|
1486
1562
|
}
|
|
1487
1563
|
);
|
|
1488
|
-
return Promise.race([iterator.next(), abortPromise])
|
|
1564
|
+
return Promise.race([iterator.next(), abortPromise]).finally(() => {
|
|
1565
|
+
cleanup2();
|
|
1566
|
+
});
|
|
1489
1567
|
};
|
|
1490
1568
|
const finalizeGeneration = () => {
|
|
1491
1569
|
collapseToReady(slots);
|
|
@@ -1544,21 +1622,22 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1544
1622
|
}
|
|
1545
1623
|
})();
|
|
1546
1624
|
}
|
|
1547
|
-
const confirmSelection = () => {
|
|
1625
|
+
const confirmSelection = (clearOutput = true) => {
|
|
1548
1626
|
const candidate = getSelectedCandidate(slots, state.selectedIndex);
|
|
1549
1627
|
if (!candidate) return;
|
|
1628
|
+
const selectedContent = editedSelections.get(candidate.slotId) ?? candidate.content;
|
|
1550
1629
|
cancelGeneration();
|
|
1551
1630
|
const totalCost = getTotalCost(slots);
|
|
1552
1631
|
const quota = getLatestQuota(slots);
|
|
1553
1632
|
resolveOnce({
|
|
1554
1633
|
action: "confirm",
|
|
1555
|
-
selected:
|
|
1634
|
+
selected: selectedContent,
|
|
1556
1635
|
selectedIndex: state.selectedIndex,
|
|
1557
1636
|
selectedCandidate: candidate,
|
|
1558
1637
|
totalCost: totalCost > 0 ? totalCost : void 0,
|
|
1559
1638
|
quota
|
|
1560
1639
|
});
|
|
1561
|
-
cleanup();
|
|
1640
|
+
cleanup(clearOutput);
|
|
1562
1641
|
};
|
|
1563
1642
|
const rerollSelection = () => {
|
|
1564
1643
|
if (!hasReadySlot(slots)) return;
|
|
@@ -1574,23 +1653,68 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1574
1653
|
const editSelection = async () => {
|
|
1575
1654
|
const candidate = getSelectedCandidate(slots, state.selectedIndex);
|
|
1576
1655
|
if (!candidate) return;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1656
|
+
stopRenderLoop();
|
|
1657
|
+
cancelGeneration();
|
|
1658
|
+
isEditorOpen = true;
|
|
1659
|
+
if (state.isGenerating) {
|
|
1660
|
+
finalizeGeneration();
|
|
1661
|
+
}
|
|
1662
|
+
ttyInput.removeListener("keypress", handleKeypress);
|
|
1663
|
+
setRawModeSafe(false);
|
|
1664
|
+
ttyInput.pause();
|
|
1665
|
+
let edited = candidate.content;
|
|
1580
1666
|
try {
|
|
1581
1667
|
const result = await openEditor(candidate.content);
|
|
1582
|
-
edited = result
|
|
1668
|
+
edited = result || candidate.content;
|
|
1583
1669
|
} catch {
|
|
1670
|
+
} finally {
|
|
1671
|
+
isEditorOpen = false;
|
|
1584
1672
|
}
|
|
1585
|
-
|
|
1673
|
+
editedSelections.set(candidate.slotId, edited);
|
|
1586
1674
|
renderer.reset();
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1675
|
+
doRender();
|
|
1676
|
+
renderer.flush();
|
|
1677
|
+
setImmediate(() => {
|
|
1678
|
+
if (!cleanedUp) {
|
|
1679
|
+
confirmSelection(false);
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
};
|
|
1683
|
+
const refineSelection = async () => {
|
|
1684
|
+
if (!hasReadySlot(slots)) return;
|
|
1685
|
+
ttyInput.off("keypress", handleKeypress);
|
|
1686
|
+
renderer.flush();
|
|
1687
|
+
ttyInput.setRawMode(false);
|
|
1688
|
+
const guide = await new Promise((resolve4) => {
|
|
1689
|
+
const prompt = `${ui.prompt(
|
|
1690
|
+
`Enter refine instructions (e.g., more formal / shorter / Enter to clear): `
|
|
1691
|
+
)}`;
|
|
1692
|
+
const promptReader = readline3.createInterface({
|
|
1693
|
+
input: ttyInput,
|
|
1694
|
+
output: ttyOutput,
|
|
1695
|
+
terminal: true
|
|
1696
|
+
});
|
|
1697
|
+
let resolved2 = false;
|
|
1698
|
+
const finish = (value) => {
|
|
1699
|
+
if (resolved2) return;
|
|
1700
|
+
resolved2 = true;
|
|
1701
|
+
promptReader.close();
|
|
1702
|
+
resolve4(value);
|
|
1592
1703
|
};
|
|
1704
|
+
promptReader.on("SIGINT", () => {
|
|
1705
|
+
finish(null);
|
|
1706
|
+
});
|
|
1707
|
+
promptReader.question(prompt, (input) => {
|
|
1708
|
+
finish(input.trim());
|
|
1709
|
+
});
|
|
1593
1710
|
});
|
|
1711
|
+
ttyInput.setRawMode(true);
|
|
1712
|
+
ttyInput.on("keypress", handleKeypress);
|
|
1713
|
+
renderer.reset();
|
|
1714
|
+
if (guide === null) return;
|
|
1715
|
+
cancelGeneration();
|
|
1716
|
+
resolveOnce({ action: "refine", guide });
|
|
1717
|
+
cleanup();
|
|
1594
1718
|
};
|
|
1595
1719
|
const handleKeypress = async (_str, key) => {
|
|
1596
1720
|
if (!key) return;
|
|
@@ -1602,10 +1726,14 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
|
|
|
1602
1726
|
confirmSelection();
|
|
1603
1727
|
return;
|
|
1604
1728
|
}
|
|
1605
|
-
if (key.name === "r") {
|
|
1729
|
+
if (key.name === "r" && !key.shift) {
|
|
1606
1730
|
rerollSelection();
|
|
1607
1731
|
return;
|
|
1608
1732
|
}
|
|
1733
|
+
if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
|
|
1734
|
+
await refineSelection();
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1609
1737
|
if (key.name === "e") {
|
|
1610
1738
|
await editSelection();
|
|
1611
1739
|
return;
|
|
@@ -1807,6 +1935,16 @@ function normalizeGuide(value) {
|
|
|
1807
1935
|
if (!trimmed) return void 0;
|
|
1808
1936
|
return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
|
|
1809
1937
|
}
|
|
1938
|
+
function composeGuidance(baseGuide, guideHint) {
|
|
1939
|
+
const normalizedBase = baseGuide?.trim() ?? "";
|
|
1940
|
+
const normalizedGuideHint = guideHint?.trim() ?? "";
|
|
1941
|
+
if (!normalizedBase && !normalizedGuideHint) return void 0;
|
|
1942
|
+
if (!normalizedBase) return normalizedGuideHint;
|
|
1943
|
+
if (!normalizedGuideHint) return normalizedBase;
|
|
1944
|
+
return `${normalizedBase}
|
|
1945
|
+
|
|
1946
|
+
Refinement: ${normalizedGuideHint}`;
|
|
1947
|
+
}
|
|
1810
1948
|
function exitWithInvalidModelError(error) {
|
|
1811
1949
|
console.error(`Error: Model '${error.model}' is not supported.`);
|
|
1812
1950
|
if (error.allowedModels.length > 0) {
|
|
@@ -1922,6 +2060,7 @@ async function commit(args2) {
|
|
|
1922
2060
|
const commandExecutionSignal = abortController.signal;
|
|
1923
2061
|
const commandExecutionPromise = promise;
|
|
1924
2062
|
const apiClient = api;
|
|
2063
|
+
let guideHint;
|
|
1925
2064
|
commandExecutionPromise.catch(async (error) => {
|
|
1926
2065
|
abortController.abort(abortReasonForError(error));
|
|
1927
2066
|
await handleCommandExecutionError(error, {
|
|
@@ -1946,7 +2085,7 @@ async function commit(args2) {
|
|
|
1946
2085
|
const createCandidates = (signal) => generateCommitMessages({
|
|
1947
2086
|
diff,
|
|
1948
2087
|
models,
|
|
1949
|
-
guide: options.guide,
|
|
2088
|
+
guide: composeGuidance(options.guide, guideHint),
|
|
1950
2089
|
signal: mergeAbortSignals(signal, commandExecutionSignal),
|
|
1951
2090
|
cliSessionId,
|
|
1952
2091
|
commandExecutionPromise,
|
|
@@ -1956,7 +2095,7 @@ async function commit(args2) {
|
|
|
1956
2095
|
const gen = generateCommitMessages({
|
|
1957
2096
|
diff,
|
|
1958
2097
|
models: models.slice(0, 1),
|
|
1959
|
-
guide: options.guide,
|
|
2098
|
+
guide: composeGuidance(options.guide, guideHint),
|
|
1960
2099
|
signal: commandExecutionSignal,
|
|
1961
2100
|
cliSessionId,
|
|
1962
2101
|
commandExecutionPromise,
|
|
@@ -1995,6 +2134,10 @@ async function commit(args2) {
|
|
|
1995
2134
|
if (result.action === "reroll") {
|
|
1996
2135
|
continue;
|
|
1997
2136
|
}
|
|
2137
|
+
if (result.action === "refine" && result.guide !== void 0) {
|
|
2138
|
+
guideHint = result.guide.trim() || void 0;
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
1998
2141
|
if (result.action === "confirm" && result.selected) {
|
|
1999
2142
|
await recordSelection(result.selectedCandidate?.generationId);
|
|
2000
2143
|
const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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.
|
|
1499
|
-
|
|
1500
|
-
ttyInput.
|
|
1501
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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:
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
|
1690
|
+
edited = result || candidate.content;
|
|
1608
1691
|
} catch {
|
|
1692
|
+
} finally {
|
|
1693
|
+
isEditorOpen = false;
|
|
1609
1694
|
}
|
|
1610
|
-
|
|
1695
|
+
editedSelections.set(candidate.slotId, edited);
|
|
1611
1696
|
renderer.reset();
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2212
|
+
resolveGuide()
|
|
2073
2213
|
);
|
|
2074
2214
|
return;
|
|
2075
2215
|
}
|
|
2076
|
-
await runInteractiveDescribe(
|
|
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.
|
|
2733
|
+
version: "0.1.5",
|
|
2575
2734
|
description: "LLM-powered development workflow assistant",
|
|
2576
2735
|
type: "module",
|
|
2577
2736
|
license: "MIT",
|