honeydo 0.1.3 → 0.2.0

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.
@@ -227,7 +227,7 @@ export function buildSettingsEnv(providerEnv, model) {
227
227
  * - "return" / "enter" → confirm the current row (the physical Enter key
228
228
  * emits "return" in raw mode; "enter" is the LF byte, which a tty may
229
229
  * substitute for CR in input buffered before raw mode was enabled)
230
- * - "escape" → skip (不切换)
230
+ * - "escape" → skip (退出,不启动后端)
231
231
  * - Emacs (revise-2): ctrl+"n" ≡ down, ctrl+"p" ≡ up (same wrap); ctrl+"g"
232
232
  * ≡ escape → skip; meta+"<" → first row (absolute), meta+">" → last row
233
233
  * (absolute). Horizontal Emacs keys (C-f/C-b/C-a/C-e) and paging (C-v/M-v)
@@ -236,7 +236,7 @@ export function buildSettingsEnv(providerEnv, model) {
236
236
  * exit 130)
237
237
  *
238
238
  * `index` is the highlighted row, `count` the total rendered rows INCLUDING
239
- * the trailing 不切换 row. Movement wraps with `(index±1+count)%count`; with
239
+ * the footer row. Movement wraps with `(index±1+count)%count`; with
240
240
  * count <= 0 moves are a noop (nothing is rendered).
241
241
  */
242
242
  export function applyPickerKey(k, index, count) {
@@ -396,7 +396,9 @@ export function parseKimiUsages(body) {
396
396
  }
397
397
  /**
398
398
  * Parse GLM `/api/monitor/usage/quota/limit` (C-Q2): data.limits[] entries
399
- * 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
400
402
  * the short (5h) window, last the weekly one.
401
403
  */
402
404
  export function parseGlmQuota(body) {
@@ -413,7 +415,7 @@ export function parseGlmQuota(body) {
413
415
  if (typeof item !== "object" || item === null)
414
416
  continue;
415
417
  const r = item;
416
- if (r.type !== "TOKENS_LIMIT")
418
+ if (r.type !== "TOKENS_LIMIT" && r.type !== "CREDIT_LIMIT")
417
419
  continue;
418
420
  const pct = toNum(r.percentage);
419
421
  if (pct === undefined)
@@ -451,20 +453,90 @@ function formatReset(iso, nowMs) {
451
453
  return rh > 0 ? `${d}d${rh}h` : `${d}d`;
452
454
  }
453
455
  /**
454
- * Format quota windows as the menu subtitle (C-Q3): `5h:P% wk:P% ↻<rel>`
455
- * (short reset preferred for the ↻ segment); missing windows degrade; a
456
- * 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.
457
482
  */
458
483
  export function formatQuota(q, nowMs) {
484
+ const p = quotaParts(q, nowMs);
459
485
  const parts = [];
460
- if (q.short !== undefined)
461
- parts.push(`5h:${q.short.pct}%`);
462
- if (q.weekly !== undefined)
463
- parts.push(`wk:${q.weekly.pct}%`);
464
- if (parts.length === 0)
465
- return "";
466
- const rel = formatReset(q.short?.resetIso ?? q.weekly?.resetIso, nowMs);
467
- 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(" ");
468
540
  }
469
541
  /**
470
542
  * Build claude argv (C8). `--model` is NOT passed here — for the claude
@@ -1405,12 +1477,20 @@ export function runClaude(args, timeoutMs, cwd) {
1405
1477
  * Unlike runAgy/runClaude: no timeout, no stdout/stderr capture —
1406
1478
  * the child owns the terminal. The child's exit code is passed
1407
1479
  * through unchanged (SIGINT→130, SIGTERM→143 per shell convention).
1480
+ *
1481
+ * An inherited CLAUDE_CODE_CHILD_SESSION=1 is stripped: claude reads it as
1482
+ * "spawned sub-worker" and silently turns transcript saving off, so the
1483
+ * session never shows up in `claude --resume`. An attended TUI is a
1484
+ * top-level session, never a sub-worker. agy never reads the var, so the
1485
+ * strip is a no-op there; print mode keeps the env verbatim.
1408
1486
  */
1409
- function spawnInteractive(bin, args, cwd) {
1487
+ export function spawnInteractive(bin, args, cwd) {
1488
+ const env = { ...process.env };
1489
+ delete env.CLAUDE_CODE_CHILD_SESSION;
1410
1490
  return new Promise((resolveFn) => {
1411
1491
  const child = spawn(bin, args, {
1412
1492
  cwd,
1413
- env: { ...process.env },
1493
+ env,
1414
1494
  stdio: "inherit",
1415
1495
  });
1416
1496
  child.on("error", (err) => {
@@ -1870,17 +1950,122 @@ function readStdin() {
1870
1950
  // ---------------------------------------------------------------------------
1871
1951
  // Provider picker (TTY only) — arrow keys, zero deps
1872
1952
  // ---------------------------------------------------------------------------
1953
+ // Picker frame colors: 16-color structure + truecolor Sage accents (B2).
1954
+ const DIM_ON = "\x1b[2m";
1955
+ const INTENSITY_OFF = "\x1b[22m";
1956
+ const CYAN = "\x1b[36m";
1957
+ const BOLD_ON = "\x1b[1m";
1958
+ const RESET_ALL = "\x1b[0m";
1959
+ /** 选中行背景 #292e42(truecolor)。 */
1960
+ const SELECTED_BG = "\x1b[48;2;41;46;66m";
1961
+ const PICKER_TITLE_MAIN = "◆ gcli";
1962
+ const PICKER_TITLE_SUB = " · 选择 provider";
1963
+ const PICKER_HINT_KEYS = "↑↓/j/k 移动 · Enter 确认 · Esc 不切换";
1964
+ const PICKER_HINT_SKIP = "Esc 不切换";
1965
+ const PICKER_SEPARATOR = "─".repeat(50);
1966
+ const NO_QUOTA_MARK = "—";
1967
+ /**
1968
+ * 给 picker 实际持有的 quota 文本(`formatQuota` 输出格式,byte-locked)上
1969
+ * 色:`5h:P%`/`wk:P%` token 按各自 pct 染色,`↻rel` token 恒 dim。与
1970
+ * `colorQuota` 同一视觉,只是入参是已格式化文本(DI 契约里 entries.quota
1971
+ * 为 string,渲染层拿不到结构化窗口)。前提:pct 为整数——生产 parsers
1972
+ * 均 Math.floor 保证;异形 token 优雅退化为不染色。
1973
+ */
1974
+ function colorQuotaText(text) {
1975
+ return text
1976
+ .split(" ")
1977
+ .map((token) => {
1978
+ const win = /^(5h|wk):(\d+)%$/.exec(token);
1979
+ if (win !== null) {
1980
+ return `${levelColor(Number(win[2]))}${token}${FG_OFF}`;
1981
+ }
1982
+ if (token.startsWith("↻"))
1983
+ return `${DIM_ON}${token}${INTENSITY_OFF}`;
1984
+ return token;
1985
+ })
1986
+ .join(" ");
1987
+ }
1988
+ /**
1989
+ * One entry row: `❯ `/two-space prefix | name column (max name width + 2,
1990
+ * left-aligned) | colored quota (or dim — placeholder) padded to the widest
1991
+ * quota across entries | dim ` <n>会话` (only when entry.sessions is set) |
1992
+ * cyan `●<tag>`. The quota pad (by JS `.length`) keeps the session column
1993
+ * start aligned across rows; padding sits outside the colored segments so
1994
+ * the dim placeholder stays contiguous. Selected rows add the truecolor
1995
+ * background + a bold name and end with a full reset; unselected rows keep
1996
+ * the same structure without background.
1997
+ */
1998
+ function pickerEntryRow(entry, selected, nameWidth, quotaWidth, noColor) {
1999
+ const name = entry.name.padEnd(nameWidth);
2000
+ const quotaText = entry.quota !== undefined && entry.quota !== "" ? entry.quota : undefined;
2001
+ const pad = " ".repeat(Math.max(0, quotaWidth - (quotaText ?? NO_QUOTA_MARK).length));
2002
+ const quota = quotaText === undefined
2003
+ ? noColor
2004
+ ? NO_QUOTA_MARK
2005
+ : `${DIM_ON}${NO_QUOTA_MARK}${INTENSITY_OFF}`
2006
+ : noColor
2007
+ ? quotaText
2008
+ : colorQuotaText(quotaText);
2009
+ const quotaColumn = `${quota}${pad}`;
2010
+ const sessions = entry.sessions === undefined
2011
+ ? ""
2012
+ : noColor
2013
+ ? ` ${entry.sessions}会话`
2014
+ : `${DIM_ON} ${entry.sessions}会话${INTENSITY_OFF}`;
2015
+ const tag = entry.tag !== undefined && entry.tag !== ""
2016
+ ? noColor
2017
+ ? ` ●${entry.tag}`
2018
+ : ` ${CYAN}●${entry.tag}${FG_OFF}`
2019
+ : "";
2020
+ if (noColor) {
2021
+ return `${selected ? "❯ " : " "}${name}${quotaColumn}${sessions}${tag}`;
2022
+ }
2023
+ return selected
2024
+ ? `${SELECTED_BG}${COLOR_SAGE}❯${FG_OFF} ${BOLD_ON}${name}${INTENSITY_OFF}${quotaColumn}${sessions}${tag}${RESET_ALL}`
2025
+ : ` ${name}${quotaColumn}${sessions}${tag}`;
2026
+ }
2027
+ /**
2028
+ * The full picker frame as plain lines: 标题 2 行 + dim 分隔线 + 条目 N 行 +
2029
+ * dim 末行提示 (non-selectable — skip 只经 Esc/C-g). With `unattributedCount >
2030
+ * 0` the footer reads `另有 N 个裸 claude 会话未归属 · Esc 不切换` (bare claude
2031
+ * processes carry no --settings and cannot be attributed to a provider);
2032
+ * otherwise it is plain `Esc 不切换`. Pure: no I/O; with `noColor` (NO_COLOR
2033
+ * downgrade) zero ANSI escapes — 仅纯文本排版,❯ 缩进 + 列对齐保留。Redraw
2034
+ * cursor-up height = `entries.length + 4` regardless (footer merges into the
2035
+ * one hint row).
2036
+ */
2037
+ export function renderPickerRows(entries, selectedIndex, noColor, unattributedCount = 0) {
2038
+ const nameWidth = entries.reduce((w, e) => Math.max(w, e.name.length), 0) + 2;
2039
+ const quotaWidth = entries.reduce((w, e) => Math.max(w, (e.quota !== undefined && e.quota !== "" ? e.quota : NO_QUOTA_MARK)
2040
+ .length), 0);
2041
+ const rows = [
2042
+ noColor
2043
+ ? `${PICKER_TITLE_MAIN}${PICKER_TITLE_SUB}`
2044
+ : `${CYAN}${BOLD_ON}${PICKER_TITLE_MAIN}${RESET_ALL}${PICKER_TITLE_SUB}`,
2045
+ noColor ? PICKER_HINT_KEYS : `${DIM_ON}${PICKER_HINT_KEYS}${INTENSITY_OFF}`,
2046
+ noColor ? PICKER_SEPARATOR : `${DIM_ON}${PICKER_SEPARATOR}${INTENSITY_OFF}`,
2047
+ ];
2048
+ for (let i = 0; i < entries.length; i++) {
2049
+ rows.push(pickerEntryRow(entries[i], i === selectedIndex, nameWidth, quotaWidth, noColor));
2050
+ }
2051
+ const footerText = unattributedCount > 0
2052
+ ? `另有 ${unattributedCount} 个裸 claude 会话未归属 · ${PICKER_HINT_SKIP}`
2053
+ : PICKER_HINT_SKIP;
2054
+ rows.push(noColor ? footerText : `${DIM_ON}${footerText}${INTENSITY_OFF}`);
2055
+ return rows;
2056
+ }
1873
2057
  /**
1874
2058
  * Production deps.pickProvider: arrow-key menu rendered entirely on stderr
1875
2059
  * (stdout stays pipe-clean). ↑↓/j/k move with wrap, Enter confirms, Esc
1876
2060
  * skips, ctrl-c restores the terminal then exits 130; any other key is a
1877
- * noop (C-P1).
2061
+ * noop (C-P1). Frame = 标题 2 行 + 分隔线 + 条目 N 行 + dim `Esc 不切换`
2062
+ * 末行提示——旧的可选中「不切换」行已退化为提示(skip 走 Esc/C-g),移动
2063
+ * 只覆盖条目行。
1878
2064
  *
1879
2065
  * Zero deps: `readline.emitKeypressEvents` + raw-mode stdin + hand-written
1880
- * ANSI. Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL per line)
1881
- * so navigation leaves no ghosting (C-P3). The trailing 不切换 row is
1882
- * appended here — `entries` holds providers only (D4); confirming it (or
1883
- * pressing Esc) resolves {kind:"skip"}.
2066
+ * ANSI (16-color structure + truecolor Sage Limit 染色; `NO_COLOR` 非空 →
2067
+ * 全无色纯文本). Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL
2068
+ * per line) so navigation leaves no ghosting (C-P3).
1884
2069
  *
1885
2070
  * Raw-mode lifecycle: `process.stdin.isRaw` is saved before
1886
2071
  * `setRawMode(true)` and restored on EVERY exit path (confirm / Esc / ctrl-c
@@ -1888,51 +2073,74 @@ function readStdin() {
1888
2073
  * its lazily-attached internal `data`) listeners are removed — so the
1889
2074
  * spawned claude TUI takes stdin over cleanly. The picker always completes
1890
2075
  * before any backend spawn.
2076
+ *
2077
+ * `unattributedCount` (optional) drives the bare-claude footer hint; 0 or
2078
+ * omitted renders the plain `Esc 不切换` footer. When the positional arg is
2079
+ * absent, the count is recovered from the entries themselves
2080
+ * (`PickerEntry.unattributed`, attached by the claude menu path) so two-arg
2081
+ * pickProvider wrappers still render the hint.
1891
2082
  */
1892
- function pickProviderInteractive(entries, initialIndex) {
2083
+ export function pickProviderInteractive(entries, initialIndex, unattributedCount) {
1893
2084
  return new Promise((resolvePromise) => {
1894
2085
  const stderr = process.stderr;
1895
2086
  const stdin = process.stdin;
1896
- const rowCount = entries.length + 1; // D1: count includes the 不切换 row
1897
- let index = Math.min(Math.max(Math.trunc(initialIndex), 0), rowCount - 1); // clamp (D4)
2087
+ const noColor = (process.env.NO_COLOR ?? "") !== "";
2088
+ const frameUnattributed = unattributedCount ??
2089
+ entries.find((e) => e.unattributed !== undefined)?.unattributed ??
2090
+ 0;
2091
+ const frameRows = entries.length + 4; // 标题2 + 分隔线1 + 条目N + 末行1
2092
+ let index = Math.min(Math.max(Math.trunc(initialIndex), 0), Math.max(entries.length - 1, 0)); // clamp (D4)
1898
2093
  let settled = false;
1899
- const SKIP_LABEL = "不切换(使用 cc-switch 当前生效配置)";
1900
- const TITLE = "gcli: 选择 cc-switch provider(↑↓/j/k/C-n/C-p 移动 · Enter 确认 · Esc/C-g 不切换):";
1901
- const labelOf = (row) => {
1902
- if (row >= entries.length)
1903
- return SKIP_LABEL;
1904
- const entry = entries[row];
1905
- return entry.quota ? `${entry.name} ${entry.quota}` : entry.name;
1906
- };
1907
- // Selected row: ❯ + full-line inverse video; unselected: two-space indent
1908
- // (C-P7). \x1b[K clears to EOL so a shorter previous render leaves no
1909
- // ghosting (残影).
1910
- const rowText = (row) => {
1911
- const label = labelOf(row);
1912
- return row === index
1913
- ? `\x1b[7m❯ ${label}\x1b[27m\x1b[K`
1914
- : ` ${label}\x1b[K`;
1915
- };
1916
- const drawRows = () => {
1917
- for (let row = 0; row < rowCount; row++) {
1918
- stderr.write(`${rowText(row)}\n`);
2094
+ // Full repaint: pure row construction + per-line clear-to-EOL so a
2095
+ // shorter previous render leaves no ghosting (残影).
2096
+ const drawFrame = () => {
2097
+ for (const line of renderPickerRows(entries, index, noColor, frameUnattributed)) {
2098
+ stderr.write(`${line}\x1b[K\n`);
1919
2099
  }
1920
2100
  };
1921
2101
  // In-place redraw: the cursor sits just below the last row after each
1922
- // draw, so move it back up over every entry row before repainting.
2102
+ // draw, so move it back up over every frame line before repainting.
1923
2103
  const redraw = () => {
1924
- stderr.write(`\x1b[${rowCount}A\r`);
1925
- drawRows();
2104
+ stderr.write(`\x1b[${frameRows}A\r`);
2105
+ drawFrame();
1926
2106
  };
1927
2107
  // Raw-mode lifecycle (C-P3): save → raw → ... EVERY exit path restores.
1928
2108
  const wasRaw = stdin.isRaw === true;
1929
2109
  const dataListenersBefore = stdin.listeners("data");
1930
2110
  let onKeypress;
1931
2111
  let onGone = () => { };
2112
+ // 孤立 ESC 快速路径:emitKeypressEvents 不带 interface 参数时没有 escape
2113
+ // 消歧超时,真实终端按 Esc 发出的孤立 \x1b 会被状态机无限挂起(keypress
2114
+ // 永不发出)——实测 PTY 下 30s 无事件。因此监听 data 层:单字节 \x1b 且
2115
+ // 50ms 内无后续字节 → 视为 Esc 键 skip;有后续(箭头序列拆包等)→ 取消
2116
+ // 计时器交还 readline 正常解析。readline 的 name==="escape" 路径保留,
2117
+ // settled 守卫防双触发。
2118
+ let escTimer;
2119
+ const onDataFastEsc = (chunk) => {
2120
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
2121
+ if (s !== "\x1b") {
2122
+ if (escTimer !== undefined) {
2123
+ clearTimeout(escTimer);
2124
+ escTimer = undefined;
2125
+ }
2126
+ return;
2127
+ }
2128
+ if (escTimer !== undefined)
2129
+ clearTimeout(escTimer);
2130
+ escTimer = setTimeout(() => {
2131
+ escTimer = undefined;
2132
+ finish({ kind: "skip" });
2133
+ }, 50);
2134
+ };
1932
2135
  const cleanup = () => {
1933
2136
  if (onKeypress !== undefined) {
1934
2137
  stdin.removeListener("keypress", onKeypress);
1935
2138
  }
2139
+ if (escTimer !== undefined) {
2140
+ clearTimeout(escTimer);
2141
+ escTimer = undefined;
2142
+ }
2143
+ stdin.removeListener("data", onDataFastEsc);
1936
2144
  stdin.removeListener("close", onGone);
1937
2145
  stdin.removeListener("error", onGone);
1938
2146
  // emitKeypressEvents lazily attaches an internal 'data' listener (via
@@ -1955,9 +2163,8 @@ function pickProviderInteractive(entries, initialIndex) {
1955
2163
  cleanup();
1956
2164
  resolvePromise(outcome);
1957
2165
  };
1958
- // Title + hint drawn once; entry rows below it (C-P7).
1959
- stderr.write(`${TITLE}\n`);
1960
- drawRows();
2166
+ // Title + hint drawn once; entry rows below it get repainted (C-P7).
2167
+ drawFrame();
1961
2168
  emitKeypressEvents(stdin);
1962
2169
  onKeypress = (_str, key) => {
1963
2170
  try {
@@ -1968,13 +2175,15 @@ function pickProviderInteractive(entries, initialIndex) {
1968
2175
  }
1969
2176
  if (key === undefined)
1970
2177
  return;
1971
- const action = applyPickerKey(key, index, rowCount);
2178
+ // Movement covers entry rows only (the 末行提示 is not selectable).
2179
+ const action = applyPickerKey(key, index, entries.length);
1972
2180
  if (action.type === "move") {
1973
2181
  index = action.index;
1974
2182
  redraw();
1975
2183
  }
1976
2184
  else if (action.type === "confirm") {
1977
- // The last row is the 不切换 row → skip (D4).
2185
+ // index is always an entry row; the guard only matters for the
2186
+ // empty-list edge (nothing selectable → skip).
1978
2187
  finish(index < entries.length
1979
2188
  ? { kind: "select", entry: entries[index] }
1980
2189
  : { kind: "skip" });
@@ -1994,12 +2203,213 @@ function pickProviderInteractive(entries, initialIndex) {
1994
2203
  // skip rather than hang.
1995
2204
  onGone = () => finish({ kind: "skip" });
1996
2205
  stdin.on("keypress", onKeypress);
2206
+ stdin.on("data", onDataFastEsc);
1997
2207
  stdin.once("close", onGone);
1998
2208
  stdin.once("error", onGone);
1999
2209
  if (stdin.isTTY)
2000
2210
  stdin.setRawMode(true);
2001
2211
  });
2002
2212
  }
2213
+ /**
2214
+ * Extract the `--settings <json>` value from one joined ps argv line: scan
2215
+ * from the first `{` after `--settings` with string-aware bracket balancing
2216
+ * (ps concatenates argv with spaces, so the JSON may contain any whitespace,
2217
+ * and the payload itself nests objects). Returns undefined when the line has
2218
+ * no `--settings`, no `{` after it, or never closes — the caller treats that
2219
+ * as unattributable (honest: `--settings <file>` cannot be attributed either).
2220
+ */
2221
+ function extractSettingsJson(line) {
2222
+ const marker = line.indexOf("--settings");
2223
+ if (marker === -1)
2224
+ return undefined;
2225
+ const start = line.indexOf("{", marker);
2226
+ if (start === -1)
2227
+ return undefined;
2228
+ let depth = 0;
2229
+ let inString = false;
2230
+ let escaped = false;
2231
+ for (let i = start; i < line.length; i++) {
2232
+ const ch = line[i];
2233
+ if (inString) {
2234
+ if (escaped)
2235
+ escaped = false;
2236
+ else if (ch === "\\")
2237
+ escaped = true;
2238
+ else if (ch === '"')
2239
+ inString = false;
2240
+ continue;
2241
+ }
2242
+ if (ch === '"')
2243
+ inString = true;
2244
+ else if (ch === "{")
2245
+ depth++;
2246
+ else if (ch === "}") {
2247
+ depth--;
2248
+ if (depth === 0)
2249
+ return line.slice(start, i + 1);
2250
+ }
2251
+ }
2252
+ return undefined;
2253
+ }
2254
+ /**
2255
+ * Whether one process's settings env matches a provider: baseUrl strict-equal
2256
+ * AND (authToken strict-equal OR apiKey strict-equal) — empty/missing tokens
2257
+ * never participate, so a bare URL can never match anything (same-domain
2258
+ * dual-plan providers are told apart by token).
2259
+ */
2260
+ function sessionProviderMatches(provider, baseUrl, authToken, apiKey) {
2261
+ if (baseUrl === undefined || provider.baseUrl !== baseUrl)
2262
+ return false;
2263
+ const tokenHit = authToken !== undefined &&
2264
+ authToken !== "" &&
2265
+ provider.authToken === authToken;
2266
+ const keyHit = apiKey !== undefined && apiKey !== "" && provider.apiKey === apiKey;
2267
+ return tokenHit || keyHit;
2268
+ }
2269
+ /**
2270
+ * Attribute joined ps argv lines to providers (pure, total — never throws).
2271
+ * Lines with a parseable `--settings` env matching a provider increment that
2272
+ * provider's count; providers with identical URL+token each count the same
2273
+ * line (accepted edge). Bare claude lines, unparseable JSON, and settings
2274
+ * matching no provider all count as `unattributed`.
2275
+ */
2276
+ export function parseSessionAttribution(lines, providers) {
2277
+ const counts = {};
2278
+ let unattributed = 0;
2279
+ for (const line of lines) {
2280
+ const json = extractSettingsJson(line);
2281
+ if (json === undefined) {
2282
+ unattributed++;
2283
+ continue;
2284
+ }
2285
+ let parsed;
2286
+ try {
2287
+ parsed = JSON.parse(json);
2288
+ }
2289
+ catch {
2290
+ unattributed++;
2291
+ continue;
2292
+ }
2293
+ const env = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
2294
+ ? parsed.env
2295
+ : undefined;
2296
+ if (typeof env !== "object" || env === null || Array.isArray(env)) {
2297
+ unattributed++;
2298
+ continue;
2299
+ }
2300
+ const rec = env;
2301
+ const baseUrl = typeof rec.ANTHROPIC_BASE_URL === "string"
2302
+ ? rec.ANTHROPIC_BASE_URL
2303
+ : undefined;
2304
+ const authToken = typeof rec.ANTHROPIC_AUTH_TOKEN === "string"
2305
+ ? rec.ANTHROPIC_AUTH_TOKEN
2306
+ : undefined;
2307
+ const apiKey = typeof rec.ANTHROPIC_API_KEY === "string"
2308
+ ? rec.ANTHROPIC_API_KEY
2309
+ : undefined;
2310
+ const hits = providers.filter((p) => sessionProviderMatches(p, baseUrl, authToken, apiKey));
2311
+ if (hits.length === 0) {
2312
+ unattributed++;
2313
+ continue;
2314
+ }
2315
+ for (const p of hits) {
2316
+ counts[p.name] = (counts[p.name] ?? 0) + 1;
2317
+ }
2318
+ }
2319
+ return { counts, unattributed };
2320
+ }
2321
+ /**
2322
+ * Glue for the claude TTY menu path: scan live processes once and attribute
2323
+ * them. Any failure (deps capability missing, spawn error, non-zero pgrep/ps)
2324
+ * resolves to undefined — the caller degrades to no session column and no
2325
+ * bare-session footer (cannot distinguish "none" from "scan failed"; prefer
2326
+ * silence over a wrong number). Never throws.
2327
+ */
2328
+ async function countProviderSessions(deps, providers) {
2329
+ if (deps.listClaudeProcessArgs === undefined)
2330
+ return undefined;
2331
+ try {
2332
+ const lines = await deps.listClaudeProcessArgs();
2333
+ return parseSessionAttribution(lines, providers);
2334
+ }
2335
+ catch {
2336
+ return undefined;
2337
+ }
2338
+ }
2339
+ /**
2340
+ * Production deps.listClaudeProcessArgs: `pgrep -x claude` for pids, then
2341
+ * `ps -ww -o command= -p <pids>` (BSD flags; `-ww` defeats column truncation
2342
+ * so long argv carrying `--settings` JSON survives). Read-only, no secrets in
2343
+ * the commands themselves; ps output (which contains tokens) stays in memory.
2344
+ * Exit semantics: pgrep 1 = no match → []; pgrep >1 or any ps failure →
2345
+ * rejects (error text carries only exit codes / ps stderr — never ps stdout).
2346
+ */
2347
+ function listClaudeProcessArgsOnDisk() {
2348
+ return new Promise((resolvePromise, rejectPromise) => {
2349
+ const pgrep = spawn("pgrep", ["-x", CLAUDE_BIN], {
2350
+ stdio: ["ignore", "pipe", "pipe"],
2351
+ });
2352
+ let pidsOut = "";
2353
+ let pgrepErr = "";
2354
+ pgrep.stdout.on("data", (chunk) => {
2355
+ pidsOut += chunk.toString();
2356
+ });
2357
+ pgrep.stderr.on("data", (chunk) => {
2358
+ pgrepErr += chunk.toString();
2359
+ });
2360
+ pgrep.on("error", (err) => {
2361
+ rejectPromise(new Error(`pgrep failed to spawn: ${err.message}`));
2362
+ });
2363
+ pgrep.on("close", (code) => {
2364
+ if (code === 1) {
2365
+ resolvePromise([]); // no processes matched — not an error
2366
+ return;
2367
+ }
2368
+ if (code !== 0) {
2369
+ const msg = pgrepErr.trim();
2370
+ rejectPromise(new Error(msg
2371
+ ? `pgrep exited with code ${code}: ${msg}`
2372
+ : `pgrep exited with code ${code}`));
2373
+ return;
2374
+ }
2375
+ const pids = pidsOut
2376
+ .split("\n")
2377
+ .map((s) => s.trim())
2378
+ .filter((s) => s !== "");
2379
+ if (pids.length === 0) {
2380
+ resolvePromise([]);
2381
+ return;
2382
+ }
2383
+ const ps = spawn("ps", ["-ww", "-o", "command=", "-p", pids.join(",")], {
2384
+ stdio: ["ignore", "pipe", "pipe"],
2385
+ });
2386
+ let psOut = "";
2387
+ let psErr = "";
2388
+ ps.stdout.on("data", (chunk) => {
2389
+ psOut += chunk.toString();
2390
+ });
2391
+ ps.stderr.on("data", (chunk) => {
2392
+ psErr += chunk.toString();
2393
+ });
2394
+ ps.on("error", (err) => {
2395
+ rejectPromise(new Error(`ps failed to spawn: ${err.message}`));
2396
+ });
2397
+ ps.on("close", (psCode) => {
2398
+ if (psCode !== 0) {
2399
+ const msg = psErr.trim();
2400
+ rejectPromise(new Error(msg
2401
+ ? `ps exited with code ${psCode}: ${msg}`
2402
+ : `ps exited with code ${psCode}`));
2403
+ return;
2404
+ }
2405
+ resolvePromise(psOut
2406
+ .split("\n")
2407
+ .map((s) => s.trimEnd())
2408
+ .filter((s) => s !== ""));
2409
+ });
2410
+ });
2411
+ });
2412
+ }
2003
2413
  // ---------------------------------------------------------------------------
2004
2414
  // Last-provider memory (D2) — production deps.readLastProvider/writeLastProvider
2005
2415
  // ---------------------------------------------------------------------------
@@ -2315,8 +2725,8 @@ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
2315
2725
  does NOT rewrite ~/.claude/settings.json.
2316
2726
  - No --provider in a TTY: gcli lists all cc-switch providers (in
2317
2727
  cc-switch's own DB order) in an arrow-key picker (↑↓/j/k and Emacs
2318
- C-n/C-p move · Enter 确认 · Esc/C-g = 不切换, keep claude's default
2319
- config; M-</M-> jump to first/last; ctrl-c exits 130). In print mode
2728
+ C-n/C-p move · Enter 确认 · Esc/C-g = 退出(不启动 claude);
2729
+ M-</M-> jump to first/last; ctrl-c exits 130). In print mode
2320
2730
  (-p) the last confirmed provider is reused silently (one stderr hint
2321
2731
  line); the menu only pops when nothing valid is remembered, or when
2322
2732
  --pick is given. Without a TTY the picker never triggers — no prompt,
@@ -2566,6 +2976,7 @@ async function runClaudeBackend(parsed, deps) {
2566
2976
  }
2567
2977
  }
2568
2978
  let settingsEnv;
2979
+ let pickerSkipped = false;
2569
2980
  let pickerWarning;
2570
2981
  if (parsed.provider !== undefined) {
2571
2982
  const validated = validateProviderName(parsed.provider);
@@ -2666,17 +3077,45 @@ async function runClaudeBackend(parsed, deps) {
2666
3077
  quotaItems.push({ name: p.name, env: envResult.env });
2667
3078
  }
2668
3079
  }
2669
- const quotaMap = await deps.fetchProviderQuotas(quotaItems);
3080
+ // Session attribution runs once, in parallel with the quota fetch,
3081
+ // on this menu path only. Any failure resolves to undefined and the
3082
+ // picker degrades to no session column (same soft-degrade as quota).
3083
+ const sessionProviders = quotaItems.map((q) => ({
3084
+ name: q.name,
3085
+ baseUrl: q.env.ANTHROPIC_BASE_URL,
3086
+ authToken: q.env.ANTHROPIC_AUTH_TOKEN,
3087
+ apiKey: q.env.ANTHROPIC_API_KEY,
3088
+ }));
3089
+ const [quotaMap, attribution] = await Promise.all([
3090
+ deps.fetchProviderQuotas(quotaItems),
3091
+ countProviderSessions(deps, sessionProviders),
3092
+ ]);
2670
3093
  // D5/D6 (revise-3): rows are name + quota subtitle (host is gone);
2671
- // the remembered row gets a(上次)marker after the subtitle.
3094
+ // the remembered row carries its ●上次 marker as a structured tag
3095
+ // (rendering decides placement/color; quota text stays byte-clean).
3096
+ // Live-session counts ride the same rows (0 included); undefined
3097
+ // when the scan was unavailable so the column degrades per entry.
3098
+ // The frame-level bare-claude count rides every entry too (same
3099
+ // value) so the footer hint survives pickProvider wrappers that
3100
+ // forward only (entries, initialIndex); undefined → degrade to 0.
2672
3101
  const entries = ordered.map((p) => {
2673
- let quota = quotaMap.get(p.name);
3102
+ const entry = {
3103
+ name: p.name,
3104
+ quota: quotaMap.get(p.name),
3105
+ sessions: attribution === undefined
3106
+ ? undefined
3107
+ : (attribution.counts[p.name] ?? 0),
3108
+ unattributed: attribution?.unattributed,
3109
+ };
2674
3110
  if (remembered !== undefined && p.name === remembered) {
2675
- quota = quota !== undefined ? `${quota}(上次)` : "(上次)";
3111
+ entry.tag = "上次";
2676
3112
  }
2677
- return { name: p.name, quota };
3113
+ return entry;
2678
3114
  });
2679
- const picked = await deps.pickProvider(entries, memoryValid ? memoryIndex : 0);
3115
+ const picked = await deps.pickProvider(entries, memoryValid ? memoryIndex : 0,
3116
+ // Degraded scan → 0: cannot distinguish "no bare sessions" from
3117
+ // "scan failed", so the footer hint stays silent either way.
3118
+ attribution?.unattributed ?? 0);
2680
3119
  if (picked.kind === "select") {
2681
3120
  // Exact-name lookup (no matchProviderName): the picker returns
2682
3121
  // an entry straight from this list.
@@ -2694,7 +3133,10 @@ async function runClaudeBackend(parsed, deps) {
2694
3133
  await deps.writeLastProvider(picked.entry.name);
2695
3134
  }
2696
3135
  }
2697
- // skip → keep claude's default config; memory untouched.
3136
+ else {
3137
+ pickerSkipped = true; // Esc/C-g → 退出,不启动 claude
3138
+ }
3139
+ // skip 语义(2026-09 用户裁定):退出。memory untouched.
2698
3140
  }
2699
3141
  }
2700
3142
  }
@@ -2713,6 +3155,15 @@ async function runClaudeBackend(parsed, deps) {
2713
3155
  ...o,
2714
3156
  stderr: o.stderr ? `${pickerWarning}\n${o.stderr}` : pickerWarning,
2715
3157
  };
3158
+ // Esc/C-g skip = 退出(2026-09 用户裁定,替代旧「不切换照常启动」语义):
3159
+ // 不 spawn 任何后端,干净退出 exit 0。stderr 提示确认动作已被感知。
3160
+ if (pickerSkipped) {
3161
+ return withPickerWarning({
3162
+ exitCode: 0,
3163
+ stdout: "",
3164
+ stderr: "gcli: 已退出(未启动 claude)",
3165
+ });
3166
+ }
2716
3167
  const cwdAbs = parsed.cwd ? resolve(parsed.cwd) : undefined;
2717
3168
  // Interactive mode (no -p in a TTY): inherit stdio, pass exit code through.
2718
3169
  if (prompt === undefined) {
@@ -3411,8 +3862,9 @@ async function main() {
3411
3862
  runClaudeInteractive: (args, cwd) => runClaudeInteractive(args, cwd),
3412
3863
  runAgyInteractive: (args, cwd) => runAgyInteractive(args, cwd),
3413
3864
  isInteractive: () => process.stdin.isTTY === true,
3414
- pickProvider: (entries, initialIndex) => pickProviderInteractive(entries, initialIndex),
3865
+ pickProvider: (entries, initialIndex, unattributedCount) => pickProviderInteractive(entries, initialIndex, unattributedCount),
3415
3866
  fetchProviderQuotas: (items) => fetchProviderQuotasHttp(items),
3867
+ listClaudeProcessArgs: () => listClaudeProcessArgsOnDisk(),
3416
3868
  readLastProvider: () => readLastProviderFromDisk(),
3417
3869
  writeLastProvider: (name) => writeLastProviderToDisk(name),
3418
3870
  runHermes: (args, timeoutMs) => runHermesProcess(args, timeoutMs ?? DEFAULT_TIMEOUT_MS),