honeydo 0.1.2 → 0.1.4

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.
@@ -6,8 +6,9 @@
6
6
  * `gcli agy ...` routes to the agy CLI (explicit subcommand required); the
7
7
  * claude backend can switch cc-switch providers inline via
8
8
  * `claude -p ... --settings`. Adds value over calling the backends directly:
9
- * prompt via argv or stdin (`-p -`), 50k-char output truncation, a hard
10
- * timeout (spawn SIGTERM), explicit exit codes, and empty-output detection.
9
+ * prompt via argv or stdin (`-p -`), a hard timeout (spawn SIGTERM), explicit
10
+ * exit codes, and empty-output detection. Output is passed through unmodified
11
+ * — size limits are the endpoint's business, not ours.
11
12
  *
12
13
  * The claude backend resolves `--provider <name>` from the cc-switch SQLite
13
14
  * DB (read-only) and never rewrites ~/.claude/settings.json — the provider
@@ -32,7 +33,6 @@ import { emitKeypressEvents } from "node:readline";
32
33
  import { fileURLToPath } from "node:url";
33
34
  import { parseArgs } from "node:util";
34
35
  export const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
35
- export const CHARACTER_LIMIT = 50_000;
36
36
  export const API_DEFAULT_MAX_TOKENS = 80000;
37
37
  const AGY_BIN = "agy";
38
38
  const CLAUDE_BIN = "claude";
@@ -125,12 +125,6 @@ export function parseSubcommand(argv) {
125
125
  return { subcommand: undefined, rest: argv };
126
126
  return { error: `unknown subcommand: ${first}` };
127
127
  }
128
- export function truncate(text) {
129
- if (text.length <= CHARACTER_LIMIT)
130
- return text;
131
- return (text.slice(0, CHARACTER_LIMIT) +
132
- `\n\n[Truncated — response exceeded ${CHARACTER_LIMIT} characters]`);
133
- }
134
128
  /** Translate gcli options into agy argv. Timeout is enforced by spawn kill.
135
129
  * prompt is optional — when undefined, no -p is emitted (interactive mode). */
