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 +36 -3
- package/dist/git-ultrahope.js +267 -61
- package/dist/index.js +225 -66
- package/package.json +1 -1
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`
|
|
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
|
package/dist/git-ultrahope.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
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) => {
|
|
1390
|
+
let cleanupDone = false;
|
|
1391
|
+
const cleanupTempArtifacts = () => {
|
|
1392
|
+
if (cleanupDone) return;
|
|
1393
|
+
cleanupDone = true;
|
|
1358
1394
|
try {
|
|
1359
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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.
|
|
1474
|
-
|
|
1475
|
-
ttyInput.
|
|
1476
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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:
|
|
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
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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
|
|
1671
|
+
edited = result || candidate.content;
|
|
1583
1672
|
} catch {
|
|
1673
|
+
} finally {
|
|
1674
|
+
isEditorOpen = false;
|
|
1584
1675
|
}
|
|
1585
|
-
|
|
1676
|
+
editedSelections.set(candidate.slotId, edited);
|
|
1586
1677
|
renderer.reset();
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
2073
|
+
let diff = getStagedDiff();
|
|
1892
2074
|
if (!diff.trim()) {
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
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.6",
|
|
2575
2734
|
description: "LLM-powered development workflow assistant",
|
|
2576
2735
|
type: "module",
|
|
2577
2736
|
license: "MIT",
|