ultrahope 0.1.6 → 0.1.7

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.
@@ -90,6 +90,17 @@ var InvalidModelError = class extends Error {
90
90
  this.name = "InvalidModelError";
91
91
  }
92
92
  };
93
+ var InputLengthExceededError = class extends Error {
94
+ constructor(count, limit, plan = "free", message) {
95
+ super(
96
+ message ?? `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`
97
+ );
98
+ this.count = count;
99
+ this.limit = limit;
100
+ this.plan = plan;
101
+ this.name = "InputLengthExceededError";
102
+ }
103
+ };
93
104
  async function getErrorText(response, error) {
94
105
  if (error) {
95
106
  try {
@@ -149,6 +160,15 @@ function handle402Error(error) {
149
160
  log("generate error (402 daily_limit)", error);
150
161
  throw new DailyLimitExceededError(count, limit, resetsAt);
151
162
  }
163
+ function throwInputLengthExceededError(error) {
164
+ const payload = error;
165
+ const count = typeof payload?.count === "number" ? payload.count : 0;
166
+ const limit = typeof payload?.limit === "number" ? payload.limit : 0;
167
+ const plan = payload?.plan === "free" ? payload.plan : "free";
168
+ const message = typeof payload?.message === "string" ? payload.message : `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`;
169
+ log("generate error (400 input_too_long)", error);
170
+ throw new InputLengthExceededError(count, limit, plan, message);
171
+ }
152
172
  function throwInvalidModelError(error) {
153
173
  const payload = error;
154
174
  const message = payload?.message ?? "Model is not supported.";
@@ -160,6 +180,13 @@ function throwInvalidModelError(error) {
160
180
  log("generate error (400 invalid_model)", error);
161
181
  throw new InvalidModelError(model, allowedModels, message);
162
182
  }
183
+ function handle400Error(error) {
184
+ const payload = error;
185
+ if (payload?.error === "input_too_long") {
186
+ throwInputLengthExceededError(error);
187
+ }
188
+ throwInvalidModelError(error);
189
+ }
163
190
  function createApiClient(token) {
164
191
  const headers = {
165
192
  "Content-Type": "application/json"
@@ -203,7 +230,7 @@ function createApiClient(token) {
203
230
  } catch {
204
231
  errorPayload = await getErrorText(res, null);
205
232
  }
206
- throwInvalidModelError(errorPayload);
233
+ handle400Error(errorPayload);
207
234
  }
208
235
  if (!res.ok) {
209
236
  const text = await getErrorText(res, null);
@@ -268,6 +295,9 @@ function createApiClient(token) {
268
295
  log("command_execution error (402)", error);
269
296
  handle402Error(error);
270
297
  }
298
+ if (response.status === 400) {
299
+ handle400Error(error);
300
+ }
271
301
  if (!response.ok) {
272
302
  const text = await getErrorText(response, error);
273
303
  log("command_execution error", { status: response.status, text });
@@ -296,7 +326,7 @@ function createApiClient(token) {
296
326
  handle402Error(error);
297
327
  }
298
328
  if (response.status === 400) {
299
- throwInvalidModelError(error);
329
+ handle400Error(error);
300
330
  }
301
331
  if (!response.ok) {
302
332
  const text = await getErrorText(response, error);
@@ -350,7 +380,7 @@ function createApiClient(token) {
350
380
  handle402Error(error);
351
381
  }
352
382
  if (response.status === 400) {
353
- throwInvalidModelError(error);
383
+ handle400Error(error);
354
384
  }
355
385
  if (!response.ok) {
356
386
  const text = await getErrorText(response, error);
@@ -377,7 +407,7 @@ function createApiClient(token) {
377
407
  handle402Error(error);
378
408
  }
379
409
  if (response.status === 400) {
380
- throwInvalidModelError(error);
410
+ handle400Error(error);
381
411
  }
382
412
  if (!response.ok) {
383
413
  const text = await getErrorText(response, error);
@@ -540,9 +570,6 @@ var theme = {
540
570
  };
541
571
 
542
572
  // lib/ui.ts
543
- function formatTotalCost(cost) {
544
- return `$${cost.toFixed(6)}`;
545
- }
546
573
  var ui = {
547
574
  success: (msg) => `${theme.success}\u2714${theme.reset} ${theme.primary}${msg}${theme.reset}`,
548
575
  progress: (msg) => `${theme.progress}\u25B6${theme.reset} ${theme.primary}${msg}${theme.reset}`,
@@ -756,6 +783,16 @@ async function handleCommandExecutionError(error, options) {
756
783
  console.error(error.formatMessage());
757
784
  process.exit(1);
758
785
  }
786
+ if (error instanceof InputLengthExceededError) {
787
+ console.error("\x1B[31m\u2716\x1B[0m Input is too long for the Free plan.");
788
+ console.error(
789
+ ` Max allowed characters: ${error.limit}. Received: ${error.count}.`
790
+ );
791
+ console.error(
792
+ " Please shorten your input or upgrade to Pro for unlimited input length."
793
+ );
794
+ process.exit(1);
795
+ }
759
796
  const message = error instanceof Error ? error.message : String(error);
760
797
  console.error(`Error: Failed to start command execution. ${message}`);
761
798
  process.exit(1);
@@ -1152,6 +1189,63 @@ function formatDiffStats(stats) {
1152
1189
  return parts.join(", ");
1153
1190
  }
1154
1191
 
1192
+ // lib/renderer.ts
1193
+ import * as readline2 from "readline";
1194
+ var SPINNER_FRAMES = [
1195
+ "\u280B",
1196
+ "\u2819",
1197
+ "\u2839",
1198
+ "\u2838",
1199
+ "\u283C",
1200
+ "\u2834",
1201
+ "\u2826",
1202
+ "\u2827",
1203
+ "\u2807",
1204
+ "\u280F"
1205
+ ];
1206
+ function isTTY(output) {
1207
+ return output.isTTY === true;
1208
+ }
1209
+ function createRenderer(output) {
1210
+ let pendingHeight = 0;
1211
+ let committedHeight = 0;
1212
+ const render = (content) => {
1213
+ if (!isTTY(output)) {
1214
+ output.write(content);
1215
+ return;
1216
+ }
1217
+ if (pendingHeight > 0) {
1218
+ readline2.moveCursor(output, 0, -pendingHeight);
1219
+ readline2.cursorTo(output, 0);
1220
+ readline2.clearScreenDown(output);
1221
+ }
1222
+ output.write(content);
1223
+ pendingHeight = content.split("\n").length - 1;
1224
+ };
1225
+ const flush = () => {
1226
+ committedHeight += pendingHeight;
1227
+ pendingHeight = 0;
1228
+ };
1229
+ const clearAll = () => {
1230
+ if (!isTTY(output)) {
1231
+ return;
1232
+ }
1233
+ const totalHeight = pendingHeight + committedHeight;
1234
+ if (totalHeight > 0) {
1235
+ readline2.moveCursor(output, 0, -totalHeight);
1236
+ readline2.cursorTo(output, 0);
1237
+ readline2.clearScreenDown(output);
1238
+ }
1239
+ pendingHeight = 0;
1240
+ committedHeight = 0;
1241
+ };
1242
+ const reset = () => {
1243
+ pendingHeight = 0;
1244
+ committedHeight = 0;
1245
+ };
1246
+ return { render, flush, clearAll, reset };
1247
+ }
1248
+
1155
1249
  // lib/selector.ts
1156
1250
  import { spawn } from "child_process";
1157
1251
  import {
@@ -1214,9 +1308,8 @@ function getSelectedCandidate(slots, selectedIndex) {
1214
1308
  return slot?.status === "ready" ? slot.candidate : void 0;
1215
1309
  }
1216
1310
 
1217
- // lib/renderer.ts
1218
- import * as readline2 from "readline";
1219
- var SPINNER_FRAMES = [
1311
+ // ../shared/terminal-selector-view-model.ts
1312
+ var DEFAULT_SPINNER_FRAMES = [
1220
1313
  "\u280B",
1221
1314
  "\u2819",
1222
1315
  "\u2839",
@@ -1228,51 +1321,199 @@ var SPINNER_FRAMES = [
1228
1321
  "\u2807",
1229
1322
  "\u280F"
1230
1323
  ];
1231
- function isTTY(output) {
1232
- return output.isTTY === true;
1233
- }
1234
- function createRenderer(output) {
1235
- let pendingHeight = 0;
1236
- let committedHeight = 0;
1237
- const render = (content) => {
1238
- if (!isTTY(output)) {
1239
- output.write(content);
1240
- return;
1241
- }
1242
- if (pendingHeight > 0) {
1243
- readline2.moveCursor(output, 0, -pendingHeight);
1244
- readline2.cursorTo(output, 0);
1245
- readline2.clearScreenDown(output);
1324
+ var HINT_ACTION_ORDER = [
1325
+ "navigate",
1326
+ "confirm",
1327
+ "clickConfirm",
1328
+ "edit",
1329
+ "reroll",
1330
+ "refine",
1331
+ "quit"
1332
+ ];
1333
+ var HINT_ACTION_GROUPS = [
1334
+ ["navigate", "confirm", "clickConfirm"],
1335
+ ["edit", "reroll", "refine"],
1336
+ ["quit"]
1337
+ ];
1338
+ var DEFAULT_HINT_LABELS = {
1339
+ cli: {
1340
+ navigate: "\u2191\u2193 navigate",
1341
+ confirm: "\u23CE confirm",
1342
+ clickConfirm: "click confirm",
1343
+ edit: "(e)dit",
1344
+ reroll: "(r)eroll",
1345
+ refine: "(R)efine",
1346
+ quit: "(q)uit"
1347
+ },
1348
+ web: {
1349
+ navigate: "\u2191\u2193 navigate",
1350
+ confirm: "enter confirm",
1351
+ clickConfirm: "click confirm",
1352
+ edit: "(e)dit",
1353
+ reroll: "(r)eroll",
1354
+ refine: "(R)efine",
1355
+ quit: "(q)uit"
1356
+ }
1357
+ };
1358
+ var SELECTOR_HINT_ACTION_LABELS = DEFAULT_HINT_LABELS;
1359
+ var DEFAULT_SELECTOR_COPY = {
1360
+ runningLabel: "Generating commit messages...",
1361
+ selectionLabel: "Select a commit message",
1362
+ itemLabelSingular: "commit message",
1363
+ itemLabelPlural: "commit messages"
1364
+ };
1365
+ var DEFAULT_SELECTOR_CAPABILITIES = {
1366
+ clickConfirm: false,
1367
+ edit: false,
1368
+ refine: false
1369
+ };
1370
+ function formatDuration(ms) {
1371
+ const safeMs = Math.max(0, Math.round(ms));
1372
+ if (safeMs < 1e3) {
1373
+ return `${safeMs}ms`;
1374
+ }
1375
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1376
+ return `${seconds}s`;
1377
+ }
1378
+ function formatSelectorHintActions(actions, target, options = {}) {
1379
+ const labels = SELECTOR_HINT_ACTION_LABELS[target];
1380
+ const ordered = normalizeHintActions(actions);
1381
+ if (options.separator) {
1382
+ return ordered.map((action) => labels[action]).join(options.separator);
1383
+ }
1384
+ return HINT_ACTION_GROUPS.map(
1385
+ (group) => group.filter((action) => ordered.includes(action)).map((action) => labels[action]).join(" ")
1386
+ ).filter((groupText) => groupText !== "").join(" | ");
1387
+ }
1388
+ function normalizeHintActions(actions) {
1389
+ const set = new Set(actions);
1390
+ const ordered = [];
1391
+ for (const action of HINT_ACTION_ORDER) {
1392
+ if (set.has(action)) {
1393
+ ordered.push(action);
1246
1394
  }
1247
- output.write(content);
1248
- pendingHeight = content.split("\n").length - 1;
1249
- };
1250
- const flush = () => {
1251
- committedHeight += pendingHeight;
1252
- pendingHeight = 0;
1395
+ }
1396
+ return ordered;
1397
+ }
1398
+ function resolveHintActions(input) {
1399
+ if (input.readyCount <= 0) {
1400
+ return ["quit"];
1401
+ }
1402
+ const actions = [
1403
+ "navigate",
1404
+ "confirm",
1405
+ "reroll",
1406
+ "quit"
1407
+ ];
1408
+ if (input.capabilities.clickConfirm) {
1409
+ actions.push("clickConfirm");
1410
+ }
1411
+ if (input.capabilities.edit) {
1412
+ actions.push("edit");
1413
+ }
1414
+ if (input.capabilities.refine) {
1415
+ actions.push("refine");
1416
+ }
1417
+ return normalizeHintActions(actions);
1418
+ }
1419
+ function formatReadyMeta(slot) {
1420
+ const { candidate } = slot;
1421
+ if (!candidate.model) {
1422
+ return "";
1423
+ }
1424
+ const formattedModel = formatModelName(candidate.model);
1425
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1426
+ if (candidate.cost != null) {
1427
+ return `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}`;
1428
+ }
1429
+ return `${formattedModel}${formattedDuration}`;
1430
+ }
1431
+ function createSlotViewModel(slot, index, selectedIndex) {
1432
+ if (slot.status === "pending") {
1433
+ return {
1434
+ status: "pending",
1435
+ selected: false,
1436
+ radio: "\u25CB",
1437
+ title: "Generating...",
1438
+ meta: slot.model ? formatModelName(slot.model) : void 0,
1439
+ muted: true
1440
+ };
1441
+ }
1442
+ if (slot.status === "error") {
1443
+ return {
1444
+ status: "error",
1445
+ selected: false,
1446
+ radio: "\u25CB",
1447
+ title: slot.content,
1448
+ muted: true
1449
+ };
1450
+ }
1451
+ const selected = index === selectedIndex;
1452
+ const title = slot.candidate.content.split("\n")[0]?.trim() || "";
1453
+ const meta = formatReadyMeta(slot);
1454
+ return {
1455
+ status: "ready",
1456
+ selected,
1457
+ radio: selected ? "\u25CF" : "\u25CB",
1458
+ title,
1459
+ meta: meta || void 0,
1460
+ muted: !selected
1253
1461
  };
1254
- const clearAll = () => {
1255
- if (!isTTY(output)) {
1256
- return;
1257
- }
1258
- const totalHeight = pendingHeight + committedHeight;
1259
- if (totalHeight > 0) {
1260
- readline2.moveCursor(output, 0, -totalHeight);
1261
- readline2.cursorTo(output, 0);
1262
- readline2.clearScreenDown(output);
1263
- }
1264
- pendingHeight = 0;
1265
- committedHeight = 0;
1462
+ }
1463
+ function resolveEditedSummary(input) {
1464
+ const selectedSlot = input.state.slots[input.state.selectedIndex];
1465
+ if (selectedSlot?.status !== "ready") {
1466
+ return void 0;
1467
+ }
1468
+ const edited = input.editedSelections?.get(selectedSlot.candidate.slotId);
1469
+ if (!edited) {
1470
+ return void 0;
1471
+ }
1472
+ return edited.split("\n")[0]?.slice(0, 120) || "";
1473
+ }
1474
+ function buildSelectorViewModel(input) {
1475
+ const spinnerFrames = input.spinnerFrames ?? DEFAULT_SPINNER_FRAMES;
1476
+ const copy = { ...DEFAULT_SELECTOR_COPY, ...input.copy };
1477
+ const capabilities = {
1478
+ ...DEFAULT_SELECTOR_CAPABILITIES,
1479
+ ...input.capabilities
1266
1480
  };
1267
- const reset = () => {
1268
- pendingHeight = 0;
1269
- committedHeight = 0;
1481
+ const readyCount = getReadyCount(input.state.slots);
1482
+ const totalCost = getTotalCost(input.state.slots);
1483
+ const frame = Math.floor(input.nowMs / 80) % spinnerFrames.length;
1484
+ const generatedLabel = readyCount === 1 ? `Generated 1 ${copy.itemLabelSingular}` : `Generated ${readyCount} ${copy.itemLabelPlural}`;
1485
+ const hintActions = resolveHintActions({
1486
+ readyCount,
1487
+ capabilities
1488
+ });
1489
+ return {
1490
+ header: {
1491
+ mode: input.state.isGenerating ? "running" : "done",
1492
+ spinner: spinnerFrames[frame],
1493
+ progress: `${readyCount}/${input.state.totalSlots}`,
1494
+ totalCostLabel: totalCost > 0 ? formatTotalCostLabel(totalCost) : void 0,
1495
+ runningLabel: copy.runningLabel,
1496
+ generatedLabel
1497
+ },
1498
+ hint: {
1499
+ kind: readyCount > 0 ? "ready" : "empty",
1500
+ selectionLabel: readyCount > 0 ? copy.selectionLabel : void 0,
1501
+ actions: hintActions
1502
+ },
1503
+ slots: input.state.slots.map(
1504
+ (slot, index) => createSlotViewModel(slot, index, input.state.selectedIndex)
1505
+ ),
1506
+ editedSummary: resolveEditedSummary(input)
1270
1507
  };
1271
- return { render, flush, clearAll, reset };
1272
1508
  }
1273
1509
 
1274
1510
  // lib/selector.ts
1275
1511
  var TTY_PATH = "/dev/tty";
1512
+ var CLI_HINT_GROUPS = [
1513
+ ["navigate", "confirm", "clickConfirm"],
1514
+ ["edit", "reroll", "refine"],
1515
+ ["quit"]
1516
+ ];
1276
1517
  function collapseToReady(slots) {
1277
1518
  const readySlots = slots.filter((s) => s.status === "ready");
1278
1519
  slots.length = 0;
@@ -1280,77 +1521,58 @@ function collapseToReady(slots) {
1280
1521
  slots.push(slot);
1281
1522
  }
1282
1523
  }
1283
- function formatSlot(slot, selected) {
1284
- if (slot.status === "pending") {
1285
- const radio2 = "\u25CB";
1286
- const line2 = `${theme.dim} ${radio2} Generating...${theme.reset}`;
1287
- const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
1288
- return meta2 ? [line2, meta2] : [line2];
1289
- }
1290
- if (slot.status === "error") {
1291
- const radio2 = "\u25CB";
1292
- const line2 = `${theme.dim} ${radio2} ${slot.content}${theme.reset}`;
1293
- return [line2];
1294
- }
1295
- const candidate = slot.candidate;
1296
- const title = candidate.content.split("\n")[0]?.trim() || "";
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}` : "";
1308
- if (selected) {
1309
- const radio2 = "\u25CF";
1310
- const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
1311
- const meta2 = modelInfo ? ` ${theme.progress}${modelInfo}${theme.reset}` : "";
1524
+ function renderSlotMeta(meta, muted) {
1525
+ return muted ? `${theme.dim}${meta}${theme.reset}` : `${theme.primary}${meta}${theme.reset}`;
1526
+ }
1527
+ function renderCliSlotLines(slot) {
1528
+ const radio = slot.selected ? `${theme.success}${slot.radio}${theme.reset}` : `${theme.dim}${slot.radio}${theme.reset}`;
1529
+ const linePrefix = ` ${radio} `;
1530
+ if (slot.status === "ready" && slot.selected) {
1531
+ const line2 = `${linePrefix}${theme.primary}${theme.bold}${slot.title}${theme.reset}`;
1532
+ const meta2 = slot.meta ? ` ${renderSlotMeta(slot.meta, false)}` : "";
1312
1533
  return meta2 ? [line2, meta2] : [line2];
1313
1534
  }
1314
- const radio = "\u25CB";
1315
- const line = `${theme.dim} ${radio} ${title}${theme.reset}`;
1316
- const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1535
+ const line = `${linePrefix}${theme.dim}${slot.title}${theme.reset}`;
1536
+ const meta = slot.meta ? ` ${renderSlotMeta(slot.meta, true)}` : "";
1317
1537
  return meta ? [line, meta] : [line];
1318
1538
  }
1539
+ function renderCliHintLine(actions, readyCount) {
1540
+ const actionSet = new Set(actions);
1541
+ const renderedGroups = CLI_HINT_GROUPS.map(
1542
+ (group) => group.filter((action) => actionSet.has(action)).map((action) => {
1543
+ const label = formatSelectorHintActions([action], "cli");
1544
+ if (action === "navigate" && readyCount <= 1) {
1545
+ return `${theme.dim}${label}${theme.reset}`;
1546
+ }
1547
+ return `${theme.primary}${label}${theme.reset}`;
1548
+ }).join(" ")
1549
+ ).filter((groupText) => groupText !== "");
1550
+ const separator = ` ${theme.primary}|${theme.reset} `;
1551
+ return ` ${renderedGroups.join(separator)}`;
1552
+ }
1319
1553
  function renderSelector(state, nowMs, renderer, editedSelections) {
1320
- const { slots, selectedIndex, isGenerating, totalSlots } = state;
1321
1554
  const lines = [];
1322
- const readyCount = getReadyCount(slots);
1323
- const totalCost = getTotalCost(slots);
1324
- const costSuffix = totalCost > 0 ? ` (total: ${formatTotalCostLabel(totalCost)})` : "";
1325
- if (isGenerating) {
1326
- const frameIndex = Math.floor(nowMs / 80) % SPINNER_FRAMES.length;
1327
- const spinner = SPINNER_FRAMES[frameIndex];
1328
- const progress = `${readyCount}/${totalSlots}`;
1555
+ const viewModel = buildSelectorViewModel({
1556
+ state,
1557
+ nowMs,
1558
+ spinnerFrames: SPINNER_FRAMES,
1559
+ editedSelections,
1560
+ capabilities: {
1561
+ edit: true,
1562
+ refine: true
1563
+ }
1564
+ });
1565
+ const costSuffix = viewModel.header.totalCostLabel ? ` (total: ${viewModel.header.totalCostLabel})` : "";
1566
+ if (viewModel.header.mode === "running") {
1329
1567
  lines.push(
1330
- `${theme.progress}${spinner}${theme.reset} ${theme.primary}Generating commit messages... ${progress}${costSuffix}${theme.reset}`
1568
+ `${theme.progress}${viewModel.header.spinner}${theme.reset} ${theme.primary}${viewModel.header.runningLabel} ${viewModel.header.progress}${costSuffix}${theme.reset}`
1331
1569
  );
1332
1570
  } else {
1333
- const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1334
- lines.push(ui.success(label));
1335
- }
1336
- const selectedSlot = slots[selectedIndex];
1337
- const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1338
- const hasReady = readyCount > 0;
1339
- if (hasReady) {
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
- }
1348
- } else {
1349
- lines.push(ui.hint(" q quit"));
1571
+ lines.push(ui.success(`${viewModel.header.generatedLabel}${costSuffix}`));
1350
1572
  }
1351
1573
  lines.push("");
1352
- for (let i = 0; i < slots.length; i++) {
1353
- const slotLines = formatSlot(slots[i], i === selectedIndex);
1574
+ for (const slot of viewModel.slots) {
1575
+ const slotLines = renderCliSlotLines(slot);
1354
1576
  for (const line of slotLines) {
1355
1577
  lines.push(line);
1356
1578
  }
@@ -1358,13 +1580,19 @@ function renderSelector(state, nowMs, renderer, editedSelections) {
1358
1580
  lines.push("");
1359
1581
  }
1360
1582
  }
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
- }
1583
+ const readyCount = viewModel.slots.filter(
1584
+ (slot) => slot.status === "ready"
1585
+ ).length;
1586
+ if (viewModel.hint.kind === "ready") {
1587
+ lines.push(renderCliHintLine(viewModel.hint.actions, readyCount));
1588
+ } else {
1589
+ lines.push(
1590
+ ui.hint(` ${formatSelectorHintActions(viewModel.hint.actions, "cli")}`)
1591
+ );
1592
+ }
1593
+ if (viewModel.editedSummary) {
1594
+ lines.push(ui.success(`Edited: ${viewModel.editedSummary}`));
1595
+ lines.push("");
1368
1596
  }
1369
1597
  renderer.render(`${lines.join("\n")}
1370
1598
  `);
@@ -1632,6 +1860,24 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1632
1860
  cancelGeneration();
1633
1861
  const totalCost = getTotalCost(slots);
1634
1862
  const quota = getLatestQuota(slots);
1863
+ if (clearOutput) {
1864
+ const viewModel = buildSelectorViewModel({
1865
+ state,
1866
+ nowMs: Date.now(),
1867
+ spinnerFrames: SPINNER_FRAMES,
1868
+ editedSelections,
1869
+ capabilities: { edit: true, refine: true }
1870
+ });
1871
+ const costSuffix = viewModel.header.totalCostLabel ? ` (total: ${viewModel.header.totalCostLabel})` : "";
1872
+ const selectedTitle = selectedContent.split("\n")[0]?.trim() || selectedContent;
1873
+ renderer.clearAll();
1874
+ ttyOutput.write(
1875
+ `${ui.success(`${viewModel.header.generatedLabel}${costSuffix}`)}
1876
+ `
1877
+ );
1878
+ ttyOutput.write(`${ui.success(`Selected: ${selectedTitle}`)}
1879
+ `);
1880
+ }
1635
1881
  resolveOnce({
1636
1882
  action: "confirm",
1637
1883
  selected: selectedContent,
@@ -1640,7 +1886,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1640
1886
  totalCost: totalCost > 0 ? totalCost : void 0,
1641
1887
  quota
1642
1888
  });
1643
- cleanup(clearOutput);
1889
+ cleanup(false);
1644
1890
  };
1645
1891
  const rerollSelection = () => {
1646
1892
  if (!hasReadySlot(slots)) return;
@@ -1733,7 +1979,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1733
1979
  rerollSelection();
1734
1980
  return;
1735
1981
  }
1736
- if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
1982
+ if (key.name === "r" && (key.shift || key.sequence === "R")) {
1737
1983
  await refineSelection();
1738
1984
  return;
1739
1985
  }
@@ -2056,7 +2302,10 @@ function stageAllChanges() {
2056
2302
  }
2057
2303
  function commitWithMessage(message) {
2058
2304
  try {
2059
- execSync2(`git commit -m ${JSON.stringify(message)}`, { stdio: "inherit" });
2305
+ return execSync2(`git commit -m ${JSON.stringify(message)}`, {
2306
+ stdio: "pipe",
2307
+ encoding: "utf-8"
2308
+ }).trim();
2060
2309
  } catch {
2061
2310
  process.exit(1);
2062
2311
  }
@@ -2200,12 +2449,19 @@ async function commit(args2) {
2200
2449
  continue;
2201
2450
  }
2202
2451
  if (result.action === "confirm" && result.selected) {
2203
- await recordSelection(result.selectedCandidate?.generationId);
2204
- const costLabel = result.totalCost != null ? ` (total: ${formatTotalCost(result.totalCost)})` : "";
2205
- console.log(ui.success(`Message selected${costLabel}`));
2206
- console.log(`${ui.success("Running git commit")}
2452
+ recordSelection(result.selectedCandidate?.generationId);
2453
+ const label = `git commit -m ${JSON.stringify(result.selected)}`;
2454
+ const renderer = createRenderer(process.stderr);
2455
+ renderer.render(`${SPINNER_FRAMES[0]} ${label}
2207
2456
  `);
2208
- commitWithMessage(result.selected);
2457
+ const output = commitWithMessage(result.selected);
2458
+ renderer.clearAll();
2459
+ console.log(ui.success(label));
2460
+ if (output) {
2461
+ for (const line of output.split("\n")) {
2462
+ console.log(ui.hint(` ${line}`));
2463
+ }
2464
+ }
2209
2465
  if (result.quota) {
2210
2466
  showQuotaInfo(result.quota);
2211
2467
  }
package/dist/index.js CHANGED
@@ -87,6 +87,17 @@ var InvalidModelError = class extends Error {
87
87
  this.name = "InvalidModelError";
88
88
  }
89
89
  };
90
+ var InputLengthExceededError = class extends Error {
91
+ constructor(count, limit, plan = "free", message) {
92
+ super(
93
+ message ?? `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`
94
+ );
95
+ this.count = count;
96
+ this.limit = limit;
97
+ this.plan = plan;
98
+ this.name = "InputLengthExceededError";
99
+ }
100
+ };
90
101
  async function getErrorText(response, error) {
91
102
  if (error) {
92
103
  try {
@@ -146,6 +157,15 @@ function handle402Error(error) {
146
157
  log("generate error (402 daily_limit)", error);
147
158
  throw new DailyLimitExceededError(count, limit, resetsAt);
148
159
  }
160
+ function throwInputLengthExceededError(error) {
161
+ const payload = error;
162
+ const count = typeof payload?.count === "number" ? payload.count : 0;
163
+ const limit = typeof payload?.limit === "number" ? payload.limit : 0;
164
+ const plan = payload?.plan === "free" ? payload.plan : "free";
165
+ const message = typeof payload?.message === "string" ? payload.message : `Input length ${count} exceeds the ${plan} plan limit of ${limit} characters.`;
166
+ log("generate error (400 input_too_long)", error);
167
+ throw new InputLengthExceededError(count, limit, plan, message);
168
+ }
149
169
  function throwInvalidModelError(error) {
150
170
  const payload = error;
151
171
  const message = payload?.message ?? "Model is not supported.";
@@ -157,6 +177,13 @@ function throwInvalidModelError(error) {
157
177
  log("generate error (400 invalid_model)", error);
158
178
  throw new InvalidModelError(model, allowedModels, message);
159
179
  }
180
+ function handle400Error(error) {
181
+ const payload = error;
182
+ if (payload?.error === "input_too_long") {
183
+ throwInputLengthExceededError(error);
184
+ }
185
+ throwInvalidModelError(error);
186
+ }
160
187
  function createApiClient(token) {
161
188
  const headers = {
162
189
  "Content-Type": "application/json"
@@ -200,7 +227,7 @@ function createApiClient(token) {
200
227
  } catch {
201
228
  errorPayload = await getErrorText(res, null);
202
229
  }
203
- throwInvalidModelError(errorPayload);
230
+ handle400Error(errorPayload);
204
231
  }
205
232
  if (!res.ok) {
206
233
  const text = await getErrorText(res, null);
@@ -265,6 +292,9 @@ function createApiClient(token) {
265
292
  log("command_execution error (402)", error);
266
293
  handle402Error(error);
267
294
  }
295
+ if (response.status === 400) {
296
+ handle400Error(error);
297
+ }
268
298
  if (!response.ok) {
269
299
  const text = await getErrorText(response, error);
270
300
  log("command_execution error", { status: response.status, text });
@@ -293,7 +323,7 @@ function createApiClient(token) {
293
323
  handle402Error(error);
294
324
  }
295
325
  if (response.status === 400) {
296
- throwInvalidModelError(error);
326
+ handle400Error(error);
297
327
  }
298
328
  if (!response.ok) {
299
329
  const text = await getErrorText(response, error);
@@ -347,7 +377,7 @@ function createApiClient(token) {
347
377
  handle402Error(error);
348
378
  }
349
379
  if (response.status === 400) {
350
- throwInvalidModelError(error);
380
+ handle400Error(error);
351
381
  }
352
382
  if (!response.ok) {
353
383
  const text = await getErrorText(response, error);
@@ -374,7 +404,7 @@ function createApiClient(token) {
374
404
  handle402Error(error);
375
405
  }
376
406
  if (response.status === 400) {
377
- throwInvalidModelError(error);
407
+ handle400Error(error);
378
408
  }
379
409
  if (!response.ok) {
380
410
  const text = await getErrorText(response, error);
@@ -760,6 +790,16 @@ async function handleCommandExecutionError(error, options) {
760
790
  console.error(error.formatMessage());
761
791
  process.exit(1);
762
792
  }
793
+ if (error instanceof InputLengthExceededError) {
794
+ console.error("\x1B[31m\u2716\x1B[0m Input is too long for the Free plan.");
795
+ console.error(
796
+ ` Max allowed characters: ${error.limit}. Received: ${error.count}.`
797
+ );
798
+ console.error(
799
+ " Please shorten your input or upgrade to Pro for unlimited input length."
800
+ );
801
+ process.exit(1);
802
+ }
763
803
  const message = error instanceof Error ? error.message : String(error);
764
804
  console.error(`Error: Failed to start command execution. ${message}`);
765
805
  process.exit(1);
@@ -1171,6 +1211,63 @@ function formatDiffStats(stats) {
1171
1211
  return parts.join(", ");
1172
1212
  }
1173
1213
 
1214
+ // lib/renderer.ts
1215
+ import * as readline2 from "readline";
1216
+ var SPINNER_FRAMES = [
1217
+ "\u280B",
1218
+ "\u2819",
1219
+ "\u2839",
1220
+ "\u2838",
1221
+ "\u283C",
1222
+ "\u2834",
1223
+ "\u2826",
1224
+ "\u2827",
1225
+ "\u2807",
1226
+ "\u280F"
1227
+ ];
1228
+ function isTTY(output) {
1229
+ return output.isTTY === true;
1230
+ }
1231
+ function createRenderer(output) {
1232
+ let pendingHeight = 0;
1233
+ let committedHeight = 0;
1234
+ const render = (content) => {
1235
+ if (!isTTY(output)) {
1236
+ output.write(content);
1237
+ return;
1238
+ }
1239
+ if (pendingHeight > 0) {
1240
+ readline2.moveCursor(output, 0, -pendingHeight);
1241
+ readline2.cursorTo(output, 0);
1242
+ readline2.clearScreenDown(output);
1243
+ }
1244
+ output.write(content);
1245
+ pendingHeight = content.split("\n").length - 1;
1246
+ };
1247
+ const flush = () => {
1248
+ committedHeight += pendingHeight;
1249
+ pendingHeight = 0;
1250
+ };
1251
+ const clearAll = () => {
1252
+ if (!isTTY(output)) {
1253
+ return;
1254
+ }
1255
+ const totalHeight = pendingHeight + committedHeight;
1256
+ if (totalHeight > 0) {
1257
+ readline2.moveCursor(output, 0, -totalHeight);
1258
+ readline2.cursorTo(output, 0);
1259
+ readline2.clearScreenDown(output);
1260
+ }
1261
+ pendingHeight = 0;
1262
+ committedHeight = 0;
1263
+ };
1264
+ const reset = () => {
1265
+ pendingHeight = 0;
1266
+ committedHeight = 0;
1267
+ };
1268
+ return { render, flush, clearAll, reset };
1269
+ }
1270
+
1174
1271
  // lib/selector.ts
1175
1272
  import { spawn } from "child_process";
1176
1273
  import {
@@ -1233,9 +1330,8 @@ function getSelectedCandidate(slots, selectedIndex) {
1233
1330
  return slot?.status === "ready" ? slot.candidate : void 0;
1234
1331
  }
1235
1332
 
1236
- // lib/renderer.ts
1237
- import * as readline2 from "readline";
1238
- var SPINNER_FRAMES = [
1333
+ // ../shared/terminal-selector-view-model.ts
1334
+ var DEFAULT_SPINNER_FRAMES = [
1239
1335
  "\u280B",
1240
1336
  "\u2819",
1241
1337
  "\u2839",
@@ -1247,51 +1343,199 @@ var SPINNER_FRAMES = [
1247
1343
  "\u2807",
1248
1344
  "\u280F"
1249
1345
  ];
1250
- function isTTY(output) {
1251
- return output.isTTY === true;
1346
+ var HINT_ACTION_ORDER = [
1347
+ "navigate",
1348
+ "confirm",
1349
+ "clickConfirm",
1350
+ "edit",
1351
+ "reroll",
1352
+ "refine",
1353
+ "quit"
1354
+ ];
1355
+ var HINT_ACTION_GROUPS = [
1356
+ ["navigate", "confirm", "clickConfirm"],
1357
+ ["edit", "reroll", "refine"],
1358
+ ["quit"]
1359
+ ];
1360
+ var DEFAULT_HINT_LABELS = {
1361
+ cli: {
1362
+ navigate: "\u2191\u2193 navigate",
1363
+ confirm: "\u23CE confirm",
1364
+ clickConfirm: "click confirm",
1365
+ edit: "(e)dit",
1366
+ reroll: "(r)eroll",
1367
+ refine: "(R)efine",
1368
+ quit: "(q)uit"
1369
+ },
1370
+ web: {
1371
+ navigate: "\u2191\u2193 navigate",
1372
+ confirm: "enter confirm",
1373
+ clickConfirm: "click confirm",
1374
+ edit: "(e)dit",
1375
+ reroll: "(r)eroll",
1376
+ refine: "(R)efine",
1377
+ quit: "(q)uit"
1378
+ }
1379
+ };
1380
+ var SELECTOR_HINT_ACTION_LABELS = DEFAULT_HINT_LABELS;
1381
+ var DEFAULT_SELECTOR_COPY = {
1382
+ runningLabel: "Generating commit messages...",
1383
+ selectionLabel: "Select a commit message",
1384
+ itemLabelSingular: "commit message",
1385
+ itemLabelPlural: "commit messages"
1386
+ };
1387
+ var DEFAULT_SELECTOR_CAPABILITIES = {
1388
+ clickConfirm: false,
1389
+ edit: false,
1390
+ refine: false
1391
+ };
1392
+ function formatDuration(ms) {
1393
+ const safeMs = Math.max(0, Math.round(ms));
1394
+ if (safeMs < 1e3) {
1395
+ return `${safeMs}ms`;
1396
+ }
1397
+ const seconds = (safeMs / 1e3).toFixed(1).replace(/\.0$/, "");
1398
+ return `${seconds}s`;
1399
+ }
1400
+ function formatSelectorHintActions(actions, target, options = {}) {
1401
+ const labels = SELECTOR_HINT_ACTION_LABELS[target];
1402
+ const ordered = normalizeHintActions(actions);
1403
+ if (options.separator) {
1404
+ return ordered.map((action) => labels[action]).join(options.separator);
1405
+ }
1406
+ return HINT_ACTION_GROUPS.map(
1407
+ (group) => group.filter((action) => ordered.includes(action)).map((action) => labels[action]).join(" ")
1408
+ ).filter((groupText) => groupText !== "").join(" | ");
1409
+ }
1410
+ function normalizeHintActions(actions) {
1411
+ const set = new Set(actions);
1412
+ const ordered = [];
1413
+ for (const action of HINT_ACTION_ORDER) {
1414
+ if (set.has(action)) {
1415
+ ordered.push(action);
1416
+ }
1417
+ }
1418
+ return ordered;
1419
+ }
1420
+ function resolveHintActions(input) {
1421
+ if (input.readyCount <= 0) {
1422
+ return ["quit"];
1423
+ }
1424
+ const actions = [
1425
+ "navigate",
1426
+ "confirm",
1427
+ "reroll",
1428
+ "quit"
1429
+ ];
1430
+ if (input.capabilities.clickConfirm) {
1431
+ actions.push("clickConfirm");
1432
+ }
1433
+ if (input.capabilities.edit) {
1434
+ actions.push("edit");
1435
+ }
1436
+ if (input.capabilities.refine) {
1437
+ actions.push("refine");
1438
+ }
1439
+ return normalizeHintActions(actions);
1252
1440
  }
1253
- function createRenderer(output) {
1254
- let pendingHeight = 0;
1255
- let committedHeight = 0;
1256
- const render = (content) => {
1257
- if (!isTTY(output)) {
1258
- output.write(content);
1259
- return;
1260
- }
1261
- if (pendingHeight > 0) {
1262
- readline2.moveCursor(output, 0, -pendingHeight);
1263
- readline2.cursorTo(output, 0);
1264
- readline2.clearScreenDown(output);
1265
- }
1266
- output.write(content);
1267
- pendingHeight = content.split("\n").length - 1;
1268
- };
1269
- const flush = () => {
1270
- committedHeight += pendingHeight;
1271
- pendingHeight = 0;
1441
+ function formatReadyMeta(slot) {
1442
+ const { candidate } = slot;
1443
+ if (!candidate.model) {
1444
+ return "";
1445
+ }
1446
+ const formattedModel = formatModelName(candidate.model);
1447
+ const formattedDuration = candidate.generationMs == null ? "" : ` ${formatDuration(candidate.generationMs)}`;
1448
+ if (candidate.cost != null) {
1449
+ return `${formattedModel} ${formatCost(candidate.cost)}${formattedDuration}`;
1450
+ }
1451
+ return `${formattedModel}${formattedDuration}`;
1452
+ }
1453
+ function createSlotViewModel(slot, index, selectedIndex) {
1454
+ if (slot.status === "pending") {
1455
+ return {
1456
+ status: "pending",
1457
+ selected: false,
1458
+ radio: "\u25CB",
1459
+ title: "Generating...",
1460
+ meta: slot.model ? formatModelName(slot.model) : void 0,
1461
+ muted: true
1462
+ };
1463
+ }
1464
+ if (slot.status === "error") {
1465
+ return {
1466
+ status: "error",
1467
+ selected: false,
1468
+ radio: "\u25CB",
1469
+ title: slot.content,
1470
+ muted: true
1471
+ };
1472
+ }
1473
+ const selected = index === selectedIndex;
1474
+ const title = slot.candidate.content.split("\n")[0]?.trim() || "";
1475
+ const meta = formatReadyMeta(slot);
1476
+ return {
1477
+ status: "ready",
1478
+ selected,
1479
+ radio: selected ? "\u25CF" : "\u25CB",
1480
+ title,
1481
+ meta: meta || void 0,
1482
+ muted: !selected
1272
1483
  };
1273
- const clearAll = () => {
1274
- if (!isTTY(output)) {
1275
- return;
1276
- }
1277
- const totalHeight = pendingHeight + committedHeight;
1278
- if (totalHeight > 0) {
1279
- readline2.moveCursor(output, 0, -totalHeight);
1280
- readline2.cursorTo(output, 0);
1281
- readline2.clearScreenDown(output);
1282
- }
1283
- pendingHeight = 0;
1284
- committedHeight = 0;
1484
+ }
1485
+ function resolveEditedSummary(input) {
1486
+ const selectedSlot = input.state.slots[input.state.selectedIndex];
1487
+ if (selectedSlot?.status !== "ready") {
1488
+ return void 0;
1489
+ }
1490
+ const edited = input.editedSelections?.get(selectedSlot.candidate.slotId);
1491
+ if (!edited) {
1492
+ return void 0;
1493
+ }
1494
+ return edited.split("\n")[0]?.slice(0, 120) || "";
1495
+ }
1496
+ function buildSelectorViewModel(input) {
1497
+ const spinnerFrames = input.spinnerFrames ?? DEFAULT_SPINNER_FRAMES;
1498
+ const copy = { ...DEFAULT_SELECTOR_COPY, ...input.copy };
1499
+ const capabilities = {
1500
+ ...DEFAULT_SELECTOR_CAPABILITIES,
1501
+ ...input.capabilities
1285
1502
  };
1286
- const reset = () => {
1287
- pendingHeight = 0;
1288
- committedHeight = 0;
1503
+ const readyCount = getReadyCount(input.state.slots);
1504
+ const totalCost = getTotalCost(input.state.slots);
1505
+ const frame = Math.floor(input.nowMs / 80) % spinnerFrames.length;
1506
+ const generatedLabel = readyCount === 1 ? `Generated 1 ${copy.itemLabelSingular}` : `Generated ${readyCount} ${copy.itemLabelPlural}`;
1507
+ const hintActions = resolveHintActions({
1508
+ readyCount,
1509
+ capabilities
1510
+ });
1511
+ return {
1512
+ header: {
1513
+ mode: input.state.isGenerating ? "running" : "done",
1514
+ spinner: spinnerFrames[frame],
1515
+ progress: `${readyCount}/${input.state.totalSlots}`,
1516
+ totalCostLabel: totalCost > 0 ? formatTotalCostLabel(totalCost) : void 0,
1517
+ runningLabel: copy.runningLabel,
1518
+ generatedLabel
1519
+ },
1520
+ hint: {
1521
+ kind: readyCount > 0 ? "ready" : "empty",
1522
+ selectionLabel: readyCount > 0 ? copy.selectionLabel : void 0,
1523
+ actions: hintActions
1524
+ },
1525
+ slots: input.state.slots.map(
1526
+ (slot, index) => createSlotViewModel(slot, index, input.state.selectedIndex)
1527
+ ),
1528
+ editedSummary: resolveEditedSummary(input)
1289
1529
  };
1290
- return { render, flush, clearAll, reset };
1291
1530
  }
1292
1531
 
1293
1532
  // lib/selector.ts
1294
1533
  var TTY_PATH = "/dev/tty";
1534
+ var CLI_HINT_GROUPS = [
1535
+ ["navigate", "confirm", "clickConfirm"],
1536
+ ["edit", "reroll", "refine"],
1537
+ ["quit"]
1538
+ ];
1295
1539
  function collapseToReady(slots) {
1296
1540
  const readySlots = slots.filter((s) => s.status === "ready");
1297
1541
  slots.length = 0;
@@ -1299,77 +1543,58 @@ function collapseToReady(slots) {
1299
1543
  slots.push(slot);
1300
1544
  }
1301
1545
  }
1302
- function formatSlot(slot, selected) {
1303
- if (slot.status === "pending") {
1304
- const radio2 = "\u25CB";
1305
- const line2 = `${theme.dim} ${radio2} Generating...${theme.reset}`;
1306
- const meta2 = slot.model ? `${theme.dim} ${formatModelName(slot.model)}${theme.reset}` : "";
1307
- return meta2 ? [line2, meta2] : [line2];
1308
- }
1309
- if (slot.status === "error") {
1310
- const radio2 = "\u25CB";
1311
- const line2 = `${theme.dim} ${radio2} ${slot.content}${theme.reset}`;
1312
- return [line2];
1313
- }
1314
- const candidate = slot.candidate;
1315
- const title = candidate.content.split("\n")[0]?.trim() || "";
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}` : "";
1327
- if (selected) {
1328
- const radio2 = "\u25CF";
1329
- const line2 = ` ${radio2} ${theme.bold}${title}${theme.reset}`;
1330
- const meta2 = modelInfo ? ` ${theme.progress}${modelInfo}${theme.reset}` : "";
1546
+ function renderSlotMeta(meta, muted) {
1547
+ return muted ? `${theme.dim}${meta}${theme.reset}` : `${theme.primary}${meta}${theme.reset}`;
1548
+ }
1549
+ function renderCliSlotLines(slot) {
1550
+ const radio = slot.selected ? `${theme.success}${slot.radio}${theme.reset}` : `${theme.dim}${slot.radio}${theme.reset}`;
1551
+ const linePrefix = ` ${radio} `;
1552
+ if (slot.status === "ready" && slot.selected) {
1553
+ const line2 = `${linePrefix}${theme.primary}${theme.bold}${slot.title}${theme.reset}`;
1554
+ const meta2 = slot.meta ? ` ${renderSlotMeta(slot.meta, false)}` : "";
1331
1555
  return meta2 ? [line2, meta2] : [line2];
1332
1556
  }
1333
- const radio = "\u25CB";
1334
- const line = `${theme.dim} ${radio} ${title}${theme.reset}`;
1335
- const meta = modelInfo ? `${theme.dim} ${modelInfo}${theme.reset}` : "";
1557
+ const line = `${linePrefix}${theme.dim}${slot.title}${theme.reset}`;
1558
+ const meta = slot.meta ? ` ${renderSlotMeta(slot.meta, true)}` : "";
1336
1559
  return meta ? [line, meta] : [line];
1337
1560
  }
1561
+ function renderCliHintLine(actions, readyCount) {
1562
+ const actionSet = new Set(actions);
1563
+ const renderedGroups = CLI_HINT_GROUPS.map(
1564
+ (group) => group.filter((action) => actionSet.has(action)).map((action) => {
1565
+ const label = formatSelectorHintActions([action], "cli");
1566
+ if (action === "navigate" && readyCount <= 1) {
1567
+ return `${theme.dim}${label}${theme.reset}`;
1568
+ }
1569
+ return `${theme.primary}${label}${theme.reset}`;
1570
+ }).join(" ")
1571
+ ).filter((groupText) => groupText !== "");
1572
+ const separator = ` ${theme.primary}|${theme.reset} `;
1573
+ return ` ${renderedGroups.join(separator)}`;
1574
+ }
1338
1575
  function renderSelector(state, nowMs, renderer, editedSelections) {
1339
- const { slots, selectedIndex, isGenerating, totalSlots } = state;
1340
1576
  const lines = [];
1341
- const readyCount = getReadyCount(slots);
1342
- const totalCost = getTotalCost(slots);
1343
- const costSuffix = totalCost > 0 ? ` (total: ${formatTotalCostLabel(totalCost)})` : "";
1344
- if (isGenerating) {
1345
- const frameIndex = Math.floor(nowMs / 80) % SPINNER_FRAMES.length;
1346
- const spinner = SPINNER_FRAMES[frameIndex];
1347
- const progress = `${readyCount}/${totalSlots}`;
1577
+ const viewModel = buildSelectorViewModel({
1578
+ state,
1579
+ nowMs,
1580
+ spinnerFrames: SPINNER_FRAMES,
1581
+ editedSelections,
1582
+ capabilities: {
1583
+ edit: true,
1584
+ refine: true
1585
+ }
1586
+ });
1587
+ const costSuffix = viewModel.header.totalCostLabel ? ` (total: ${viewModel.header.totalCostLabel})` : "";
1588
+ if (viewModel.header.mode === "running") {
1348
1589
  lines.push(
1349
- `${theme.progress}${spinner}${theme.reset} ${theme.primary}Generating commit messages... ${progress}${costSuffix}${theme.reset}`
1590
+ `${theme.progress}${viewModel.header.spinner}${theme.reset} ${theme.primary}${viewModel.header.runningLabel} ${viewModel.header.progress}${costSuffix}${theme.reset}`
1350
1591
  );
1351
1592
  } else {
1352
- const label = readyCount === 1 ? `1 commit message generated${costSuffix}` : `${readyCount} commit messages generated${costSuffix}`;
1353
- lines.push(ui.success(label));
1354
- }
1355
- const selectedSlot = slots[selectedIndex];
1356
- const isEditedSelection = selectedSlot?.status === "ready" && editedSelections?.has(selectedSlot.candidate.slotId) === true;
1357
- const hasReady = readyCount > 0;
1358
- if (hasReady) {
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
- }
1367
- } else {
1368
- lines.push(ui.hint(" q quit"));
1593
+ lines.push(ui.success(`${viewModel.header.generatedLabel}${costSuffix}`));
1369
1594
  }
1370
1595
  lines.push("");
1371
- for (let i = 0; i < slots.length; i++) {
1372
- const slotLines = formatSlot(slots[i], i === selectedIndex);
1596
+ for (const slot of viewModel.slots) {
1597
+ const slotLines = renderCliSlotLines(slot);
1373
1598
  for (const line of slotLines) {
1374
1599
  lines.push(line);
1375
1600
  }
@@ -1377,13 +1602,19 @@ function renderSelector(state, nowMs, renderer, editedSelections) {
1377
1602
  lines.push("");
1378
1603
  }
1379
1604
  }
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
- }
1605
+ const readyCount = viewModel.slots.filter(
1606
+ (slot) => slot.status === "ready"
1607
+ ).length;
1608
+ if (viewModel.hint.kind === "ready") {
1609
+ lines.push(renderCliHintLine(viewModel.hint.actions, readyCount));
1610
+ } else {
1611
+ lines.push(
1612
+ ui.hint(` ${formatSelectorHintActions(viewModel.hint.actions, "cli")}`)
1613
+ );
1614
+ }
1615
+ if (viewModel.editedSummary) {
1616
+ lines.push(ui.success(`Edited: ${viewModel.editedSummary}`));
1617
+ lines.push("");
1387
1618
  }
1388
1619
  renderer.render(`${lines.join("\n")}
1389
1620
  `);
@@ -1651,6 +1882,24 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1651
1882
  cancelGeneration();
1652
1883
  const totalCost = getTotalCost(slots);
1653
1884
  const quota = getLatestQuota(slots);
1885
+ if (clearOutput) {
1886
+ const viewModel = buildSelectorViewModel({
1887
+ state,
1888
+ nowMs: Date.now(),
1889
+ spinnerFrames: SPINNER_FRAMES,
1890
+ editedSelections,
1891
+ capabilities: { edit: true, refine: true }
1892
+ });
1893
+ const costSuffix = viewModel.header.totalCostLabel ? ` (total: ${viewModel.header.totalCostLabel})` : "";
1894
+ const selectedTitle = selectedContent.split("\n")[0]?.trim() || selectedContent;
1895
+ renderer.clearAll();
1896
+ ttyOutput.write(
1897
+ `${ui.success(`${viewModel.header.generatedLabel}${costSuffix}`)}
1898
+ `
1899
+ );
1900
+ ttyOutput.write(`${ui.success(`Selected: ${selectedTitle}`)}
1901
+ `);
1902
+ }
1654
1903
  resolveOnce({
1655
1904
  action: "confirm",
1656
1905
  selected: selectedContent,
@@ -1659,7 +1908,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1659
1908
  totalCost: totalCost > 0 ? totalCost : void 0,
1660
1909
  quota
1661
1910
  });
1662
- cleanup(clearOutput);
1911
+ cleanup(false);
1663
1912
  };
1664
1913
  const rerollSelection = () => {
1665
1914
  if (!hasReadySlot(slots)) return;
@@ -1752,7 +2001,7 @@ async function selectFromSlots(initialSlots, asyncCtx, ttyIo) {
1752
2001
  rerollSelection();
1753
2002
  return;
1754
2003
  }
1755
- if (key.name === "r" && (key.shift || key.name === "R" || key.sequence === "R")) {
2004
+ if (key.name === "r" && (key.shift || key.sequence === "R")) {
1756
2005
  await refineSelection();
1757
2006
  return;
1758
2007
  }
@@ -2037,9 +2286,12 @@ function getJjDiff(revision) {
2037
2286
  }
2038
2287
  function describeRevision(revision, message) {
2039
2288
  try {
2040
- spawnSync("jj", ["describe", "-r", revision, "-m", message], {
2041
- stdio: "inherit"
2042
- });
2289
+ const result = spawnSync(
2290
+ "jj",
2291
+ ["describe", "-r", revision, "-m", message],
2292
+ { stdio: "pipe", encoding: "utf-8" }
2293
+ );
2294
+ return [result.stdout, result.stderr].filter(Boolean).join("").trim();
2043
2295
  } catch {
2044
2296
  process.exit(1);
2045
2297
  }
@@ -2159,15 +2411,22 @@ async function runInteractiveDescribe(options, models, createCandidates, context
2159
2411
  continue;
2160
2412
  }
2161
2413
  if (result.action === "confirm" && result.selected) {
2162
- await recordSelection(
2414
+ recordSelection(
2163
2415
  context.apiClient,
2164
2416
  result.selectedCandidate?.generationId
2165
2417
  );
2166
- console.log(
2167
- `${ui.success(`Running jj describe -r ${options.revision}`)}
2168
- `
2169
- );
2170
- describeRevision(options.revision, result.selected);
2418
+ const label = `jj describe -r ${options.revision} -m ${JSON.stringify(result.selected)}`;
2419
+ const renderer = createRenderer(process.stderr);
2420
+ renderer.render(`${SPINNER_FRAMES[0]} ${label}
2421
+ `);
2422
+ const output = describeRevision(options.revision, result.selected);
2423
+ renderer.clearAll();
2424
+ console.log(ui.success(label));
2425
+ if (output) {
2426
+ for (const line of output.split("\n")) {
2427
+ console.log(ui.hint(` ${line}`));
2428
+ }
2429
+ }
2171
2430
  if (result.quota) {
2172
2431
  showQuotaInfo(result.quota);
2173
2432
  }
@@ -2730,7 +2989,7 @@ function parseArgs(args2) {
2730
2989
  // package.json
2731
2990
  var package_default = {
2732
2991
  name: "ultrahope",
2733
- version: "0.1.6",
2992
+ version: "0.1.7",
2734
2993
  description: "LLM-powered development workflow assistant",
2735
2994
  type: "module",
2736
2995
  license: "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultrahope",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "LLM-powered development workflow assistant",
5
5
  "type": "module",
6
6
  "license": "MIT",