136
130
  export function buildAgyArgs(opts) {
@@ -233,7 +227,7 @@ export function buildSettingsEnv(providerEnv, model) {
233
227
  * - "return" / "enter" → confirm the current row (the physical Enter key
234
228
  * emits "return" in raw mode; "enter" is the LF byte, which a tty may
235
229
  * substitute for CR in input buffered before raw mode was enabled)
236
- * - "escape" → skip (不切换)
230
+ * - "escape" → skip (退出,不启动后端)
237
231
  * - Emacs (revise-2): ctrl+"n" ≡ down, ctrl+"p" ≡ up (same wrap); ctrl+"g"
238
232
  * ≡ escape → skip; meta+"<" → first row (absolute), meta+">" → last row
239
233
  * (absolute). Horizontal Emacs keys (C-f/C-b/C-a/C-e) and paging (C-v/M-v)
@@ -242,7 +236,7 @@ export function buildSettingsEnv(providerEnv, model) {
242
236
  * exit 130)
243
237
  *
244
238
  * `index` is the highlighted row, `count` the total rendered rows INCLUDING
245
- * the trailing 不切换 row. Movement wraps with `(index±1+count)%count`; with
239
+ * the footer row. Movement wraps with `(index±1+count)%count`; with
246
240
  * count <= 0 moves are a noop (nothing is rendered).
247
241
  */
248
242
  export function applyPickerKey(k, index, count) {
@@ -402,7 +396,9 @@ export function parseKimiUsages(body) {
402
396
  }
403
397
  /**
404
398
  * Parse GLM `/api/monitor/usage/quota/limit` (C-Q2): data.limits[] entries
405
- * with type == "TOKENS_LIMIT"; sorted by nextResetTime ascending — first is
399
+ * with type "TOKENS_LIMIT" (coding-plan 订阅) or "CREDIT_LIMIT" (credit 资源
400
+ * 包) — billing type is per-account, fields are identical, same rendering
401
+ * (parity with statusline-sage); sorted by nextResetTime ascending — first is
406
402
  * the short (5h) window, last the weekly one.
407
403
  */
408
404
  export function parseGlmQuota(body) {
@@ -419,7 +415,7 @@ export function parseGlmQuota(body) {
419
415
  if (typeof item !== "object" || item === null)
420
416
  continue;
421
417
  const r = item;
422
- if (r.type !== "TOKENS_LIMIT")
418
+ if (r.type !== "TOKENS_LIMIT" && r.type !== "CREDIT_LIMIT")
423
419
  continue;
424
420
  const pct = toNum(r.percentage);
425
421
  if (pct === undefined)
@@ -457,20 +453,90 @@ function formatReset(iso, nowMs) {
457
453
  return rh > 0 ? `${d}d${rh}h` : `${d}d`;
458
454
  }
459
455
  /**
460
- * Format quota windows as the menu subtitle (C-Q3): `5h:P% wk:P% ↻<rel>`
461
- * (short reset preferred for the ↻ segment); missing windows degrade; a
462
- * missing/expired reset omits the ↻ segment entirely; no windows → "".
456
+ * Structured render input shared by the plain and the colored quota
457
+ * renderers: one percentage + one relative reset per window (each window's
458
+ * reset computed independently; missing/expired → omitted for that window).
459
+ * No windows at all → empty parts (the plain join then yields "").
460
+ */
461
+ function quotaParts(q, nowMs) {
462
+ const shortPct = q.short?.pct;
463
+ const weeklyPct = q.weekly?.pct;
464
+ if (shortPct === undefined && weeklyPct === undefined)
465
+ return {};
466
+ return {
467
+ shortPct,
468
+ weeklyPct,
469
+ shortResetRel: q.short === undefined ? undefined : formatReset(q.short.resetIso, nowMs),
470
+ weeklyResetRel: q.weekly === undefined
471
+ ? undefined
472
+ : formatReset(q.weekly.resetIso, nowMs),
473
+ };
474
+ }
475
+ /**
476
+ * Format quota windows as the menu subtitle (C-Q3, 2026-09 双窗重置):
477
+ * `5h:P% ↻<rel> wk:P% ↻<rel>` — each window carries its own reset time.
478
+ * Missing windows degrade; a missing/expired reset omits that window's ↻;
479
+ * no windows → "". Byte-exact contract with downstream consumers — the
480
+ * plain-text output is locked by unit tests; colors live in `colorQuota`,
481
+ * never here.
463
482
  */
464
483
  export function formatQuota(q, nowMs) {
484
+ const p = quotaParts(q, nowMs);
465
485
  const parts = [];
466
- if (q.short !== undefined)
467
- parts.push(`5h:${q.short.pct}%`);
468
- if (q.weekly !== undefined)
469
- parts.push(`wk:${q.weekly.pct}%`);
470
- if (parts.length === 0)
471
- return "";
472
- const rel = formatReset(q.short?.resetIso ?? q.weekly?.resetIso, nowMs);
473
- return rel === undefined ? parts.join(" ") : `${parts.join(" ")} ↻${rel}`;
486
+ if (p.shortPct !== undefined) {
487
+ parts.push(p.shortResetRel === undefined
488
+ ? `5h:${p.shortPct}%`
489
+ : `5h:${p.shortPct}% ↻${p.shortResetRel}`);
490
+ }
491
+ if (p.weeklyPct !== undefined) {
492
+ parts.push(p.weeklyResetRel === undefined
493
+ ? `wk:${p.weeklyPct}%`
494
+ : `wk:${p.weeklyPct}% ↻${p.weeklyResetRel}`);
495
+ }
496
+ return parts.join(" ");
497
+ }
498
+ // Limit 染色阈值(对齐 statusline-sage 的 GLM_HIGH/GLM_MID)。
499
+ export const QUOTA_HIGH = 85;
500
+ export const QUOTA_MID = 60;
501
+ /** truecolor Sage 同款三色:#3A7D68 苔绿 / #D4920A 琥珀 / #D94F3D 朱红。 */
502
+ const COLOR_SAGE = "\x1b[38;2;58;125;104m";
503
+ const COLOR_AMBER = "\x1b[38;2;212;146;10m";
504
+ const COLOR_VERMILION = "\x1b[38;2;217;79;61m";
505
+ /** 关闭前景色回到终端默认,但不复位背景——选中行的整行高亮依赖这一点。 */
506
+ const FG_OFF = "\x1b[39m";
507
+ /**
508
+ * Limit 用量 → 段颜色:≥85 朱红、≥60 琥珀、其余(含非有限数)回退苔绿
509
+ * (阈值语义照搬 statusline-sage `_level_color`)。
510
+ */
511
+ export function levelColor(pct) {
512
+ if (!Number.isFinite(pct))
513
+ return COLOR_SAGE;
514
+ if (pct >= QUOTA_HIGH)
515
+ return COLOR_VERMILION;
516
+ if (pct >= QUOTA_MID)
517
+ return COLOR_AMBER;
518
+ return COLOR_SAGE;
519
+ }
520
+ /**
521
+ * `formatQuota` 的染色版(picker 用):`5h:P% ↻rel`/`wk:P% ↻rel` 每个窗口段
522
+ * 按各自 pct 独立选色,各自的重置时间恒 dim,不参与染色。结构与降级规则和
523
+ * `formatQuota` 一致(无窗口 → "")。行内使用 `\x1b[39m`/`\x1b[22m` 收尾而
524
+ * 非全复位,以免抹掉选中行背景。
525
+ */
526
+ export function colorQuota(q, nowMs) {
527
+ const p = quotaParts(q, nowMs);
528
+ const parts = [];
529
+ if (p.shortPct !== undefined) {
530
+ parts.push(p.shortResetRel === undefined
531
+ ? `${levelColor(p.shortPct)}5h:${p.shortPct}%${FG_OFF}`
532
+ : `${levelColor(p.shortPct)}5h:${p.shortPct}%${FG_OFF} \x1b[2m↻${p.shortResetRel}\x1b[22m`);
533
+ }
534
+ if (p.weeklyPct !== undefined) {
535
+ parts.push(p.weeklyResetRel === undefined
536
+ ? `${levelColor(p.weeklyPct)}wk:${p.weeklyPct}%${FG_OFF}`
537
+ : `${levelColor(p.weeklyPct)}wk:${p.weeklyPct}%${FG_OFF} \x1b[2m↻${p.weeklyResetRel}\x1b[22m`);
538
+ }
539
+ return parts.join(" ");
474
540
  }
475
541
  /**
476
542
  * Build claude argv (C8). `--model` is NOT passed here — for the claude
@@ -1189,9 +1255,9 @@ export function parseCliArgs(argv) {
1189
1255
  let timeoutMs = DEFAULT_TIMEOUT_MS;
1190
1256
  if (values.timeout !== undefined) {
1191
1257
  const n = Number(values.timeout);
1192
- if (!Number.isFinite(n) || n < 1000 || n > 1_800_000) {
1258
+ if (!Number.isFinite(n) || n < 1000) {
1193
1259
  return {
1194
- error: `--timeout must be a number in [1000, 1800000], got "${values.timeout}"`,
1260
+ error: `--timeout must be a number >= 1000, got "${values.timeout}"`,
1195
1261
  };
1196
1262
  }
1197
1263
  timeoutMs = n;
@@ -1255,9 +1321,9 @@ export function parseApiArgs(argv) {
1255
1321
  let maxTokens = API_DEFAULT_MAX_TOKENS;
1256
1322
  if (values["max-tokens"] !== undefined) {
1257
1323
  const n = Number(values["max-tokens"]);
1258
- if (!Number.isFinite(n) || n < 1 || n > 200_000) {
1324
+ if (!Number.isFinite(n) || n < 1) {
1259
1325
  return {
1260
- error: `--max-tokens must be a number in [1, 200000], got "${values["max-tokens"]}"`,
1326
+ error: `--max-tokens must be a finite number >= 1, got "${values["max-tokens"]}"`,
1261
1327
  };
1262
1328
  }
1263
1329
  maxTokens = n;
@@ -1265,9 +1331,9 @@ export function parseApiArgs(argv) {
1265
1331
  let timeoutMs = DEFAULT_TIMEOUT_MS;
1266
1332
  if (values.timeout !== undefined) {
1267
1333
  const n = Number(values.timeout);
1268
- if (!Number.isFinite(n) || n < 1000 || n > 1_800_000) {
1334
+ if (!Number.isFinite(n) || n < 1000) {
1269
1335
  return {
1270
- error: `--timeout must be a number in [1000, 1800000], got "${values.timeout}"`,
1336
+ error: `--timeout must be a number >= 1000, got "${values.timeout}"`,
1271
1337
  };
1272
1338
  }
1273
1339
  timeoutMs = n;
@@ -1294,9 +1360,9 @@ export function parseApiArgs(argv) {
1294
1360
  let retries = 1;
1295
1361
  if (values.retry !== undefined) {
1296
1362
  const n = Number(values.retry);
1297
- if (!Number.isInteger(n) || n < 0 || n > 5) {
1363
+ if (!Number.isInteger(n) || n < 0) {
1298
1364
  return {
1299
- error: `--retry must be an integer in [0, 5], got "${values.retry}"`,
1365
+ error: `--retry must be a non-negative integer, got "${values.retry}"`,
1300
1366
  };
1301
1367
  }
1302
1368
  retries = n;
@@ -1408,8 +1474,8 @@ export function runClaude(args, timeoutMs, cwd) {
1408
1474
  /**
1409
1475
  * Spawn a backend with stdio fully inherited (interactive TUI mode).
1410
1476
  *
1411
- * Unlike runAgy/runClaude: no timeout, no stdout/stderr capture, no
1412
- * truncation — the child owns the terminal. The child's exit code is passed
1477
+ * Unlike runAgy/runClaude: no timeout, no stdout/stderr capture —
1478
+ * the child owns the terminal. The child's exit code is passed
1413
1479
  * through unchanged (SIGINT→130, SIGTERM→143 per shell convention).
1414
1480
  */
1415
1481
  function spawnInteractive(bin, args, cwd) {
@@ -1466,7 +1532,7 @@ const API_RETRY_MAX_DELAY_MS = 4000;
1466
1532
  * (timeoutMs). Either firing aborts the fetch via AbortController → exit 1
1467
1533
  * with a timeout message.
1468
1534
  * - stream=false: awaits the full JSON body and extracts `content[].text`,
1469
- * then applies the 50k-char truncation.
1535
+ * returned as-is (no size cap — endpoint limits are the endpoint's call).
1470
1536
  *
1471
1537
  * TRANSIENT failures are retried automatically (default 1 retry, `--retry N`):
1472
1538
  * network-level fetch errors, HTTP 408/429/5xx, malformed JSON, empty-content
@@ -1639,7 +1705,7 @@ async function runApiAttempt(req) {
1639
1705
  };
1640
1706
  }
1641
1707
  return {
1642
- outcome: { exitCode: 0, stdout: truncate(text), stderr: "" },
1708
+ outcome: { exitCode: 0, stdout: text, stderr: "" },
1643
1709
  retryable: false,
1644
1710
  };
1645
1711
  }
@@ -1751,8 +1817,6 @@ async function runApiAttempt(req) {
1751
1817
  retryable: false,
1752
1818
  };
1753
1819
  }
1754
- // Streaming output is not truncated (real-time, contract §A); only the
1755
- // non-stream path truncates.
1756
1820
  return {
1757
1821
  outcome: { exitCode: 0, stdout: aggregated, stderr: "" },
1758
1822
  retryable: false,
@@ -1878,17 +1942,102 @@ function readStdin() {
1878
1942
  // ---------------------------------------------------------------------------
1879
1943
  // Provider picker (TTY only) — arrow keys, zero deps
1880
1944
  // ---------------------------------------------------------------------------
1945
+ // Picker frame colors: 16-color structure + truecolor Sage accents (B2).
1946
+ const DIM_ON = "\x1b[2m";
1947
+ const INTENSITY_OFF = "\x1b[22m";
1948
+ const CYAN = "\x1b[36m";
1949
+ const BOLD_ON = "\x1b[1m";
1950
+ const RESET_ALL = "\x1b[0m";
1951
+ /** 选中行背景 #292e42(truecolor)。 */
1952
+ const SELECTED_BG = "\x1b[48;2;41;46;66m";
1953
+ const PICKER_TITLE_MAIN = "◆ gcli";
1954
+ const PICKER_TITLE_SUB = " · 选择 provider";
1955
+ const PICKER_HINT_KEYS = "↑↓/j/k 移动 · Enter 确认 · Esc 退出";
1956
+ const PICKER_HINT_SKIP = "Esc 退出";
1957
+ const PICKER_SEPARATOR = "─".repeat(50);
1958
+ const NO_QUOTA_MARK = "—";
1959
+ /**
1960
+ * 给 picker 实际持有的 quota 文本(`formatQuota` 输出格式,byte-locked)上
1961
+ * 色:`5h:P%`/`wk:P%` token 按各自 pct 染色,`↻rel` token 恒 dim。与
1962
+ * `colorQuota` 同一视觉,只是入参是已格式化文本(DI 契约里 entries.quota
1963
+ * 为 string,渲染层拿不到结构化窗口)。前提:pct 为整数——生产 parsers
1964
+ * 均 Math.floor 保证;异形 token 优雅退化为不染色。
1965
+ */
1966
+ function colorQuotaText(text) {
1967
+ return text
1968
+ .split(" ")
1969
+ .map((token) => {
1970
+ const win = /^(5h|wk):(\d+)%$/.exec(token);
1971
+ if (win !== null) {
1972
+ return `${levelColor(Number(win[2]))}${token}${FG_OFF}`;
1973
+ }
1974
+ if (token.startsWith("↻"))
1975
+ return `${DIM_ON}${token}${INTENSITY_OFF}`;
1976
+ return token;
1977
+ })
1978
+ .join(" ");
1979
+ }
1980
+ /**
1981
+ * One entry row: `❯ `/two-space prefix | name column (max name width + 2,
1982
+ * left-aligned) | colored quota (or dim — placeholder) | cyan `●<tag>`.
1983
+ * Selected rows add the truecolor background + a bold name and end with a
1984
+ * full reset; unselected rows keep the same structure without background.
1985
+ */
1986
+ function pickerEntryRow(entry, selected, nameWidth, noColor) {
1987
+ const name = entry.name.padEnd(nameWidth);
1988
+ const quotaText = entry.quota !== undefined && entry.quota !== "" ? entry.quota : undefined;
1989
+ const quota = quotaText === undefined
1990
+ ? noColor
1991
+ ? NO_QUOTA_MARK
1992
+ : `${DIM_ON}${NO_QUOTA_MARK}${INTENSITY_OFF}`
1993
+ : noColor
1994
+ ? quotaText
1995
+ : colorQuotaText(quotaText);
1996
+ const tag = entry.tag !== undefined && entry.tag !== ""
1997
+ ? noColor
1998
+ ? ` ●${entry.tag}`
1999
+ : ` ${CYAN}●${entry.tag}${FG_OFF}`
2000
+ : "";
2001
+ if (noColor) {
2002
+ return `${selected ? "❯ " : " "}${name}${quota}${tag}`;
2003
+ }
2004
+ return selected
2005
+ ? `${SELECTED_BG}${COLOR_SAGE}❯${FG_OFF} ${BOLD_ON}${name}${INTENSITY_OFF}${quota}${tag}${RESET_ALL}`
2006
+ : ` ${name}${quota}${tag}`;
2007
+ }
2008
+ /**
2009
+ * The full picker frame as plain lines: 标题 2 行 + dim 分隔线 + 条目 N 行 +
2010
+ * dim 末行提示 (`Esc 退出`, non-selectable — skip 只经 Esc/C-g(skip = 退出不启动)). Pure:
2011
+ * no I/O; with `noColor` (NO_COLOR downgrade) zero ANSI escapes — 仅纯文本
2012
+ * 排版,❯ 缩进 + 列对齐保留。Redraw cursor-up height = `entries.length + 4`.
2013
+ */
2014
+ export function renderPickerRows(entries, selectedIndex, noColor) {
2015
+ const nameWidth = entries.reduce((w, e) => Math.max(w, e.name.length), 0) + 2;
2016
+ const rows = [
2017
+ noColor
2018
+ ? `${PICKER_TITLE_MAIN}${PICKER_TITLE_SUB}`
2019
+ : `${CYAN}${BOLD_ON}${PICKER_TITLE_MAIN}${RESET_ALL}${PICKER_TITLE_SUB}`,
2020
+ noColor ? PICKER_HINT_KEYS : `${DIM_ON}${PICKER_HINT_KEYS}${INTENSITY_OFF}`,
2021
+ noColor ? PICKER_SEPARATOR : `${DIM_ON}${PICKER_SEPARATOR}${INTENSITY_OFF}`,
2022
+ ];
2023
+ for (let i = 0; i < entries.length; i++) {
2024
+ rows.push(pickerEntryRow(entries[i], i === selectedIndex, nameWidth, noColor));
2025
+ }
2026
+ rows.push(noColor ? PICKER_HINT_SKIP : `${DIM_ON}${PICKER_HINT_SKIP}${INTENSITY_OFF}`);
2027
+ return rows;
2028
+ }
1881
2029
  /**
1882
2030
  * Production deps.pickProvider: arrow-key menu rendered entirely on stderr
1883
2031
  * (stdout stays pipe-clean). ↑↓/j/k move with wrap, Enter confirms, Esc
1884
2032
  * skips, ctrl-c restores the terminal then exits 130; any other key is a
1885
- * noop (C-P1).
2033
+ * noop (C-P1). Frame = 标题 2 行 + 分隔线 + 条目 N 行 + dim `Esc 退出`
2034
+ * 末行提示——旧的可选中「不切换」行已退化为提示(skip 走 Esc/C-g),移动
2035
+ * 只覆盖条目行。
1886
2036
  *
1887
2037
  * Zero deps: `readline.emitKeypressEvents` + raw-mode stdin + hand-written
1888
- * ANSI. Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL per line)
1889
- * so navigation leaves no ghosting (C-P3). The trailing 不切换 row is
1890
- * appended here — `entries` holds providers only (D4); confirming it (or
1891
- * pressing Esc) resolves {kind:"skip"}.
2038
+ * ANSI (16-color structure + truecolor Sage Limit 染色; `NO_COLOR` 非空 →
2039
+ * 全无色纯文本). Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL
2040
+ * per line) so navigation leaves no ghosting (C-P3).
1892
2041
  *
1893
2042
  * Raw-mode lifecycle: `process.stdin.isRaw` is saved before
1894
2043
  * `setRawMode(true)` and restored on EVERY exit path (confirm / Esc / ctrl-c
@@ -1897,50 +2046,64 @@ function readStdin() {
1897
2046
  * spawned claude TUI takes stdin over cleanly. The picker always completes
1898
2047
  * before any backend spawn.
1899
2048
  */
1900
- function pickProviderInteractive(entries, initialIndex) {
2049
+ export function pickProviderInteractive(entries, initialIndex) {
1901
2050
  return new Promise((resolvePromise) => {
1902
2051
  const stderr = process.stderr;
1903
2052
  const stdin = process.stdin;
1904
- const rowCount = entries.length + 1; // D1: count includes the 不切换 row
1905
- let index = Math.min(Math.max(Math.trunc(initialIndex), 0), rowCount - 1); // clamp (D4)
2053
+ const noColor = (process.env.NO_COLOR ?? "") !== "";
2054
+ const frameRows = entries.length + 4; // 标题2 + 分隔线1 + 条目N + 末行1
2055
+ let index = Math.min(Math.max(Math.trunc(initialIndex), 0), Math.max(entries.length - 1, 0)); // clamp (D4)
1906
2056
  let settled = false;
1907
- const SKIP_LABEL = "不切换(使用 cc-switch 当前生效配置)";
1908
- const TITLE = "gcli: 选择 cc-switch provider(↑↓/j/k/C-n/C-p 移动 · Enter 确认 · Esc/C-g 不切换):";
1909
- const labelOf = (row) => {
1910
- if (row >= entries.length)
1911
- return SKIP_LABEL;
1912
- const entry = entries[row];
1913
- return entry.quota ? `${entry.name} ${entry.quota}` : entry.name;
1914
- };
1915
- // Selected row: ❯ + full-line inverse video; unselected: two-space indent
1916
- // (C-P7). \x1b[K clears to EOL so a shorter previous render leaves no
1917
- // ghosting (残影).
1918
- const rowText = (row) => {
1919
- const label = labelOf(row);
1920
- return row === index
1921
- ? `\x1b[7m❯ ${label}\x1b[27m\x1b[K`
1922
- : ` ${label}\x1b[K`;
1923
- };
1924
- const drawRows = () => {
1925
- for (let row = 0; row < rowCount; row++) {
1926
- stderr.write(`${rowText(row)}\n`);
2057
+ // Full repaint: pure row construction + per-line clear-to-EOL so a
2058
+ // shorter previous render leaves no ghosting (残影).
2059
+ const drawFrame = () => {
2060
+ for (const line of renderPickerRows(entries, index, noColor)) {
2061
+ stderr.write(`${line}\x1b[K\n`);
1927
2062
  }
1928
2063
  };
1929
2064
  // In-place redraw: the cursor sits just below the last row after each
1930
- // draw, so move it back up over every entry row before repainting.
2065
+ // draw, so move it back up over every frame line before repainting.
1931
2066
  const redraw = () => {
1932
- stderr.write(`\x1b[${rowCount}A\r`);
1933
- drawRows();
2067
+ stderr.write(`\x1b[${frameRows}A\r`);
2068
+ drawFrame();
1934
2069
  };
1935
2070
  // Raw-mode lifecycle (C-P3): save → raw → ... EVERY exit path restores.
1936
2071
  const wasRaw = stdin.isRaw === true;
1937
2072
  const dataListenersBefore = stdin.listeners("data");
1938
2073
  let onKeypress;
1939
2074
  let onGone = () => { };
2075
+ // 孤立 ESC 快速路径:emitKeypressEvents 不带 interface 参数时没有 escape
2076
+ // 消歧超时,真实终端按 Esc 发出的孤立 \x1b 会被状态机无限挂起(keypress
2077
+ // 永不发出)——实测 PTY 下 30s 无事件。因此监听 data 层:单字节 \x1b 且
2078
+ // 50ms 内无后续字节 → 视为 Esc 键 skip;有后续(箭头序列拆包等)→ 取消
2079
+ // 计时器交还 readline 正常解析。readline 的 name==="escape" 路径保留,
2080
+ // settled 守卫防双触发。
2081
+ let escTimer;
2082
+ const onDataFastEsc = (chunk) => {
2083
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
2084
+ if (s !== "\x1b") {
2085
+ if (escTimer !== undefined) {
2086
+ clearTimeout(escTimer);
2087
+ escTimer = undefined;
2088
+ }
2089
+ return;
2090
+ }
2091
+ if (escTimer !== undefined)
2092
+ clearTimeout(escTimer);
2093
+ escTimer = setTimeout(() => {
2094
+ escTimer = undefined;
2095
+ finish({ kind: "skip" });
2096
+ }, 50);
2097
+ };
1940
2098
  const cleanup = () => {
1941
2099
  if (onKeypress !== undefined) {
1942
2100
  stdin.removeListener("keypress", onKeypress);
1943
2101
  }
2102
+ if (escTimer !== undefined) {
2103
+ clearTimeout(escTimer);
2104
+ escTimer = undefined;
2105
+ }
2106
+ stdin.removeListener("data", onDataFastEsc);
1944
2107
  stdin.removeListener("close", onGone);
1945
2108
  stdin.removeListener("error", onGone);
1946
2109
  // emitKeypressEvents lazily attaches an internal 'data' listener (via
@@ -1963,9 +2126,8 @@ function pickProviderInteractive(entries, initialIndex) {
1963
2126
  cleanup();
1964
2127
  resolvePromise(outcome);
1965
2128
  };
1966
- // Title + hint drawn once; entry rows below it (C-P7).
1967
- stderr.write(`${TITLE}\n`);
1968
- drawRows();
2129
+ // Title + hint drawn once; entry rows below it get repainted (C-P7).
2130
+ drawFrame();
1969
2131
  emitKeypressEvents(stdin);
1970
2132
  onKeypress = (_str, key) => {
1971
2133
  try {
@@ -1976,13 +2138,15 @@ function pickProviderInteractive(entries, initialIndex) {
1976
2138
  }
1977
2139
  if (key === undefined)
1978
2140
  return;
1979
- const action = applyPickerKey(key, index, rowCount);
2141
+ // Movement covers entry rows only (the 末行提示 is not selectable).
2142
+ const action = applyPickerKey(key, index, entries.length);
1980
2143
  if (action.type === "move") {
1981
2144
  index = action.index;
1982
2145
  redraw();
1983
2146
  }
1984
2147
  else if (action.type === "confirm") {
1985
- // The last row is the 不切换 row → skip (D4).
2148
+ // index is always an entry row; the guard only matters for the
2149
+ // empty-list edge (nothing selectable → skip).
1986
2150
  finish(index < entries.length
1987
2151
  ? { kind: "select", entry: entries[index] }
1988
2152
  : { kind: "skip" });
@@ -2002,6 +2166,7 @@ function pickProviderInteractive(entries, initialIndex) {
2002
2166
  // skip rather than hang.
2003
2167
  onGone = () => finish({ kind: "skip" });
2004
2168
  stdin.on("keypress", onKeypress);
2169
+ stdin.on("data", onDataFastEsc);
2005
2170
  stdin.once("close", onGone);
2006
2171
  stdin.once("error", onGone);
2007
2172
  if (stdin.isTTY)
@@ -2283,11 +2448,11 @@ const HELP = `Usage:
2283
2448
  gcli hermes <provider|status|rollback> [options] hermes agent 主模型一键切换
2284
2449
 
2285
2450
  gcli sits in front of three backends and gives skills a stable entry point:
2286
- 50k-char output truncation (agy/claude; api only when non-stream), a hard
2287
- timeout, explicit exit codes, and stdin piping (\`-p -\`).
2451
+ a hard timeout, explicit exit codes, and stdin piping (\`-p -\`). Output is
2452
+ passed through unmodified — size limits are the endpoint's business.
2288
2453
 
2289
2454
  Without -p, the agy/claude backends launch their interactive TUI (inherited
2290
- stdio; no timeout, no truncation; the child's exit code is passed through).
2455
+ stdio; no timeout; the child's exit code is passed through).
2291
2456
  This requires a TTY — piping into gcli without -p is an error (use '-p -' to
2292
2457
  pipe a prompt). The api backend is one-shot HTTP and always requires -p.
2293
2458
 
@@ -2302,7 +2467,7 @@ agy backend (\`gcli agy ...\` — the subcommand is REQUIRED; bare \`gcli\` is c
2302
2467
  --yolo Auto-approve tool actions (agy --dangerously-skip-permissions)
2303
2468
  --sandbox Run agy in sandbox mode
2304
2469
  --cwd <dir> Working directory (added via agy --add-dir)
2305
- --timeout <ms> Hard timeout in ms (default 300000, max 1800000)
2470
+ --timeout <ms> Hard timeout in ms (default 300000)
2306
2471
  --version Print the agy version
2307
2472
  --help Show this help
2308
2473
  -- <args...> Pass remaining args through to agy verbatim
@@ -2313,7 +2478,7 @@ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
2313
2478
  --pick Force the provider picker menu, even in print mode
2314
2479
  --model <name> Override ANTHROPIC_MODEL in the provider env
2315
2480
  --cwd <dir> Working directory (added via claude --add-dir)
2316
- --timeout <ms> Hard timeout in ms (default 300000, max 1800000)
2481
+ --timeout <ms> Hard timeout in ms (default 300000)
2317
2482
  --version Print the claude version
2318
2483
  --help Show this help
2319
2484
  -- <args...> Pass remaining args through to claude verbatim
@@ -2323,8 +2488,8 @@ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
2323
2488
  does NOT rewrite ~/.claude/settings.json.
2324
2489
  - No --provider in a TTY: gcli lists all cc-switch providers (in
2325
2490
  cc-switch's own DB order) in an arrow-key picker (↑↓/j/k and Emacs
2326
- C-n/C-p move · Enter 确认 · Esc/C-g = 不切换, keep claude's default
2327
- config; M-</M-> jump to first/last; ctrl-c exits 130). In print mode
2491
+ C-n/C-p move · Enter 确认 · Esc/C-g = 退出(不启动 claude);
2492
+ M-</M-> jump to first/last; ctrl-c exits 130). In print mode
2328
2493
  (-p) the last confirmed provider is reused silently (one stderr hint
2329
2494
  line); the menu only pops when nothing valid is remembered, or when
2330
2495
  --pick is given. Without a TTY the picker never triggers — no prompt,
@@ -2346,8 +2511,9 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
2346
2511
  -p, --prompt <text|-> Prompt text, "-" for stdin (REQUIRED)
2347
2512
  --provider <name> cc-switch provider (REQUIRED; supplies base URL/token/model)
2348
2513
  --model <name> Override the provider's ANTHROPIC_MODEL
2349
- --max-tokens <n> Output token budget (default 80000)
2350
- --timeout <ms> Idle + absolute timeout in ms (default 300000, max 1800000)
2514
+ --max-tokens <n> Output token budget (default 80000; upper bound is
2515
+ whatever the endpoint accepts)
2516
+ --timeout <ms> Idle + absolute timeout in ms (default 300000)
2351
2517
  --stream|--no-stream Stream SSE and aggregate live (default stream)
2352
2518
  --thinking <mode> auto|off|on (default auto = omit the thinking field,
2353
2519
  endpoint default applies; off sends disabled; on
@@ -2355,7 +2521,7 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
2355
2521
  --max-tokens >= 2048)
2356
2522
  --retry <n> Auto retries for transient failures: network errors,
2357
2523
  HTTP 408/429/5xx, malformed JSON, empty content
2358
- (default 1, max 5; exponential backoff 400ms..4s)
2524
+ (default 1; exponential backoff 400ms..4s)
2359
2525
  --version Print the api backend identity
2360
2526
  --help Show this help
2361
2527
 
@@ -2415,12 +2581,12 @@ function mapSpawnResult(result, backend, timeoutMs) {
2415
2581
  return {
2416
2582
  exitCode: 1,
2417
2583
  stdout: "",
2418
- stderr: `gcli: ${backend} failed (exit ${result.exitCode})\n${truncate(errInfo)}`,
2584
+ stderr: `gcli: ${backend} failed (exit ${result.exitCode})\n${errInfo}`,
2419
2585
  };
2420
2586
  }
2421
2587
  if (!out) {
2422
2588
  const info = result.stderr.trim()
2423
- ? `stderr: ${truncate(result.stderr)}`
2589
+ ? `stderr: ${result.stderr}`
2424
2590
  : "no output";
2425
2591
  return {
2426
2592
  exitCode: 1,
@@ -2428,7 +2594,7 @@ function mapSpawnResult(result, backend, timeoutMs) {
2428
2594
  stderr: `gcli: ${backend} returned ${info}`,
2429
2595
  };
2430
2596
  }
2431
- return { exitCode: 0, stdout: truncate(out), stderr: "" };
2597
+ return { exitCode: 0, stdout: out, stderr: "" };
2432
2598
  }
2433
2599
  async function runAgyBackend(parsed, deps) {
2434
2600
  // --pick is claude-backend-only (cc-switch provider picker); agy has no
@@ -2573,6 +2739,7 @@ async function runClaudeBackend(parsed, deps) {
2573
2739
  }
2574
2740
  }
2575
2741
  let settingsEnv;
2742
+ let pickerSkipped = false;
2576
2743
  let pickerWarning;
2577
2744
  if (parsed.provider !== undefined) {
2578
2745
  const validated = validateProviderName(parsed.provider);
@@ -2675,13 +2842,17 @@ async function runClaudeBackend(parsed, deps) {
2675
2842
  }
2676
2843
  const quotaMap = await deps.fetchProviderQuotas(quotaItems);
2677
2844
  // D5/D6 (revise-3): rows are name + quota subtitle (host is gone);
2678
- // the remembered row gets a(上次)marker after the subtitle.
2845
+ // the remembered row carries its ●上次 marker as a structured tag
2846
+ // (rendering decides placement/color; quota text stays byte-clean).
2679
2847
  const entries = ordered.map((p) => {
2680
- let quota = quotaMap.get(p.name);
2848
+ const entry = {
2849
+ name: p.name,
2850
+ quota: quotaMap.get(p.name),
2851
+ };
2681
2852
  if (remembered !== undefined && p.name === remembered) {
2682
- quota = quota !== undefined ? `${quota}(上次)` : "(上次)";
2853
+ entry.tag = "上次";
2683
2854
  }
2684
- return { name: p.name, quota };
2855
+ return entry;
2685
2856
  });
2686
2857
  const picked = await deps.pickProvider(entries, memoryValid ? memoryIndex : 0);
2687
2858
  if (picked.kind === "select") {
@@ -2701,7 +2872,10 @@ async function runClaudeBackend(parsed, deps) {
2701
2872
  await deps.writeLastProvider(picked.entry.name);
2702
2873
  }
2703
2874
  }
2704
- // skip → keep claude's default config; memory untouched.
2875
+ else {
2876
+ pickerSkipped = true; // Esc/C-g → 退出,不启动 claude
2877
+ }
2878
+ // skip 语义(2026-09 用户裁定):退出。memory untouched.
2705
2879
  }
2706
2880
  }
2707
2881
  }
@@ -2720,6 +2894,15 @@ async function runClaudeBackend(parsed, deps) {
2720
2894
  ...o,
2721
2895
  stderr: o.stderr ? `${pickerWarning}\n${o.stderr}` : pickerWarning,
2722
2896
  };
2897
+ // Esc/C-g skip = 退出(2026-09 用户裁定,替代旧「不切换照常启动」语义):
2898
+ // 不 spawn 任何后端,干净退出 exit 0。stderr 提示确认动作已被感知。
2899
+ if (pickerSkipped) {
2900
+ return withPickerWarning({
2901
+ exitCode: 0,
2902
+ stdout: "",
2903
+ stderr: "gcli: 已退出(未启动 claude)",
2904
+ });
2905
+ }
2723
2906
  const cwdAbs = parsed.cwd ? resolve(parsed.cwd) : undefined;
2724
2907
  // Interactive mode (no -p in a TTY): inherit stdio, pass exit code through.
2725
2908
  if (prompt === undefined) {