git-coco 0.50.0 → 0.51.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.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.50.0";
81
+ const BUILD_VERSION = "0.51.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -145,11 +145,11 @@ function removeUndefined(obj) {
145
145
  return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
146
146
  }
147
147
 
148
- const dynamicImport$2 = new Function('specifier', 'return import(specifier)');
148
+ const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
149
149
  let promptsPromise;
150
150
  function loadInquirerPrompts() {
151
151
  if (!promptsPromise) {
152
- promptsPromise = dynamicImport$2('@inquirer/prompts');
152
+ promptsPromise = dynamicImport$1('@inquirer/prompts');
153
153
  }
154
154
  return promptsPromise;
155
155
  }
@@ -1140,6 +1140,11 @@ const schema$1 = {
1140
1140
  "idleTips": {
1141
1141
  "type": "boolean",
1142
1142
  "description": "Rotate short usage tips through the status line when the TUI has been idle for >10s. Off by default so power users aren't distracted."
1143
+ },
1144
+ "dateBucketing": {
1145
+ "type": "boolean",
1146
+ "description": "Group adjacent commits in the history surface under shared section headers (`── Today ──`, `── Yesterday ──`, `── April 2026 ──`) and drop the per-row date column in favor of the headers. On by default because the bucketed view gives stronger temporal orientation at a glance and the freed cells go to the commit subject. Flip off if you prefer a date column on every row.\n\nBucketing automatically suppresses itself while a search filter is active (results aren't chronological), regardless of this setting.",
1147
+ "default": true
1143
1148
  }
1144
1149
  },
1145
1150
  "additionalProperties": false,
@@ -1596,6 +1601,9 @@ const schema$1 = {
1596
1601
  "commit": {
1597
1602
  "$ref": "#/definitions/LLMModel"
1598
1603
  },
1604
+ "commitSplit": {
1605
+ "$ref": "#/definitions/LLMModel"
1606
+ },
1599
1607
  "changelog": {
1600
1608
  "$ref": "#/definitions/LLMModel"
1601
1609
  },
@@ -2505,9 +2513,10 @@ const CACHE_SUBCOMMANDS = [
2505
2513
  'parsers',
2506
2514
  'prefetch',
2507
2515
  'clear-parsers',
2516
+ 'clear-github',
2508
2517
  ];
2509
- const command$8 = 'cache <subcommand> [languages..]';
2510
- const builder$8 = (yargs) => {
2518
+ const command$a = 'cache <subcommand> [languages..]';
2519
+ const builder$a = (yargs) => {
2511
2520
  return yargs
2512
2521
  .positional('subcommand', {
2513
2522
  describe: 'Cache action to run',
@@ -2519,7 +2528,7 @@ const builder$8 = (yargs) => {
2519
2528
  type: 'string',
2520
2529
  array: true,
2521
2530
  })
2522
- .usage(getCommandUsageHeader(command$8));
2531
+ .usage(getCommandUsageHeader(command$a));
2523
2532
  };
2524
2533
 
2525
2534
  /**
@@ -2969,15 +2978,15 @@ async function runPrefetchFromEnv(options) {
2969
2978
  * cache file under ~500 KB on a typical repo (each entry is a
2970
2979
  * sha256 hash + 200-500-byte summary).
2971
2980
  */
2972
- const CACHE_SCHEMA_VERSION$1 = 1;
2973
- const CACHE_DIR_NAME$1 = 'diff-summaries';
2981
+ const CACHE_SCHEMA_VERSION$2 = 1;
2982
+ const CACHE_DIR_NAME$2 = 'diff-summaries';
2974
2983
  const CACHE_ENTRY_HARD_CAP = 500;
2975
- function resolveCacheDir$4() {
2984
+ function resolveCacheDir$5() {
2976
2985
  const xdg = process.env.XDG_CACHE_HOME;
2977
2986
  if (xdg && xdg.trim().length > 0) {
2978
- return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
2987
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$2);
2979
2988
  }
2980
- return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
2989
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$2);
2981
2990
  }
2982
2991
  function repoKey$3(repoPath) {
2983
2992
  // sha256 here is a non-security cache-key derivation — deterministic
@@ -2987,7 +2996,7 @@ function repoKey$3(repoPath) {
2987
2996
  return crypto__namespace.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
2988
2997
  }
2989
2998
  function getDiffSummaryCachePath(repoPath) {
2990
- return path__namespace$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
2999
+ return path__namespace$1.join(resolveCacheDir$5(), `summaries.${repoKey$3(repoPath)}.json`);
2991
3000
  }
2992
3001
  /**
2993
3002
  * Build the cache key for a (diff, model, prompt) tuple. sha256
@@ -3004,7 +3013,7 @@ function readEnvelope(filePath) {
3004
3013
  try {
3005
3014
  const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
3006
3015
  const parsed = JSON.parse(raw);
3007
- if (parsed.version !== CACHE_SCHEMA_VERSION$1)
3016
+ if (parsed.version !== CACHE_SCHEMA_VERSION$2)
3008
3017
  return undefined;
3009
3018
  if (!parsed.entries || typeof parsed.entries !== 'object')
3010
3019
  return undefined;
@@ -3026,7 +3035,7 @@ function readDiffSummary(repoPath, key) {
3026
3035
  function writeDiffSummary(repoPath, key, entry) {
3027
3036
  const filePath = getDiffSummaryCachePath(repoPath);
3028
3037
  const existing = readEnvelope(filePath) || {
3029
- version: CACHE_SCHEMA_VERSION$1,
3038
+ version: CACHE_SCHEMA_VERSION$2,
3030
3039
  savedAt: new Date().toISOString(),
3031
3040
  entries: {},
3032
3041
  };
@@ -3095,6 +3104,132 @@ function clearDiffSummaryCache(repoPath) {
3095
3104
  }
3096
3105
  }
3097
3106
 
3107
+ /**
3108
+ * Disk-backed cache for `coco issues` / `coco prs` list fetches
3109
+ * (#882 phase 2). Triage is bursty — a user runs `coco issues` a
3110
+ * dozen times in a few minutes, then doesn't touch it for hours —
3111
+ * so a short TTL (default 5 minutes) buys a lot of latency back
3112
+ * without serving stale data outside that window.
3113
+ *
3114
+ * Best-effort, same as `overviewCache.ts`: read failures fall back
3115
+ * to "no cache" (the fetcher does a fresh `gh` call), write failures
3116
+ * are swallowed silently (next call just re-fetches). The cache is
3117
+ * never load-bearing — `gh` is always the source of truth.
3118
+ *
3119
+ * Keying: `{kind}.{repoHash}.{filterHash}.json` where:
3120
+ * - `kind` is `'issues'` or `'prs'` so the two surfaces don't
3121
+ * collide.
3122
+ * - `repoHash` is a stable short hash of the absolute repo path
3123
+ * (same scheme as `overviewCache.ts`).
3124
+ * - `filterHash` is a stable short hash of the canonicalized
3125
+ * filter object so different `--state` / `--assignee` / `--label`
3126
+ * combinations cache independently.
3127
+ *
3128
+ * No PII in filenames; no auth context is hashed; no
3129
+ * collision-resistance against an adversary is required.
3130
+ */
3131
+ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
3132
+ const CACHE_SCHEMA_VERSION$1 = 1;
3133
+ const CACHE_DIR_NAME$1 = 'github';
3134
+ function resolveCacheDir$4() {
3135
+ const xdg = process.env.XDG_CACHE_HOME;
3136
+ if (xdg && xdg.trim().length > 0) {
3137
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
3138
+ }
3139
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
3140
+ }
3141
+ function shortHash$1(input) {
3142
+ // sha1 here is a non-security cache-key derivation — we just need a
3143
+ // deterministic short identifier so two repos / filters at different
3144
+ // values never collide in the cache directory. No PII or auth
3145
+ // context is hashed and no collision-resistance against an adversary
3146
+ // is required.
3147
+ // DevSkim: ignore DS126858
3148
+ return crypto__namespace.createHash('sha1').update(input).digest('hex').slice(0, 16);
3149
+ }
3150
+ /**
3151
+ * Canonicalize the filter object into a stable string before hashing.
3152
+ * Sorts keys + drops undefined entries so equivalent filters
3153
+ * (`{state: 'open'}` and `{state: 'open', limit: undefined}`) hash to
3154
+ * the same key and share cached data.
3155
+ */
3156
+ function canonicalizeFilter(filter) {
3157
+ const entries = Object.entries(filter)
3158
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
3159
+ .sort(([a], [b]) => a.localeCompare(b));
3160
+ return JSON.stringify(entries);
3161
+ }
3162
+ function getCachePath(kind, repoPath, filter) {
3163
+ const repoHash = shortHash$1(repoPath);
3164
+ const filterHash = shortHash$1(canonicalizeFilter(filter));
3165
+ return path__namespace$1.join(resolveCacheDir$4(), `${kind}.${repoHash}.${filterHash}.json`);
3166
+ }
3167
+ function readCachedList(kind, repoPath, filter, ttlMs = DEFAULT_CACHE_TTL_MS) {
3168
+ try {
3169
+ const raw = fs__namespace$1.readFileSync(getCachePath(kind, repoPath, filter), 'utf8');
3170
+ const parsed = JSON.parse(raw);
3171
+ if (parsed.version !== CACHE_SCHEMA_VERSION$1)
3172
+ return undefined;
3173
+ if (!parsed.payload || parsed.payload.kind !== kind)
3174
+ return undefined;
3175
+ if (!Array.isArray(parsed.payload.items))
3176
+ return undefined;
3177
+ const savedAt = new Date(parsed.savedAt);
3178
+ if (Number.isNaN(savedAt.getTime()))
3179
+ return undefined;
3180
+ const ageMs = Date.now() - savedAt.getTime();
3181
+ return {
3182
+ payload: parsed.payload,
3183
+ savedAt,
3184
+ ageMs,
3185
+ fresh: ageMs < ttlMs,
3186
+ };
3187
+ }
3188
+ catch {
3189
+ return undefined;
3190
+ }
3191
+ }
3192
+ function writeCachedList(repoPath, filter, payload) {
3193
+ const file = getCachePath(payload.kind, repoPath, filter);
3194
+ const envelope = {
3195
+ version: CACHE_SCHEMA_VERSION$1,
3196
+ savedAt: new Date().toISOString(),
3197
+ payload,
3198
+ };
3199
+ try {
3200
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
3201
+ fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
3202
+ }
3203
+ catch {
3204
+ // Best-effort persistence; swallow.
3205
+ }
3206
+ }
3207
+ /**
3208
+ * Drop every cached file under the github cache directory. Used by
3209
+ * `--no-cache` / explicit purge commands. Best-effort: ENOENT on a
3210
+ * never-populated cache directory is treated as success.
3211
+ */
3212
+ function clearGitHubListCache() {
3213
+ const dir = resolveCacheDir$4();
3214
+ let removed = 0;
3215
+ try {
3216
+ const entries = fs__namespace$1.readdirSync(dir);
3217
+ for (const entry of entries) {
3218
+ try {
3219
+ fs__namespace$1.unlinkSync(path__namespace$1.join(dir, entry));
3220
+ removed++;
3221
+ }
3222
+ catch {
3223
+ // Skip individual file failures; keep counting the rest.
3224
+ }
3225
+ }
3226
+ }
3227
+ catch {
3228
+ // Directory missing → nothing to clear, treat as success.
3229
+ }
3230
+ return { removed };
3231
+ }
3232
+
3098
3233
  /**
3099
3234
  * Retrieves a SimpleGit instance for a repository.
3100
3235
  *
@@ -3267,7 +3402,7 @@ async function promptLanguageSelection(logger) {
3267
3402
  });
3268
3403
  return picked;
3269
3404
  }
3270
- const handler$8 = async (argv, logger) => {
3405
+ const handler$a = async (argv, logger) => {
3271
3406
  const subcommand = argv.subcommand;
3272
3407
  const positionalLanguages = (argv.languages || [])
3273
3408
  .map((s) => s.trim())
@@ -3344,6 +3479,16 @@ const handler$8 = async (argv, logger) => {
3344
3479
  }
3345
3480
  return;
3346
3481
  }
3482
+ if (subcommand === 'clear-github') {
3483
+ const result = clearGitHubListCache();
3484
+ if (result.removed === 0) {
3485
+ logger.log(chalk.dim('No GitHub triage cache to clear.'));
3486
+ return;
3487
+ }
3488
+ logger.log(chalk.green(`✓ cleared ${result.removed} cached GitHub triage list${result.removed === 1 ? '' : 's'}`));
3489
+ logger.log(chalk.dim('Cleared from ~/.cache/coco/github/'));
3490
+ return;
3491
+ }
3347
3492
  if (subcommand === 'clear-parsers') {
3348
3493
  const languages = listManifestLanguages();
3349
3494
  let cleared = 0;
@@ -3362,15 +3507,15 @@ const handler$8 = async (argv, logger) => {
3362
3507
  return;
3363
3508
  }
3364
3509
  logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
3365
- logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers'));
3510
+ logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
3366
3511
  process.exitCode = 1;
3367
3512
  };
3368
3513
 
3369
3514
  var cache = {
3370
- command: command$8,
3515
+ command: command$a,
3371
3516
  desc: 'Manage the diff-summary cache (clear, info)',
3372
- builder: builder$8,
3373
- handler: commandExecutor(handler$8),
3517
+ builder: builder$a,
3518
+ handler: commandExecutor(handler$a),
3374
3519
  };
3375
3520
 
3376
3521
  var util;
@@ -7159,11 +7304,11 @@ const ChangelogResponseSchema = objectType({
7159
7304
  title: stringType(),
7160
7305
  content: stringType(),
7161
7306
  });
7162
- const command$7 = 'changelog';
7307
+ const command$9 = 'changelog';
7163
7308
  /**
7164
7309
  * Command line options via yargs
7165
7310
  */
7166
- const options$7 = {
7311
+ const options$9 = {
7167
7312
  range: {
7168
7313
  type: 'string',
7169
7314
  alias: 'r',
@@ -7210,8 +7355,8 @@ const options$7 = {
7210
7355
  description: 'Toggle interactive mode',
7211
7356
  },
7212
7357
  };
7213
- const builder$7 = (yargs) => {
7214
- return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
7358
+ const builder$9 = (yargs) => {
7359
+ return yargs.options(options$9).usage(getCommandUsageHeader(command$9));
7215
7360
  };
7216
7361
 
7217
7362
  /**
@@ -7415,6 +7560,12 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7415
7560
  cost: {
7416
7561
  summarize: 'gpt-4.1-nano',
7417
7562
  commit: 'gpt-4.1-mini',
7563
+ // `commitSplit` floors at mini even in cost mode. The split
7564
+ // planner emits structured JSON with strict cross-group
7565
+ // constraints (files appear exactly once, hunks fully cover or
7566
+ // not at all). Nano-class models fail those constraints often
7567
+ // enough that the cost win is eaten by the 3-retry budget.
7568
+ commitSplit: 'gpt-4.1-mini',
7418
7569
  changelog: 'gpt-4.1-mini',
7419
7570
  review: 'gpt-4.1-mini',
7420
7571
  recap: 'gpt-4.1-nano',
@@ -7424,6 +7575,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7424
7575
  balanced: {
7425
7576
  summarize: 'gpt-4.1-mini',
7426
7577
  commit: 'gpt-4.1-mini',
7578
+ commitSplit: 'gpt-4.1',
7427
7579
  changelog: 'gpt-4.1',
7428
7580
  review: 'gpt-4.1',
7429
7581
  recap: 'gpt-4.1-mini',
@@ -7433,6 +7585,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7433
7585
  quality: {
7434
7586
  summarize: 'gpt-4.1-mini',
7435
7587
  commit: 'gpt-4.1',
7588
+ commitSplit: 'gpt-4.1',
7436
7589
  changelog: 'gpt-4.1',
7437
7590
  review: 'gpt-4.1',
7438
7591
  recap: 'gpt-4.1',
@@ -7444,6 +7597,8 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7444
7597
  cost: {
7445
7598
  summarize: 'claude-3-5-haiku-latest',
7446
7599
  commit: 'claude-3-5-haiku-latest',
7600
+ // Floor at sonnet — see note on OpenAI commitSplit above.
7601
+ commitSplit: 'claude-3-5-sonnet-latest',
7447
7602
  changelog: 'claude-3-5-sonnet-latest',
7448
7603
  review: 'claude-3-5-sonnet-latest',
7449
7604
  recap: 'claude-3-5-haiku-latest',
@@ -7453,6 +7608,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7453
7608
  balanced: {
7454
7609
  summarize: 'claude-3-5-haiku-latest',
7455
7610
  commit: 'claude-3-5-sonnet-latest',
7611
+ commitSplit: 'claude-3-7-sonnet-latest',
7456
7612
  changelog: 'claude-3-5-sonnet-latest',
7457
7613
  review: 'claude-3-7-sonnet-latest',
7458
7614
  recap: 'claude-3-5-sonnet-latest',
@@ -7462,6 +7618,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7462
7618
  quality: {
7463
7619
  summarize: 'claude-3-5-sonnet-latest',
7464
7620
  commit: 'claude-3-7-sonnet-latest',
7621
+ commitSplit: 'claude-sonnet-4-0',
7465
7622
  changelog: 'claude-3-7-sonnet-latest',
7466
7623
  review: 'claude-sonnet-4-0',
7467
7624
  recap: 'claude-3-7-sonnet-latest',
@@ -7473,6 +7630,8 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7473
7630
  cost: {
7474
7631
  summarize: 'llama3.2:3b',
7475
7632
  commit: 'llama3.1:8b',
7633
+ // Floor at the coder-tuned 14b — see note on OpenAI commitSplit above.
7634
+ commitSplit: 'qwen2.5-coder:14b',
7476
7635
  changelog: 'llama3.1:8b',
7477
7636
  review: 'qwen2.5-coder:7b',
7478
7637
  recap: 'llama3.2:3b',
@@ -7482,6 +7641,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7482
7641
  balanced: {
7483
7642
  summarize: 'llama3.1:8b',
7484
7643
  commit: 'qwen2.5-coder:14b',
7644
+ commitSplit: 'qwen2.5-coder:32b',
7485
7645
  changelog: 'qwen2.5-coder:14b',
7486
7646
  review: 'qwen2.5-coder:32b',
7487
7647
  recap: 'llama3.1:8b',
@@ -7491,6 +7651,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7491
7651
  quality: {
7492
7652
  summarize: 'qwen2.5-coder:14b',
7493
7653
  commit: 'qwen2.5-coder:32b',
7654
+ commitSplit: 'qwen2.5-coder:32b',
7494
7655
  changelog: 'qwen2.5-coder:32b',
7495
7656
  review: 'qwen2.5-coder:32b',
7496
7657
  recap: 'qwen2.5-coder:14b',
@@ -7506,6 +7667,7 @@ const DYNAMIC_DEFAULTS = {
7506
7667
  const DYNAMIC_MODEL_TASKS = [
7507
7668
  'summarize',
7508
7669
  'commit',
7670
+ 'commitSplit',
7509
7671
  'changelog',
7510
7672
  'review',
7511
7673
  'recap',
@@ -9219,7 +9381,9 @@ function summarizeRustStructuralDiff(fileDiff) {
9219
9381
  * on every diff — the regex fallback is the correct steady state
9220
9382
  * in that case.
9221
9383
  */
9222
- const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
9384
+ async function loadTreeSitterModule() {
9385
+ return import('web-tree-sitter');
9386
+ }
9223
9387
  /**
9224
9388
  * Locate the bundled .wasm files. Tries the dist layout first (the
9225
9389
  * common case for installed packages), then falls back to the
@@ -9300,7 +9464,7 @@ async function ensureRuntime() {
9300
9464
  return undefined;
9301
9465
  let mod;
9302
9466
  try {
9303
- mod = await dynamicImport$1('web-tree-sitter');
9467
+ mod = await loadTreeSitterModule();
9304
9468
  }
9305
9469
  catch {
9306
9470
  return undefined;
@@ -14051,7 +14215,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
14051
14215
  }
14052
14216
  return results;
14053
14217
  }
14054
- const handler$7 = async (argv, logger) => {
14218
+ const handler$9 = async (argv, logger) => {
14055
14219
  const git = applyRepoFlag(argv);
14056
14220
  const config = loadConfig(argv);
14057
14221
  const key = getApiKeyForModel(config);
@@ -14276,11 +14440,11 @@ const handler$7 = async (argv, logger) => {
14276
14440
  };
14277
14441
 
14278
14442
  var changelog = {
14279
- command: command$7,
14443
+ command: command$9,
14280
14444
  desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
14281
- builder: builder$7,
14282
- handler: commandExecutor(handler$7),
14283
- options: options$7,
14445
+ builder: builder$9,
14446
+ handler: commandExecutor(handler$9),
14447
+ options: options$9,
14284
14448
  };
14285
14449
 
14286
14450
  const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
@@ -14297,11 +14461,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
14297
14461
  body: stringType().describe("Body of the commit message")
14298
14462
  // .max(280, "Body must be 280 characters or less"),
14299
14463
  }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
14300
- const command$6 = 'commit';
14464
+ const command$8 = 'commit';
14301
14465
  /**
14302
14466
  * Command line options via yargs
14303
14467
  */
14304
- const options$6 = {
14468
+ const options$8 = {
14305
14469
  i: {
14306
14470
  alias: 'interactive',
14307
14471
  description: 'Toggle interactive mode',
@@ -14373,8 +14537,8 @@ const options$6 = {
14373
14537
  default: false,
14374
14538
  },
14375
14539
  };
14376
- const builder$6 = (yargs) => {
14377
- return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
14540
+ const builder$8 = (yargs) => {
14541
+ return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
14378
14542
  };
14379
14543
 
14380
14544
  /**
@@ -14988,6 +15152,73 @@ function formatPlanValidationIssuesError(issues) {
14988
15152
  .filter(Boolean)
14989
15153
  .join('; ');
14990
15154
  }
15155
+ /**
15156
+ * Salvage a plan that lists the same file in `files[]` of more than
15157
+ * one group. Weaker models (e.g. `gpt-4.1-nano`) hit this often when
15158
+ * the staged set has many files — they re-assert files across groups
15159
+ * even though the prompt forbids it.
15160
+ *
15161
+ * Recovery: walk groups in plan order, keep the FIRST occurrence of
15162
+ * each file path, drop subsequent occurrences. Plan-order is used
15163
+ * because the LLM tends to put the most thematically-correct
15164
+ * assignment in the first group it considered the file for; later
15165
+ * appearances are usually accidental re-emissions.
15166
+ *
15167
+ * If dropping a duplicate leaves a group with empty `files[]` AND
15168
+ * empty `hunks[]`, `dropEmptyGroups` (run last) filters it out so
15169
+ * the apply path never sees a group with nothing to commit.
15170
+ *
15171
+ * Returns a NEW plan object — original is not mutated.
15172
+ */
15173
+ function rescueDuplicateFiles(plan) {
15174
+ const seen = new Set();
15175
+ let mutated = false;
15176
+ const rescuedGroups = plan.groups.map((group) => {
15177
+ const keptFiles = [];
15178
+ for (const file of group.files || []) {
15179
+ if (seen.has(file)) {
15180
+ mutated = true;
15181
+ continue;
15182
+ }
15183
+ seen.add(file);
15184
+ keptFiles.push(file);
15185
+ }
15186
+ return { ...group, files: keptFiles };
15187
+ });
15188
+ if (!mutated)
15189
+ return plan;
15190
+ return { ...plan, groups: rescuedGroups };
15191
+ }
15192
+ /**
15193
+ * Salvage a plan that lists the same hunk ID in `hunks[]` of more
15194
+ * than one group. Same failure mode as duplicate files but for the
15195
+ * hunk-level assignments.
15196
+ *
15197
+ * Recovery: keep the FIRST occurrence of each hunk ID across groups
15198
+ * (plan order), drop subsequent ones. `dropEmptyGroups` handles any
15199
+ * group left fully empty.
15200
+ *
15201
+ * Returns a NEW plan object — original is not mutated.
15202
+ */
15203
+ function rescueDuplicateHunks(plan) {
15204
+ const seen = new Set();
15205
+ let mutated = false;
15206
+ const rescuedGroups = plan.groups.map((group) => {
15207
+ const keptHunks = [];
15208
+ for (const hunkId of group.hunks || []) {
15209
+ if (seen.has(hunkId)) {
15210
+ mutated = true;
15211
+ continue;
15212
+ }
15213
+ seen.add(hunkId);
15214
+ keptHunks.push(hunkId);
15215
+ }
15216
+ return { ...group, hunks: keptHunks };
15217
+ });
15218
+ if (!mutated)
15219
+ return plan;
15220
+ return { ...plan, groups: rescuedGroups };
15221
+ }
14991
15222
  /**
14992
15223
  * Salvage a plan that references hunk IDs not in the inventory by
14993
15224
  * promoting those hunks to file-level assignments. The LLM commonly
@@ -15251,31 +15482,39 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
15251
15482
  });
15252
15483
  // Rescue passes. Run in order — order matters:
15253
15484
  //
15254
- // 1. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
15485
+ // 1. rescueDuplicateFiles / rescueDuplicateHunks: weak models
15486
+ // (e.g. gpt-4.1-nano) repeatedly re-assert the same file or
15487
+ // hunk across multiple groups. Keep the first occurrence,
15488
+ // drop the rest. Run FIRST so downstream rescues see a
15489
+ // deduplicated plan and don't re-process redundant entries.
15490
+ //
15491
+ // 2. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
15255
15492
  // against an empty inventory (all staged files are new).
15256
15493
  // Promote those to file-level assignments.
15257
15494
  //
15258
- // 2. rescueMixedFiles (#919): LLM commonly puts a file in
15495
+ // 3. rescueMixedFiles (#919): LLM commonly puts a file in
15259
15496
  // `files[]` of group A AND uses its hunks in `hunks[]` of
15260
15497
  // group B. Drop the hunks (the file-level claim is more
15261
15498
  // specific). Must run AFTER phantom-hunk rescue because the
15262
15499
  // rescue itself can create mixed-files situations.
15263
15500
  //
15264
- // 3. rescueMissingFiles (#921): LLM occasionally forgets a
15501
+ // 4. rescueMissingFiles (#921): LLM occasionally forgets a
15265
15502
  // staged file across every group. Append a synthetic "misc"
15266
15503
  // group so the plan covers every staged file.
15267
15504
  //
15268
- // 4. dropEmptyGroups: rescueMixedFiles can leave a group with
15269
- // empty files[] AND empty hunks[] when it had only hunks
15270
- // that got dropped. Apply-time, an empty group means
15271
- // `git commit` with nothing staged, which throws and
15272
- // aborts mid-loop after the up-front `git reset` has
15273
- // already wiped the index. Filter the empty groups out
15274
- // LAST so the apply path can't hit them.
15505
+ // 5. dropEmptyGroups: earlier rescues can leave a group with
15506
+ // empty files[] AND empty hunks[] when their only contents
15507
+ // got dropped. Apply-time, an empty group means `git commit`
15508
+ // with nothing staged, which throws and aborts mid-loop
15509
+ // after the up-front `git reset` has already wiped the
15510
+ // index. Filter the empty groups out LAST so the apply path
15511
+ // can't hit them.
15275
15512
  //
15276
15513
  // All rescues are no-ops when there's nothing to rescue, so
15277
15514
  // running them unconditionally costs nothing on healthy plans.
15278
- const phantomRescued = rescuePhantomHunks(rawPlan, staged, hunkInventory);
15515
+ const dedupedFiles = rescueDuplicateFiles(rawPlan);
15516
+ const dedupedHunks = rescueDuplicateHunks(dedupedFiles);
15517
+ const phantomRescued = rescuePhantomHunks(dedupedHunks, staged, hunkInventory);
15279
15518
  const mixedRescued = rescueMixedFiles(phantomRescued, hunkInventory);
15280
15519
  const missingRescued = rescueMissingFiles(mixedRescued, staged, hunkInventory);
15281
15520
  const plan = dropEmptyGroups(missingRescued);
@@ -15624,7 +15863,7 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
15624
15863
  * and apply would risk drift (small LLM nondeterminism, staged-state
15625
15864
  * changes the user didn't intend, etc.).
15626
15865
  */
15627
- async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, }) {
15866
+ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
15628
15867
  const changes = await getChanges({
15629
15868
  git,
15630
15869
  options: {
@@ -15697,8 +15936,10 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15697
15936
  // the conventional-commits ruleset (or generic style).
15698
15937
  }
15699
15938
  }
15939
+ const resolvedPlanLlm = planLlm ?? llm;
15940
+ const resolvedPlanModel = planService?.model ?? config.service.model;
15700
15941
  const { plan } = await generateValidatedCommitSplitPlan({
15701
- llm,
15942
+ llm: resolvedPlanLlm,
15702
15943
  prompt: COMMIT_SPLIT_PROMPT,
15703
15944
  variables: {
15704
15945
  file_inventory: fileInventory,
@@ -15716,15 +15957,24 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15716
15957
  metadata: {
15717
15958
  command: 'commit',
15718
15959
  provider: config.service.provider,
15719
- model: String(config.service.model),
15960
+ model: String(resolvedPlanModel),
15720
15961
  conventional: useConventional,
15721
15962
  },
15722
15963
  maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
15723
15964
  });
15724
15965
  return { plan, context: { changes, hunkInventory } };
15725
15966
  }
15726
- async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, }) {
15727
- const result = await prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm });
15967
+ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
15968
+ const result = await prepareCommitSplitPlan({
15969
+ argv,
15970
+ config,
15971
+ git,
15972
+ logger,
15973
+ tokenizer,
15974
+ llm,
15975
+ planLlm,
15976
+ planService,
15977
+ });
15728
15978
  if ('empty' in result) {
15729
15979
  return 'No staged changes found.';
15730
15980
  }
@@ -15743,13 +15993,14 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
15743
15993
  return formatCommitSplitPlan(plan);
15744
15994
  }
15745
15995
 
15746
- const handler$6 = async (argv, logger) => {
15996
+ const handler$8 = async (argv, logger) => {
15747
15997
  const git = applyRepoFlag(argv);
15748
15998
  const config = loadConfig(argv);
15749
15999
  const key = getApiKeyForModel(config);
15750
16000
  const { provider } = getModelAndProviderFromConfig(config);
15751
16001
  const commitService = resolveDynamicService(config, 'commit');
15752
16002
  const summaryService = resolveDynamicService(config, 'summarize');
16003
+ const splitService = resolveDynamicService(config, 'commitSplit');
15753
16004
  const model = commitService.model;
15754
16005
  if (config.service.authentication.type !== 'None' && !key) {
15755
16006
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
@@ -15758,6 +16009,12 @@ const handler$6 = async (argv, logger) => {
15758
16009
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
15759
16010
  const llm = getLlm(provider, model, { ...config, service: commitService });
15760
16011
  const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
16012
+ // The split planner uses a dedicated LLM because its output schema
16013
+ // is far stricter than the regular commit-message path (every staged
16014
+ // file claimed exactly once, no cross-group duplication, hunk-vs-
16015
+ // file mode exclusivity). Weak models fail those constraints often
16016
+ // enough that the `cost` preference floors `commitSplit` at mini.
16017
+ const splitLlm = getLlm(provider, splitService.model, { ...config, service: splitService });
15761
16018
  const INTERACTIVE = argv.interactive || isInteractive(config);
15762
16019
  if (INTERACTIVE) {
15763
16020
  if (!config.hideCocoBanner) {
@@ -15776,6 +16033,7 @@ const handler$6 = async (argv, logger) => {
15776
16033
  color: 'green',
15777
16034
  });
15778
16035
  if (isCommitSplitCommand(argv)) {
16036
+ logger.verbose(`→ split planner: ${provider} (${splitService.model})`, { color: 'green' });
15779
16037
  const splitResult = await handleCommitSplit({
15780
16038
  argv,
15781
16039
  config,
@@ -15783,6 +16041,8 @@ const handler$6 = async (argv, logger) => {
15783
16041
  logger,
15784
16042
  tokenizer,
15785
16043
  llm,
16044
+ planLlm: splitLlm,
16045
+ planService: splitService,
15786
16046
  });
15787
16047
  const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
15788
16048
  await handleResult({
@@ -16208,23 +16468,23 @@ IMPORTANT RULES:
16208
16468
  };
16209
16469
 
16210
16470
  var commit = {
16211
- command: command$6,
16471
+ command: command$8,
16212
16472
  desc: 'Summarize the staged changes in a commit message.',
16213
- builder: builder$6,
16214
- handler: commandExecutor(handler$6),
16215
- options: options$6,
16473
+ builder: builder$8,
16474
+ handler: commandExecutor(handler$8),
16475
+ options: options$8,
16216
16476
  };
16217
16477
 
16218
- const command$5 = 'doctor';
16219
- const options$5 = {
16478
+ const command$7 = 'doctor';
16479
+ const options$7 = {
16220
16480
  fix: {
16221
16481
  description: 'Attempt to auto-fix detected issues and write the updated config',
16222
16482
  type: 'boolean',
16223
16483
  default: false,
16224
16484
  },
16225
16485
  };
16226
- const builder$5 = (yargs) => {
16227
- return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
16486
+ const builder$7 = (yargs) => {
16487
+ return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
16228
16488
  };
16229
16489
 
16230
16490
  /**
@@ -16503,7 +16763,7 @@ function formatSourceInfo(sources) {
16503
16763
  }
16504
16764
  return lines;
16505
16765
  }
16506
- const handler$5 = async (argv, logger) => {
16766
+ const handler$7 = async (argv, logger) => {
16507
16767
  // Honor the global --repo flag so `coco doctor --repo <X>`
16508
16768
  // inspects X's config sources, not the launcher's cwd. The chdir
16509
16769
  // has to happen before loadConfig so `findUp` walks the targeted
@@ -16610,17 +16870,17 @@ const handler$5 = async (argv, logger) => {
16610
16870
  };
16611
16871
 
16612
16872
  var doctor = {
16613
- command: command$5,
16873
+ command: command$7,
16614
16874
  desc: 'Check your coco configuration for common issues and suggest fixes',
16615
- builder: builder$5,
16616
- handler: commandExecutor(handler$5),
16875
+ builder: builder$7,
16876
+ handler: commandExecutor(handler$7),
16617
16877
  };
16618
16878
 
16619
- const command$4 = 'init';
16879
+ const command$6 = 'init';
16620
16880
  /**
16621
16881
  * Command line options via yargs
16622
16882
  */
16623
- const options$4 = {
16883
+ const options$6 = {
16624
16884
  scope: {
16625
16885
  type: 'string',
16626
16886
  description: 'configure coco for the current user or project?',
@@ -16632,8 +16892,8 @@ const options$4 = {
16632
16892
  default: false,
16633
16893
  },
16634
16894
  };
16635
- const builder$4 = (yargs) => {
16636
- return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
16895
+ const builder$6 = (yargs) => {
16896
+ return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
16637
16897
  };
16638
16898
 
16639
16899
  /**
@@ -16988,7 +17248,7 @@ const questions = {
16988
17248
  }),
16989
17249
  };
16990
17250
 
16991
- const handler$4 = async (argv, logger) => {
17251
+ const handler$6 = async (argv, logger) => {
16992
17252
  // Honor the global --repo flag so `coco init --repo <X> --scope project`
16993
17253
  // writes the project config to X, not the launcher's cwd. The
16994
17254
  // chdir has to happen before getProjectConfigFilePath resolves
@@ -17172,15 +17432,417 @@ async function installCommitlintPackages(scope, logger) {
17172
17432
  }
17173
17433
 
17174
17434
  var init = {
17175
- command: command$4,
17435
+ command: command$6,
17176
17436
  desc: 'install & configure coco globally or for the current project',
17177
- builder: builder$4,
17178
- handler: commandExecutor(handler$4),
17179
- options: options$4,
17437
+ builder: builder$6,
17438
+ handler: commandExecutor(handler$6),
17439
+ options: options$6,
17180
17440
  };
17181
17441
 
17182
- const command$3 = 'log';
17183
- const options$3 = {
17442
+ const command$5 = 'issues';
17443
+ const options$5 = {
17444
+ state: {
17445
+ type: 'string',
17446
+ choices: ['open', 'closed', 'all'],
17447
+ description: 'Filter by issue state.',
17448
+ default: 'open',
17449
+ },
17450
+ assignee: {
17451
+ type: 'string',
17452
+ description: 'Filter by assignee GitHub login (or `@me`).',
17453
+ },
17454
+ author: {
17455
+ type: 'string',
17456
+ description: 'Filter by author GitHub login.',
17457
+ },
17458
+ label: {
17459
+ type: 'string',
17460
+ description: 'Filter by label name (comma-separated for AND).',
17461
+ },
17462
+ search: {
17463
+ type: 'string',
17464
+ description: 'Free-form GitHub issue search query.',
17465
+ },
17466
+ mine: {
17467
+ type: 'boolean',
17468
+ description: 'Shorthand for `--assignee @me`.',
17469
+ default: false,
17470
+ },
17471
+ limit: {
17472
+ type: 'number',
17473
+ description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
17474
+ },
17475
+ json: {
17476
+ type: 'boolean',
17477
+ description: 'Print machine-readable JSON instead of a formatted table.',
17478
+ default: false,
17479
+ },
17480
+ refresh: {
17481
+ type: 'boolean',
17482
+ description: 'Force fresh `gh` call (writes through to cache).',
17483
+ default: false,
17484
+ },
17485
+ 'no-cache': {
17486
+ type: 'boolean',
17487
+ description: 'Skip the disk cache entirely (no read, no write).',
17488
+ default: false,
17489
+ },
17490
+ };
17491
+ const builder$5 = (yargs) => {
17492
+ return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
17493
+ };
17494
+
17495
+ const execFileAsync = util$1.promisify(child_process.execFile);
17496
+ function parseGitHubRemoteUrl$1(url) {
17497
+ const trimmed = url.trim().replace(/\.git$/, '');
17498
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
17499
+ const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
17500
+ const match = sshMatch || httpsMatch;
17501
+ if (!match) {
17502
+ return undefined;
17503
+ }
17504
+ return {
17505
+ owner: match[1],
17506
+ name: match[2],
17507
+ };
17508
+ }
17509
+ async function defaultGhRunner(args) {
17510
+ const result = await execFileAsync('gh', args);
17511
+ return result.stdout;
17512
+ }
17513
+ async function getGitHubRepository(git) {
17514
+ const remotes = await git.getRemotes(true);
17515
+ const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
17516
+ const url = remote?.refs.push || remote?.refs.fetch;
17517
+ return url ? parseGitHubRemoteUrl$1(url) : undefined;
17518
+ }
17519
+ /**
17520
+ * Probe `gh auth status` and return whether the GitHub CLI is
17521
+ * installed AND authenticated. Used by every data fetcher to short-
17522
+ * circuit before issuing real API calls — keeps the failure-mode
17523
+ * messaging consistent ("CLI missing or not authenticated") instead
17524
+ * of leaking through as a generic spawn error.
17525
+ */
17526
+ async function isGhAuthenticated(runner) {
17527
+ try {
17528
+ await runner(['auth', 'status', '--hostname', 'github.com']);
17529
+ return true;
17530
+ }
17531
+ catch {
17532
+ return false;
17533
+ }
17534
+ }
17535
+
17536
+ /**
17537
+ * Pad a (possibly already-colored) string to `width` visible columns.
17538
+ * We can't naively `String#padEnd` after coloring because ANSI escape
17539
+ * codes inflate `.length` past the visible width. The pattern here is
17540
+ * "measure the plain string, color last" — every formatter below
17541
+ * computes column widths from raw values and passes the visible
17542
+ * length explicitly so `padToVisible` only needs to add spaces.
17543
+ */
17544
+ function padToVisible(colored, visibleLength, width) {
17545
+ if (visibleLength >= width)
17546
+ return colored;
17547
+ return colored + ' '.repeat(width - visibleLength);
17548
+ }
17549
+ const STATE_COLORS = {
17550
+ OPEN: chalk.green,
17551
+ CLOSED: chalk.red,
17552
+ MERGED: chalk.magenta,
17553
+ };
17554
+ function colorState(state) {
17555
+ const fn = STATE_COLORS[state.toUpperCase()] || chalk.dim;
17556
+ return fn(state.toLowerCase());
17557
+ }
17558
+ function formatLabels(labels) {
17559
+ if (!labels || labels.length === 0)
17560
+ return '';
17561
+ return chalk.dim(labels.map((l) => `[${l}]`).join(' '));
17562
+ }
17563
+ function formatReviewDecision(decision) {
17564
+ if (!decision)
17565
+ return ' ';
17566
+ switch (decision) {
17567
+ case 'APPROVED':
17568
+ return chalk.green('✓');
17569
+ case 'CHANGES_REQUESTED':
17570
+ return chalk.red('✗');
17571
+ case 'REVIEW_REQUIRED':
17572
+ return chalk.yellow('?');
17573
+ default:
17574
+ return chalk.dim(decision.slice(0, 1));
17575
+ }
17576
+ }
17577
+ function formatMergeable(mergeable, mergeStateStatus) {
17578
+ if (mergeStateStatus === 'CLEAN')
17579
+ return chalk.green('●');
17580
+ if (mergeStateStatus === 'BLOCKED')
17581
+ return chalk.yellow('●');
17582
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
17583
+ return chalk.red('●');
17584
+ if (mergeStateStatus === 'BEHIND')
17585
+ return chalk.cyan('●');
17586
+ if (mergeStateStatus === 'UNSTABLE')
17587
+ return chalk.yellow('●');
17588
+ return chalk.dim('●');
17589
+ }
17590
+ function formatIssueList(items) {
17591
+ if (items.length === 0) {
17592
+ return chalk.dim('No issues match the current filter.');
17593
+ }
17594
+ const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
17595
+ const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
17596
+ const stateWidth = 6;
17597
+ return items
17598
+ .map((issue) => {
17599
+ const numRaw = `#${issue.number}`;
17600
+ const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
17601
+ const stateRaw = issue.state.toLowerCase();
17602
+ const state = padToVisible(colorState(issue.state), stateRaw.length, stateWidth);
17603
+ const authorRaw = issue.author || '';
17604
+ const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
17605
+ const comments = typeof issue.comments === 'number' && issue.comments > 0
17606
+ ? chalk.dim(` ${issue.comments}c`)
17607
+ : '';
17608
+ const labels = formatLabels(issue.labels);
17609
+ const parts = [num, state, author, issue.title];
17610
+ if (labels)
17611
+ parts.push(labels);
17612
+ return parts.join(' ') + comments;
17613
+ })
17614
+ .join('\n');
17615
+ }
17616
+ function formatPullRequestList(items) {
17617
+ if (items.length === 0) {
17618
+ return chalk.dim('No pull requests match the current filter.');
17619
+ }
17620
+ const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
17621
+ const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
17622
+ const headWidth = Math.min(Math.max(...items.map((i) => i.headRefName.length), 1), 28);
17623
+ const stateWidth = 6;
17624
+ return items
17625
+ .map((pr) => {
17626
+ const numRaw = `#${pr.number}`;
17627
+ const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
17628
+ const stateRaw = pr.isDraft ? 'draft' : pr.state.toLowerCase();
17629
+ const stateColored = pr.isDraft ? chalk.dim('draft') : colorState(pr.state);
17630
+ const state = padToVisible(stateColored, stateRaw.length, stateWidth);
17631
+ const mergeable = formatMergeable(pr.mergeable, pr.mergeStateStatus);
17632
+ const review = formatReviewDecision(pr.reviewDecision);
17633
+ const authorRaw = pr.author || '';
17634
+ const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
17635
+ const branchTruncated = pr.headRefName.length > headWidth
17636
+ ? pr.headRefName.slice(0, headWidth - 1) + '…'
17637
+ : pr.headRefName;
17638
+ const branch = padToVisible(chalk.dim(branchTruncated), branchTruncated.length, headWidth);
17639
+ const labels = formatLabels(pr.labels);
17640
+ const parts = [num, state, mergeable, review, author, branch, pr.title];
17641
+ if (labels)
17642
+ parts.push(labels);
17643
+ return parts.join(' ');
17644
+ })
17645
+ .join('\n');
17646
+ }
17647
+
17648
+ /**
17649
+ * `gh issue list --json` field list. Centralized so any future
17650
+ * re-fetch (refresh, cache invalidation) requests the same shape and
17651
+ * the parser can rely on every field being present (even if optional).
17652
+ */
17653
+ const ISSUE_LIST_JSON_FIELDS = [
17654
+ 'number',
17655
+ 'title',
17656
+ 'url',
17657
+ 'state',
17658
+ 'author',
17659
+ 'assignees',
17660
+ 'labels',
17661
+ 'comments',
17662
+ 'createdAt',
17663
+ 'updatedAt',
17664
+ ].join(',');
17665
+ function parseIssueListItems(output) {
17666
+ const trimmed = output.trim();
17667
+ if (!trimmed)
17668
+ return [];
17669
+ const raw = JSON.parse(trimmed);
17670
+ return raw.map((entry) => {
17671
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
17672
+ ? String(entry.author.login)
17673
+ : undefined;
17674
+ const assignees = Array.isArray(entry.assignees)
17675
+ ? entry.assignees
17676
+ .map((a) => (a && 'login' in a ? String(a.login) : ''))
17677
+ .filter(Boolean)
17678
+ : undefined;
17679
+ const labels = Array.isArray(entry.labels)
17680
+ ? entry.labels
17681
+ .map((l) => (l && 'name' in l ? String(l.name) : ''))
17682
+ .filter(Boolean)
17683
+ : undefined;
17684
+ return {
17685
+ number: entry.number,
17686
+ title: String(entry.title || ''),
17687
+ url: String(entry.url || ''),
17688
+ state: String(entry.state || ''),
17689
+ author,
17690
+ assignees,
17691
+ labels,
17692
+ comments: typeof entry.comments === 'number' ? entry.comments : undefined,
17693
+ createdAt: String(entry.createdAt || ''),
17694
+ updatedAt: String(entry.updatedAt || ''),
17695
+ };
17696
+ });
17697
+ }
17698
+ function buildGhArgs$1(filter) {
17699
+ const args = ['issue', 'list', '--json', ISSUE_LIST_JSON_FIELDS];
17700
+ if (filter.state)
17701
+ args.push('--state', filter.state);
17702
+ if (filter.assignee)
17703
+ args.push('--assignee', filter.assignee);
17704
+ if (filter.author)
17705
+ args.push('--author', filter.author);
17706
+ if (filter.label)
17707
+ args.push('--label', filter.label);
17708
+ if (filter.search)
17709
+ args.push('--search', filter.search);
17710
+ if (typeof filter.limit === 'number')
17711
+ args.push('--limit', String(filter.limit));
17712
+ return args;
17713
+ }
17714
+ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
17715
+ const repository = await getGitHubRepository(git);
17716
+ if (!repository) {
17717
+ return {
17718
+ available: false,
17719
+ authenticated: false,
17720
+ filter,
17721
+ message: 'No GitHub remote detected.',
17722
+ };
17723
+ }
17724
+ if (!(await isGhAuthenticated(runner))) {
17725
+ return {
17726
+ available: true,
17727
+ authenticated: false,
17728
+ repository,
17729
+ filter,
17730
+ message: 'GitHub CLI is missing or not authenticated.',
17731
+ };
17732
+ }
17733
+ try {
17734
+ const output = await runner(buildGhArgs$1(filter));
17735
+ return {
17736
+ available: true,
17737
+ authenticated: true,
17738
+ repository,
17739
+ filter,
17740
+ issues: parseIssueListItems(output),
17741
+ };
17742
+ }
17743
+ catch (error) {
17744
+ return {
17745
+ available: true,
17746
+ authenticated: true,
17747
+ repository,
17748
+ filter,
17749
+ message: error instanceof Error ? error.message : 'Failed to fetch issue list.',
17750
+ };
17751
+ }
17752
+ }
17753
+
17754
+ const handler$5 = async (argv, logger) => {
17755
+ const git = applyRepoFlag(argv);
17756
+ // `applyRepoFlag` chdir'd to the repo path (or kept process.cwd
17757
+ // when --repo was omitted), so the cache key derives from a stable
17758
+ // absolute path either way.
17759
+ const repoPath = process.cwd();
17760
+ const filter = {
17761
+ state: argv.state,
17762
+ assignee: argv.mine ? '@me' : argv.assignee,
17763
+ author: argv.author,
17764
+ label: argv.label,
17765
+ search: argv.search,
17766
+ limit: argv.limit,
17767
+ };
17768
+ const cacheEnabled = !argv.noCache;
17769
+ let issues;
17770
+ let fromCache = false;
17771
+ let cacheAgeMs;
17772
+ // Repository metadata is needed for the header in both code paths
17773
+ // (cache hit and fresh fetch). The cache hit path skips
17774
+ // `getIssueList` entirely, so probe it directly here. Cheap — no
17775
+ // network, just a single `git remote` parse.
17776
+ const repository = await getGitHubRepository(git);
17777
+ if (cacheEnabled && !argv.refresh) {
17778
+ const cached = readCachedList('issues', repoPath, filter);
17779
+ if (cached?.fresh) {
17780
+ issues = cached.payload.items;
17781
+ fromCache = true;
17782
+ cacheAgeMs = cached.ageMs;
17783
+ }
17784
+ }
17785
+ if (!issues) {
17786
+ const overview = await getIssueList(git, filter);
17787
+ if (!overview.available) {
17788
+ logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
17789
+ commandExit(1);
17790
+ return;
17791
+ }
17792
+ if (!overview.authenticated) {
17793
+ logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
17794
+ logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable issue triage.'));
17795
+ commandExit(1);
17796
+ return;
17797
+ }
17798
+ if (overview.message) {
17799
+ logger.log(chalk.red(overview.message));
17800
+ commandExit(1);
17801
+ return;
17802
+ }
17803
+ issues = overview.issues || [];
17804
+ if (cacheEnabled) {
17805
+ writeCachedList(repoPath, filter, { kind: 'issues', items: issues });
17806
+ }
17807
+ }
17808
+ if (argv.json) {
17809
+ logger.log(JSON.stringify(issues, null, 2));
17810
+ return;
17811
+ }
17812
+ if (repository) {
17813
+ const filterParts = [];
17814
+ if (filter.state && filter.state !== 'open')
17815
+ filterParts.push(`state=${filter.state}`);
17816
+ if (filter.assignee)
17817
+ filterParts.push(`assignee=${filter.assignee}`);
17818
+ if (filter.author)
17819
+ filterParts.push(`author=${filter.author}`);
17820
+ if (filter.label)
17821
+ filterParts.push(`label=${filter.label}`);
17822
+ if (filter.search)
17823
+ filterParts.push(`search=${JSON.stringify(filter.search)}`);
17824
+ const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
17825
+ const cacheTag = fromCache && typeof cacheAgeMs === 'number'
17826
+ ? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
17827
+ : '';
17828
+ logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
17829
+ chalk.dim(` · ${issues.length} issue${issues.length === 1 ? '' : 's'}`) +
17830
+ suffix +
17831
+ cacheTag);
17832
+ logger.log('');
17833
+ }
17834
+ logger.log(formatIssueList(issues));
17835
+ };
17836
+
17837
+ var issues = {
17838
+ command: command$5,
17839
+ desc: 'List GitHub issues for the current repository (read-only triage)',
17840
+ builder: builder$5,
17841
+ handler: commandExecutor(handler$5),
17842
+ };
17843
+
17844
+ const command$4 = 'log';
17845
+ const options$4 = {
17184
17846
  i: {
17185
17847
  description: 'Open the interactive terminal log UI',
17186
17848
  type: 'boolean',
@@ -17243,8 +17905,8 @@ const options$3 = {
17243
17905
  default: 'compact',
17244
17906
  },
17245
17907
  };
17246
- const builder$3 = (yargs) => {
17247
- return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
17908
+ const builder$4 = (yargs) => {
17909
+ return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17248
17910
  };
17249
17911
 
17250
17912
  /**
@@ -17818,30 +18480,6 @@ async function getBranchOverview(git) {
17818
18480
  };
17819
18481
  }
17820
18482
 
17821
- const execFileAsync = util$1.promisify(child_process.execFile);
17822
- function parseGitHubRemoteUrl$1(url) {
17823
- const trimmed = url.trim().replace(/\.git$/, '');
17824
- const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
17825
- const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
17826
- const match = sshMatch || httpsMatch;
17827
- if (!match) {
17828
- return undefined;
17829
- }
17830
- return {
17831
- owner: match[1],
17832
- name: match[2],
17833
- };
17834
- }
17835
- async function defaultGhRunner(args) {
17836
- const result = await execFileAsync('gh', args);
17837
- return result.stdout;
17838
- }
17839
- async function getGitHubRepository(git) {
17840
- const remotes = await git.getRemotes(true);
17841
- const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
17842
- const url = remote?.refs.push || remote?.refs.fetch;
17843
- return url ? parseGitHubRemoteUrl$1(url) : undefined;
17844
- }
17845
18483
  function parsePullRequestInfo(output) {
17846
18484
  const trimmed = output.trim();
17847
18485
  if (!trimmed) {
@@ -17920,10 +18558,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
17920
18558
  message: 'No GitHub remote detected.',
17921
18559
  };
17922
18560
  }
17923
- try {
17924
- await runner(['auth', 'status', '--hostname', 'github.com']);
17925
- }
17926
- catch {
18561
+ if (!(await isGhAuthenticated(runner))) {
17927
18562
  return {
17928
18563
  available: true,
17929
18564
  authenticated: false,
@@ -19800,6 +20435,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19800
20435
  const key = getApiKeyForModel(config);
19801
20436
  const { provider } = getModelAndProviderFromConfig(config);
19802
20437
  const commitService = resolveDynamicService(config, 'commit');
20438
+ const splitService = resolveDynamicService(config, 'commitSplit');
19803
20439
  const model = commitService.model;
19804
20440
  if (config.service.authentication.type !== 'None' && !key) {
19805
20441
  return {
@@ -19810,6 +20446,10 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19810
20446
  try {
19811
20447
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
19812
20448
  const llm = getLlm(provider, model, { ...config, service: commitService });
20449
+ const planLlm = getLlm(provider, splitService.model, {
20450
+ ...config,
20451
+ service: splitService,
20452
+ });
19813
20453
  const result = await prepareCommitSplitPlan({
19814
20454
  argv,
19815
20455
  config,
@@ -19817,6 +20457,8 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19817
20457
  logger,
19818
20458
  tokenizer,
19819
20459
  llm,
20460
+ planLlm,
20461
+ planService: splitService,
19820
20462
  });
19821
20463
  if ('empty' in result) {
19822
20464
  return {
@@ -19958,7 +20600,7 @@ async function runPullRequestBodyWorkflow(input = {}) {
19958
20600
  const argv = createChangelogArgv({ branch: baseBranch });
19959
20601
  let raw = '';
19960
20602
  try {
19961
- raw = await captureStdout(() => handler$7(argv, new Logger({
20603
+ raw = await captureStdout(() => handler$9(argv, new Logger({
19962
20604
  verbose: true,
19963
20605
  silent: false,
19964
20606
  })));
@@ -20021,7 +20663,7 @@ async function runChangelogTextWorkflow(input = {}) {
20021
20663
  const argv = createChangelogArgv(input);
20022
20664
  let raw = '';
20023
20665
  try {
20024
- raw = await captureStdout(() => handler$7(argv, new Logger({
20666
+ raw = await captureStdout(() => handler$9(argv, new Logger({
20025
20667
  verbose: true,
20026
20668
  silent: false,
20027
20669
  })));
@@ -20043,10 +20685,12 @@ async function runChangelogTextWorkflow(input = {}) {
20043
20685
  const LOG_INK_CONTEXT_KEYS = [
20044
20686
  'bisect',
20045
20687
  'branches',
20688
+ 'issueList',
20046
20689
  'lfs',
20047
20690
  'operation',
20048
20691
  'provider',
20049
20692
  'pullRequest',
20693
+ 'pullRequestList',
20050
20694
  'reflog',
20051
20695
  'stashes',
20052
20696
  'submodules',
@@ -20492,6 +21136,60 @@ function getLogInkWorkflowActions() {
20492
21136
  kind: 'normal',
20493
21137
  requiresConfirmation: false,
20494
21138
  },
21139
+ // #882 phase 5 — triage-view destructive verbs. Each routed
21140
+ // through the y-confirm path so single-keystroke `x` / `a` /
21141
+ // `R` / `m` never silently rewrites publicly-visible state.
21142
+ // The runner reads the cursored item from the filtered list
21143
+ // at confirm-time — the cursor can't move while the
21144
+ // confirmation overlay is up, so no stale-target risk.
21145
+ {
21146
+ id: 'triage-issue-close',
21147
+ key: '',
21148
+ label: 'Close issue',
21149
+ description: 'Close the cursored issue on the triage list view.',
21150
+ kind: 'destructive',
21151
+ requiresConfirmation: true,
21152
+ },
21153
+ {
21154
+ id: 'triage-issue-reopen',
21155
+ key: '',
21156
+ label: 'Reopen issue',
21157
+ description: 'Reopen the cursored issue on the triage list view.',
21158
+ kind: 'normal',
21159
+ requiresConfirmation: true,
21160
+ },
21161
+ {
21162
+ id: 'triage-pr-merge',
21163
+ key: '',
21164
+ label: 'Merge pull request',
21165
+ description: 'Merge the cursored pull request on the triage list view (prompts for merge / squash / rebase, then confirms).',
21166
+ kind: 'destructive',
21167
+ requiresConfirmation: true,
21168
+ },
21169
+ {
21170
+ id: 'triage-pr-close',
21171
+ key: '',
21172
+ label: 'Close pull request',
21173
+ description: 'Close the cursored pull request on the triage list view without merging.',
21174
+ kind: 'destructive',
21175
+ requiresConfirmation: true,
21176
+ },
21177
+ {
21178
+ id: 'triage-pr-approve',
21179
+ key: '',
21180
+ label: 'Approve pull request',
21181
+ description: 'Submit an approving review on the cursored pull request.',
21182
+ kind: 'normal',
21183
+ requiresConfirmation: true,
21184
+ },
21185
+ {
21186
+ id: 'triage-pr-request-changes',
21187
+ key: '',
21188
+ label: 'Request changes on pull request',
21189
+ description: 'Submit a change-request review on the cursored pull request (prompts for body, then confirms).',
21190
+ kind: 'normal',
21191
+ requiresConfirmation: true,
21192
+ },
20495
21193
  {
20496
21194
  // Per-view-only: scoped to the history view in inkInput so `R`
20497
21195
  // doesn't fire elsewhere (it's also `R` for rename in branches
@@ -20887,6 +21585,20 @@ const LOG_INK_KEY_BINDINGS = [
20887
21585
  description: 'Push the dedicated pull-request action panel for the current branch.',
20888
21586
  contexts: ['normal'],
20889
21587
  },
21588
+ {
21589
+ id: 'navigatePullRequestTriage',
21590
+ keys: ['gP'],
21591
+ label: 'PR triage',
21592
+ description: 'Push the multi-PR triage list view (#882). Capital P disambiguates from `gp` which targets the single-PR panel for the current branch.',
21593
+ contexts: ['normal'],
21594
+ },
21595
+ {
21596
+ id: 'navigateIssues',
21597
+ keys: ['gi'],
21598
+ label: 'issues',
21599
+ description: 'Push the issue triage list view (#882).',
21600
+ contexts: ['normal'],
21601
+ },
20890
21602
  {
20891
21603
  id: 'navigateConflicts',
20892
21604
  keys: ['gx'],
@@ -21065,6 +21777,8 @@ const GLOBAL_BINDING_IDS = [
21065
21777
  'navigateStash',
21066
21778
  'navigateWorktrees',
21067
21779
  'navigatePullRequest',
21780
+ 'navigatePullRequestTriage',
21781
+ 'navigateIssues',
21068
21782
  'navigateConflicts',
21069
21783
  'navigateReflog',
21070
21784
  'navigateBisect',
@@ -21302,6 +22016,24 @@ function getLogInkFooterHints(options) {
21302
22016
  global: NORMAL_GLOBAL_HINTS,
21303
22017
  };
21304
22018
  }
22019
+ if (options.activeView === 'issues') {
22020
+ return {
22021
+ // #882 phase 4-6 — read + additive mutations + destructive
22022
+ // (gated through y-confirm) + filter cycling. AI summarize
22023
+ // (`I`) deferred to a follow-up.
22024
+ contextual: ['↑/↓ issues', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'x close*', 'X reopen', 'esc back'],
22025
+ global: NORMAL_GLOBAL_HINTS,
22026
+ };
22027
+ }
22028
+ if (options.activeView === 'pull-request-triage') {
22029
+ return {
22030
+ // #882 phase 4-6 — full PR action panel scoped to the triage
22031
+ // list + filter cycling. AI summarize (`I`) deferred to a
22032
+ // follow-up.
22033
+ contextual: ['↑/↓ PRs', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'm merge*', 'x close*', 'a approve', 'R changes*', 'esc back'],
22034
+ global: NORMAL_GLOBAL_HINTS,
22035
+ };
22036
+ }
21305
22037
  if (options.activeView === 'submodules') {
21306
22038
  return {
21307
22039
  contextual: ['↑/↓ entries', 'y yank path', 'Y yank sha', '/ filter', 'esc back'],
@@ -21482,6 +22214,109 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
21482
22214
  .map((entry) => entry.command);
21483
22215
  }
21484
22216
 
22217
+ /**
22218
+ * Canned filter presets for the issue / PR triage TUI views
22219
+ * (#882 phase 6). Each preset compiles to the same shape the
22220
+ * underlying list fetchers (`getIssueList` / `getPullRequestList`)
22221
+ * already accept — there's no new `gh` surface area, just a
22222
+ * curated set of common triage angles surfaced as a single
22223
+ * keystroke (`f` cycles).
22224
+ *
22225
+ * The presets are deliberately *not* a 1:1 mirror across the two
22226
+ * surfaces:
22227
+ *
22228
+ * - Issues have no draft / mergeable concept, so `draft` /
22229
+ * `mergeable` are skipped on the issue list.
22230
+ * - PRs have a `merged` state distinct from `closed`; issues
22231
+ * don't.
22232
+ * - `mine` semantics differ subtly: for issues it tends to
22233
+ * mean "I'm the assignee" (issues are tasks people pick up);
22234
+ * for PRs it means "I'm the author" (PRs are work people
22235
+ * post). The presets bake those in so the user doesn't have
22236
+ * to think about it.
22237
+ */
22238
+ /** Cycle order — must match the keystroke walk on `f`. */
22239
+ const ISSUE_FILTER_PRESETS = [
22240
+ 'open',
22241
+ 'closed',
22242
+ 'mine',
22243
+ 'assigned',
22244
+ ];
22245
+ const PULL_REQUEST_FILTER_PRESETS = [
22246
+ 'open',
22247
+ 'draft',
22248
+ 'mine',
22249
+ 'assigned',
22250
+ 'closed',
22251
+ 'merged',
22252
+ ];
22253
+ const ISSUE_FILTER_LABELS = {
22254
+ open: 'open',
22255
+ closed: 'closed',
22256
+ mine: 'mine (assigned)',
22257
+ assigned: 'assigned to me',
22258
+ };
22259
+ const PULL_REQUEST_FILTER_LABELS = {
22260
+ open: 'open',
22261
+ draft: 'draft',
22262
+ mine: 'mine (authored)',
22263
+ assigned: 'assigned to me',
22264
+ closed: 'closed',
22265
+ merged: 'merged',
22266
+ };
22267
+ /**
22268
+ * Resolve a preset to the filter object the data fetcher accepts.
22269
+ * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
22270
+ * `getPullRequestList` so unit tests can assert the mapping
22271
+ * independently from the fetch pipeline.
22272
+ */
22273
+ function issueFilterForPreset(preset) {
22274
+ switch (preset) {
22275
+ case 'open':
22276
+ return { state: 'open' };
22277
+ case 'closed':
22278
+ return { state: 'closed' };
22279
+ case 'mine':
22280
+ // Issues are tasks — "mine" is what *I'm working on*, i.e.
22281
+ // assigned to me + still open. Same as `assigned` plus the
22282
+ // open-state filter for ergonomic single-keystroke focus on
22283
+ // the active backlog.
22284
+ return { state: 'open', assignee: '@me' };
22285
+ case 'assigned':
22286
+ return { assignee: '@me' };
22287
+ }
22288
+ }
22289
+ function pullRequestFilterForPreset(preset) {
22290
+ switch (preset) {
22291
+ case 'open':
22292
+ return { state: 'open' };
22293
+ case 'draft':
22294
+ // gh's `--draft` flag implies `--state open`; surface that
22295
+ // explicitly so the canonicalize step doesn't elide it.
22296
+ return { state: 'open', draft: true };
22297
+ case 'mine':
22298
+ // PRs are work — "mine" is what *I authored*. Most useful
22299
+ // when looking at one's own backlog of in-flight PRs.
22300
+ return { state: 'open', author: '@me' };
22301
+ case 'assigned':
22302
+ return { assignee: '@me' };
22303
+ case 'closed':
22304
+ return { state: 'closed' };
22305
+ case 'merged':
22306
+ return { state: 'merged' };
22307
+ }
22308
+ }
22309
+ function cycleIssueFilterPreset(current) {
22310
+ const index = ISSUE_FILTER_PRESETS.indexOf(current);
22311
+ const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
22312
+ return ISSUE_FILTER_PRESETS[next];
22313
+ }
22314
+ function cyclePullRequestFilterPreset(current) {
22315
+ const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
22316
+ const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
22317
+ return PULL_REQUEST_FILTER_PRESETS[next];
22318
+ }
22319
+
21485
22320
  /**
21486
22321
  * Sort modes for the promoted views (P4.2).
21487
22322
  *
@@ -21869,6 +22704,10 @@ function createLogInkState(rows, options = {}) {
21869
22704
  selectedConflictFileIndex: 0,
21870
22705
  selectedReflogIndex: 0,
21871
22706
  selectedSubmoduleIndex: 0,
22707
+ selectedIssueIndex: 0,
22708
+ selectedPullRequestTriageIndex: 0,
22709
+ selectedIssueFilter: 'open',
22710
+ selectedPullRequestFilter: 'open',
21872
22711
  repoStack: [{ label: options.repoLabel || 'root' }],
21873
22712
  branchSort: DEFAULT_BRANCH_SORT_MODE,
21874
22713
  tagSort: DEFAULT_TAG_SORT_MODE,
@@ -22131,6 +22970,36 @@ function applyLogInkAction(state, action) {
22131
22970
  selectedSubmoduleIndex: clampIndex(state.selectedSubmoduleIndex + action.delta, action.count),
22132
22971
  pendingKey: undefined,
22133
22972
  };
22973
+ case 'moveIssue':
22974
+ return {
22975
+ ...state,
22976
+ selectedIssueIndex: clampIndex(state.selectedIssueIndex + action.delta, action.count),
22977
+ pendingKey: undefined,
22978
+ };
22979
+ case 'movePullRequestTriage':
22980
+ return {
22981
+ ...state,
22982
+ selectedPullRequestTriageIndex: clampIndex(state.selectedPullRequestTriageIndex + action.delta, action.count),
22983
+ pendingKey: undefined,
22984
+ };
22985
+ case 'cycleIssueFilter':
22986
+ // Advance the preset, snap the cursor to the top of the
22987
+ // (newly filtered) list — same UX rule as `cycleBranchSort`.
22988
+ // The list refetches on preset change via the effect in
22989
+ // app.ts, so the cursor at 0 lands on whatever was promoted.
22990
+ return {
22991
+ ...state,
22992
+ selectedIssueFilter: cycleIssueFilterPreset(state.selectedIssueFilter),
22993
+ selectedIssueIndex: 0,
22994
+ pendingKey: undefined,
22995
+ };
22996
+ case 'cyclePullRequestTriageFilter':
22997
+ return {
22998
+ ...state,
22999
+ selectedPullRequestFilter: cyclePullRequestFilterPreset(state.selectedPullRequestFilter),
23000
+ selectedPullRequestTriageIndex: 0,
23001
+ pendingKey: undefined,
23002
+ };
22134
23003
  case 'moveWorktreeListEntry':
22135
23004
  return {
22136
23005
  ...state,
@@ -22953,6 +23822,22 @@ function isReflogActionTarget(state) {
22953
23822
  function isSubmodulesActionTarget(state) {
22954
23823
  return state.activeView === 'submodules' && state.focus === 'commits';
22955
23824
  }
23825
+ /**
23826
+ * Issue triage list (#882 phase 3). Same shape as the other promoted
23827
+ * read-only views — j/k move the cursor when the commits pane is
23828
+ * focused on the dedicated view.
23829
+ */
23830
+ function isIssueActionTarget(state) {
23831
+ return state.activeView === 'issues' && state.focus === 'commits';
23832
+ }
23833
+ /**
23834
+ * Pull-request triage list (#882 phase 3). Distinct from the existing
23835
+ * `pull-request` single-PR action panel — this is the multi-PR list
23836
+ * surface and its cursor lives in `selectedPullRequestTriageIndex`.
23837
+ */
23838
+ function isPullRequestTriageActionTarget(state) {
23839
+ return state.activeView === 'pull-request-triage' && state.focus === 'commits';
23840
+ }
22956
23841
  function isWorktreeActionTarget(state) {
22957
23842
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
22958
23843
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
@@ -23102,6 +23987,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
23102
23987
  return [action({ type: 'pushView', value: 'worktrees' })];
23103
23988
  case 'navigatePullRequest':
23104
23989
  return [action({ type: 'pushView', value: 'pull-request' })];
23990
+ case 'navigatePullRequestTriage':
23991
+ return [action({ type: 'pushView', value: 'pull-request-triage' })];
23992
+ case 'navigateIssues':
23993
+ return [action({ type: 'pushView', value: 'issues' })];
23105
23994
  case 'navigateConflicts':
23106
23995
  return [action({ type: 'pushView', value: 'conflicts' })];
23107
23996
  case 'navigateReflog':
@@ -23276,6 +24165,69 @@ function submitInputPrompt(state) {
23276
24165
  action({ type: 'closeInputPrompt' }),
23277
24166
  ];
23278
24167
  }
24168
+ // #882 phase 4 — triage-view mutation prompts. Each kind routes to
24169
+ // its by-number workflow id; the runner reads the cursored item
24170
+ // from state + filtered list and runs the matching `gh` action.
24171
+ if (state.inputPrompt.kind === 'triage-issue-comment') {
24172
+ return [
24173
+ { type: 'runWorkflowAction', id: 'triage-issue-comment', payload: value },
24174
+ action({ type: 'closeInputPrompt' }),
24175
+ ];
24176
+ }
24177
+ if (state.inputPrompt.kind === 'triage-issue-label') {
24178
+ return [
24179
+ { type: 'runWorkflowAction', id: 'triage-issue-label', payload: value },
24180
+ action({ type: 'closeInputPrompt' }),
24181
+ ];
24182
+ }
24183
+ if (state.inputPrompt.kind === 'triage-issue-assign') {
24184
+ return [
24185
+ { type: 'runWorkflowAction', id: 'triage-issue-assign', payload: value },
24186
+ action({ type: 'closeInputPrompt' }),
24187
+ ];
24188
+ }
24189
+ if (state.inputPrompt.kind === 'triage-pr-comment') {
24190
+ return [
24191
+ { type: 'runWorkflowAction', id: 'triage-pr-comment', payload: value },
24192
+ action({ type: 'closeInputPrompt' }),
24193
+ ];
24194
+ }
24195
+ if (state.inputPrompt.kind === 'triage-pr-label') {
24196
+ return [
24197
+ { type: 'runWorkflowAction', id: 'triage-pr-label', payload: value },
24198
+ action({ type: 'closeInputPrompt' }),
24199
+ ];
24200
+ }
24201
+ if (state.inputPrompt.kind === 'triage-pr-assign') {
24202
+ return [
24203
+ { type: 'runWorkflowAction', id: 'triage-pr-assign', payload: value },
24204
+ action({ type: 'closeInputPrompt' }),
24205
+ ];
24206
+ }
24207
+ // #882 phase 5 — destructive prompt submissions route through the
24208
+ // y-confirm path (not directly to runWorkflowAction) so the user
24209
+ // gets a final "are you sure?" before anything ships. The
24210
+ // collected value (strategy / body) rides along as the
24211
+ // confirmation payload.
24212
+ if (state.inputPrompt.kind === 'triage-pr-merge-strategy') {
24213
+ const strategy = value.toLowerCase();
24214
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
24215
+ return [action({
24216
+ type: 'setStatus',
24217
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
24218
+ })];
24219
+ }
24220
+ return [
24221
+ action({ type: 'setPendingConfirmation', value: 'triage-pr-merge', payload: strategy }),
24222
+ action({ type: 'closeInputPrompt' }),
24223
+ ];
24224
+ }
24225
+ if (state.inputPrompt.kind === 'triage-pr-request-changes') {
24226
+ return [
24227
+ action({ type: 'setPendingConfirmation', value: 'triage-pr-request-changes', payload: value }),
24228
+ action({ type: 'closeInputPrompt' }),
24229
+ ];
24230
+ }
23279
24231
  if (state.inputPrompt.kind === 'pr-request-changes') {
23280
24232
  return [
23281
24233
  action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
@@ -23698,6 +24650,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
23698
24650
  action({ type: 'setStatus', value: 'jumped to pull request' }),
23699
24651
  ];
23700
24652
  }
24653
+ // `gP` chord (#882 phase 3): jump to the multi-PR triage list.
24654
+ // Capital P disambiguates from `gp` (current-branch PR panel).
24655
+ // Pleasingly symmetric with `gi` for issues — both lead to the
24656
+ // read-only list views shipped in #882.
24657
+ if (state.pendingKey === 'g' && inputValue === 'P') {
24658
+ return [
24659
+ action({ type: 'pushView', value: 'pull-request-triage' }),
24660
+ action({ type: 'setStatus', value: 'jumped to PR triage' }),
24661
+ ];
24662
+ }
24663
+ // `gi` chord (#882 phase 3): jump to the issue triage list.
24664
+ if (state.pendingKey === 'g' && inputValue === 'i') {
24665
+ return [
24666
+ action({ type: 'pushView', value: 'issues' }),
24667
+ action({ type: 'setStatus', value: 'jumped to issues' }),
24668
+ ];
24669
+ }
23701
24670
  if (state.pendingKey === 'g' && inputValue === 'x') {
23702
24671
  return [
23703
24672
  action({ type: 'pushView', value: 'conflicts' }),
@@ -24136,6 +25105,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24136
25105
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24137
25106
  return [action({ type: 'moveSubmodule', delta: -1, count: context.submoduleCount })];
24138
25107
  }
25108
+ if (isIssueActionTarget(state) && context.issueCount) {
25109
+ return [action({ type: 'moveIssue', delta: -1, count: context.issueCount })];
25110
+ }
25111
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
25112
+ return [action({
25113
+ type: 'movePullRequestTriage',
25114
+ delta: -1,
25115
+ count: context.pullRequestTriageCount,
25116
+ })];
25117
+ }
24139
25118
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
24140
25119
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
24141
25120
  }
@@ -24223,6 +25202,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24223
25202
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24224
25203
  return [action({ type: 'moveSubmodule', delta: 1, count: context.submoduleCount })];
24225
25204
  }
25205
+ if (isIssueActionTarget(state) && context.issueCount) {
25206
+ return [action({ type: 'moveIssue', delta: 1, count: context.issueCount })];
25207
+ }
25208
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
25209
+ return [action({
25210
+ type: 'movePullRequestTriage',
25211
+ delta: 1,
25212
+ count: context.pullRequestTriageCount,
25213
+ })];
25214
+ }
24226
25215
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
24227
25216
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
24228
25217
  }
@@ -24639,6 +25628,120 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24639
25628
  multiline: true,
24640
25629
  })];
24641
25630
  }
25631
+ // #882 phase 4 — issue triage per-row actions. Scoped to the
25632
+ // `'issues'` view + commits focus so the single-letter keys stay
25633
+ // free elsewhere. Each prompts; submit dispatches the matching
25634
+ // `triage-issue-*` workflow which routes through `gh issue` and
25635
+ // invalidates both the in-memory + disk caches on success.
25636
+ if (state.activeView === 'issues' && state.focus === 'commits') {
25637
+ if (inputValue === 'O' && context.issueSelectedUrl) {
25638
+ return [{ type: 'runWorkflowAction', id: 'triage-issue-open' }];
25639
+ }
25640
+ if (inputValue === 'c' && context.issueCount) {
25641
+ return [action({
25642
+ type: 'openInputPrompt',
25643
+ kind: 'triage-issue-comment',
25644
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
25645
+ multiline: true,
25646
+ })];
25647
+ }
25648
+ if (inputValue === 'L' && context.issueCount) {
25649
+ return [action({
25650
+ type: 'openInputPrompt',
25651
+ kind: 'triage-issue-label',
25652
+ label: 'Label name to add',
25653
+ })];
25654
+ }
25655
+ if (inputValue === 'A' && context.issueCount) {
25656
+ return [action({
25657
+ type: 'openInputPrompt',
25658
+ kind: 'triage-issue-assign',
25659
+ label: 'Assignee login (or @me)',
25660
+ initial: '@me',
25661
+ })];
25662
+ }
25663
+ // #882 phase 5 — destructive issue mutations. Both gated through
25664
+ // the y-confirm path. `x` closes (matches `pull-request` view's
25665
+ // close binding); `X` reopens, useful to undo a stray close.
25666
+ if (inputValue === 'x' && context.issueCount) {
25667
+ return [action({ type: 'setPendingConfirmation', value: 'triage-issue-close' })];
25668
+ }
25669
+ if (inputValue === 'X' && context.issueCount) {
25670
+ return [action({ type: 'setPendingConfirmation', value: 'triage-issue-reopen' })];
25671
+ }
25672
+ // #882 phase 6 — cycle the canned filter preset (open → closed
25673
+ // → mine → assigned → open). The effect in app.ts watches
25674
+ // `state.selectedIssueFilter` and refetches with the matching
25675
+ // filter object, so the list updates without an explicit
25676
+ // refresh keystroke.
25677
+ if (inputValue === 'f') {
25678
+ return [action({ type: 'cycleIssueFilter' })];
25679
+ }
25680
+ }
25681
+ // #882 phase 4 — PR triage per-row actions. Same shape as the
25682
+ // issue handlers above; distinct view id so the keys don't
25683
+ // collide with the single-PR action panel (`pull-request`).
25684
+ if (state.activeView === 'pull-request-triage' && state.focus === 'commits') {
25685
+ if (inputValue === 'O' && context.pullRequestTriageSelectedUrl) {
25686
+ return [{ type: 'runWorkflowAction', id: 'triage-pr-open' }];
25687
+ }
25688
+ if (inputValue === 'c' && context.pullRequestTriageCount) {
25689
+ return [action({
25690
+ type: 'openInputPrompt',
25691
+ kind: 'triage-pr-comment',
25692
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
25693
+ multiline: true,
25694
+ })];
25695
+ }
25696
+ if (inputValue === 'L' && context.pullRequestTriageCount) {
25697
+ return [action({
25698
+ type: 'openInputPrompt',
25699
+ kind: 'triage-pr-label',
25700
+ label: 'Label name to add',
25701
+ })];
25702
+ }
25703
+ if (inputValue === 'A' && context.pullRequestTriageCount) {
25704
+ return [action({
25705
+ type: 'openInputPrompt',
25706
+ kind: 'triage-pr-assign',
25707
+ label: 'Assignee login (or @me)',
25708
+ initial: '@me',
25709
+ })];
25710
+ }
25711
+ // #882 phase 5 — destructive PR mutations on the triage view.
25712
+ // Mirror the single-PR action panel's keys (m / x / a / R) but
25713
+ // route to the by-number workflows. `m` and `R` open input
25714
+ // prompts first; submit lands the strategy / body as the
25715
+ // confirmation payload, which the runner picks up after y.
25716
+ if (inputValue === 'm' && context.pullRequestTriageCount) {
25717
+ return [action({
25718
+ type: 'openInputPrompt',
25719
+ kind: 'triage-pr-merge-strategy',
25720
+ label: 'Merge strategy (merge / squash / rebase)',
25721
+ })];
25722
+ }
25723
+ if (inputValue === 'x' && context.pullRequestTriageCount) {
25724
+ return [action({ type: 'setPendingConfirmation', value: 'triage-pr-close' })];
25725
+ }
25726
+ if (inputValue === 'a' && context.pullRequestTriageCount) {
25727
+ return [action({ type: 'setPendingConfirmation', value: 'triage-pr-approve' })];
25728
+ }
25729
+ if (inputValue === 'R' && context.pullRequestTriageCount) {
25730
+ return [action({
25731
+ type: 'openInputPrompt',
25732
+ kind: 'triage-pr-request-changes',
25733
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
25734
+ multiline: true,
25735
+ })];
25736
+ }
25737
+ // #882 phase 6 — cycle the canned filter preset (open → draft
25738
+ // → mine → assigned → closed → merged → open). The effect in
25739
+ // app.ts watches `state.selectedPullRequestFilter` and refetches
25740
+ // with the matching filter object.
25741
+ if (inputValue === 'f') {
25742
+ return [action({ type: 'cyclePullRequestTriageFilter' })];
25743
+ }
25744
+ }
24642
25745
  // Global stash hotkey: `S` opens a stash-message prompt and
24643
25746
  // `createStash` runs once submitted. Available everywhere there's
24644
25747
  // not a more modal handler in front of it.
@@ -24889,6 +25992,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24889
25992
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24890
25993
  return [{ type: 'yankFromActiveView', short }];
24891
25994
  }
25995
+ // #882 phase 4 — triage views: y yanks the cursored issue / PR
25996
+ // URL so the user can paste it into a chat / PR description
25997
+ // without dropping back to the browser. Y is a no-op on these
25998
+ // views — there's no compact alternate identifier worth a
25999
+ // second key.
26000
+ if (isIssueActionTarget(state) && context.issueSelectedUrl) {
26001
+ return [{ type: 'yankFromActiveView' }];
26002
+ }
26003
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageSelectedUrl) {
26004
+ return [{ type: 'yankFromActiveView' }];
26005
+ }
24892
26006
  }
24893
26007
  // Enter on a stash row pushes the diff view scoped to that stash.
24894
26008
  // The runtime loads `git stash show -p <ref>` once the view is
@@ -25398,9 +26512,56 @@ const LOG_INK_DEFAULT_ROWS = 40;
25398
26512
  * gives both views their own air.
25399
26513
  */
25400
26514
  const INSPECTOR_TABBED_BELOW_ROWS = 28;
26515
+ /**
26516
+ * Density-tier breakpoints in columns. Picked so the three legacy
26517
+ * panels (sidebar ~24-32 + inspector ~20-32 + main) still leave the
26518
+ * history panel with at least ~40 usable cells before we start
26519
+ * collapsing chrome:
26520
+ *
26521
+ * wide >= 160 — plenty of room; keep absolute dates
26522
+ * normal >= 120 — relative dates save 8-ish cells without hiding info
26523
+ * tight >= 100 — drop date entirely; subject + refs are the priority
26524
+ * rail < 100 — even with side panels collapsed the row is tight;
26525
+ * stack to two lines and rail the side panels at rest
26526
+ */
26527
+ const LAYOUT_TIGHT_BELOW = 120;
26528
+ const LAYOUT_NORMAL_BELOW = 160;
26529
+ const LAYOUT_RAIL_BELOW = 100;
26530
+ /**
26531
+ * Fixed cell width for a railed side panel. Just wide enough for a
26532
+ * 1-cell icon + a 2-3 digit count after subtracting border (2) and
26533
+ * padding (2). Going narrower clips the count; going wider defeats
26534
+ * the purpose of railing in the first place.
26535
+ */
26536
+ const LAYOUT_RAIL_PANEL_WIDTH = 8;
26537
+ const SIDEBAR_AT_REST_BY_TIER = {
26538
+ rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
26539
+ tight: { min: 22, max: 28, fraction: 0.24 },
26540
+ normal: { min: 22, max: 30, fraction: 0.22 },
26541
+ wide: { min: 28, max: 48, fraction: 0.24 },
26542
+ };
26543
+ function calcSidebarAtRestWidth(columns, density) {
26544
+ const config = SIDEBAR_AT_REST_BY_TIER[density];
26545
+ return Math.max(config.min, Math.min(config.max, Math.floor(columns * config.fraction)));
26546
+ }
25401
26547
  function getLogInkLayout(input) {
25402
26548
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
25403
26549
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
26550
+ const density = columns >= LAYOUT_NORMAL_BELOW
26551
+ ? 'wide'
26552
+ : columns >= LAYOUT_TIGHT_BELOW
26553
+ ? 'normal'
26554
+ : columns >= LAYOUT_RAIL_BELOW
26555
+ ? 'tight'
26556
+ : 'rail';
26557
+ // Rail collapse: only happens at the narrowest tier, and only for
26558
+ // the panel that does NOT currently hold focus AND is not being
26559
+ // commandeered by the help overlay. Focus always wins — pressing
26560
+ // tab to the sidebar pops it back open even on an 80-cell terminal
26561
+ // so the user can actually use it. The help overlay also wins for
26562
+ // the inspector since that's where its descriptions render.
26563
+ const sidebarRailed = density === 'rail' && !input.sidebarFocused;
26564
+ const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
25404
26565
  // Inspector width — at rest 20-32 cells (~22% of width), focused
25405
26566
  // 36-60 cells (~40% of width). Narrow rest state keeps the commit
25406
26567
  // graph dominant; focus expansion gives the inspector room for long
@@ -25411,17 +26572,30 @@ function getLogInkLayout(input) {
25411
26572
  // hotkey descriptions render in full instead of truncating to
25412
26573
  // "Move focus...". Capped at 100 cells so a wide terminal doesn't
25413
26574
  // waste an absurd amount of horizontal space on the cheat sheet.
26575
+ //
26576
+ // Rail collapse wins over the at-rest range but loses to focus and
26577
+ // to the help overlay — both of those represent deliberate user
26578
+ // intent to read the panel.
25414
26579
  const detailWidth = input.helpOverlayActive
25415
26580
  ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
25416
26581
  : input.inspectorFocused
25417
26582
  ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
25418
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
25419
- // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
25420
- // (~36% of width). The transition is instant per render — focus tab to
25421
- // expand, focus away to collapse.
26583
+ : inspectorRailed
26584
+ ? LAYOUT_RAIL_PANEL_WIDTH
26585
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
26586
+ // Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
26587
+ // tight stays compact (22-28), normal shrinks slightly (22-30),
26588
+ // wide grows naturally (28-48) so the side panel doesn't get pinned
26589
+ // at an arbitrary cap on big terminals while the main panel hogs
26590
+ // 80% of the width. Focused: 32-50 cells (~36% of width),
26591
+ // regardless of tier — deliberate user intent to read the sidebar
26592
+ // deserves the extra width. Rail mode (narrow terminal, unfocused)
26593
+ // collapses to a fixed 8-cell strip with tab glyphs only.
25422
26594
  const sidebarWidth = input.sidebarFocused
25423
26595
  ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
25424
- : Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
26596
+ : sidebarRailed
26597
+ ? LAYOUT_RAIL_PANEL_WIDTH
26598
+ : calcSidebarAtRestWidth(columns, density);
25425
26599
  return {
25426
26600
  bodyRows: Math.max(8, rows - 5),
25427
26601
  columns,
@@ -25431,6 +26605,10 @@ function getLogInkLayout(input) {
25431
26605
  sidebarWidth,
25432
26606
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
25433
26607
  inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
26608
+ density,
26609
+ sidebarRailed,
26610
+ inspectorRailed,
26611
+ historyRowMode: density === 'rail' ? 'stacked' : 'single',
25434
26612
  };
25435
26613
  }
25436
26614
 
@@ -26140,6 +27318,382 @@ function stageConflictResolved(git, path) {
26140
27318
  return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
26141
27319
  }
26142
27320
 
27321
+ /**
27322
+ * Per-item issue detail fetcher (#882 inspector hydration). The list
27323
+ * payload from `gh issue list` deliberately omits bodies and
27324
+ * comments to keep the list fetch cheap; this module fills those in
27325
+ * on demand when the user rests the cursor on a specific issue.
27326
+ *
27327
+ * Called from the workstation runtime with a debounced timer so
27328
+ * rapid j/k navigation doesn't spam `gh`. Results land in a
27329
+ * `Map<number, IssueDetail>` cache on `LogInkContext` keyed by
27330
+ * issue number, so cursoring back to a previously-fetched item
27331
+ * shows instantly.
27332
+ */
27333
+ /**
27334
+ * `gh issue view <#> --json` field list. Kept separate from the
27335
+ * list-view field list since the detail view only needs the
27336
+ * fields that the list payload doesn't already carry.
27337
+ */
27338
+ const ISSUE_DETAIL_JSON_FIELDS = ['number', 'body', 'comments'].join(',');
27339
+ function parseIssueComments(value) {
27340
+ if (!Array.isArray(value))
27341
+ return [];
27342
+ return value.map((entry) => {
27343
+ const raw = entry;
27344
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27345
+ ? String(raw.author.login)
27346
+ : undefined;
27347
+ return {
27348
+ author,
27349
+ body: typeof raw.body === 'string' ? raw.body : '',
27350
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
27351
+ };
27352
+ });
27353
+ }
27354
+ function parseIssueDetail(output) {
27355
+ const trimmed = output.trim();
27356
+ if (!trimmed)
27357
+ return undefined;
27358
+ const raw = JSON.parse(trimmed);
27359
+ if (typeof raw.number !== 'number')
27360
+ return undefined;
27361
+ return {
27362
+ number: raw.number,
27363
+ body: typeof raw.body === 'string' ? raw.body : '',
27364
+ comments: parseIssueComments(raw.comments),
27365
+ };
27366
+ }
27367
+ async function getIssueDetail(issueNumber, runner = defaultGhRunner) {
27368
+ try {
27369
+ const output = await runner([
27370
+ 'issue',
27371
+ 'view',
27372
+ String(issueNumber),
27373
+ '--json',
27374
+ ISSUE_DETAIL_JSON_FIELDS,
27375
+ ]);
27376
+ const detail = parseIssueDetail(output);
27377
+ if (!detail) {
27378
+ return { ok: false, message: `Empty response from gh for issue #${issueNumber}` };
27379
+ }
27380
+ return { ok: true, detail };
27381
+ }
27382
+ catch (error) {
27383
+ return {
27384
+ ok: false,
27385
+ message: error instanceof Error ? error.message : String(error),
27386
+ };
27387
+ }
27388
+ }
27389
+
27390
+ /**
27391
+ * Low-risk issue mutations driven from the issue-triage TUI (#882
27392
+ * phase 4). Mirrors `pullRequestActions.ts`'s shape — each function
27393
+ * wraps a single `gh issue <verb>` invocation through the shared
27394
+ * runner indirection so tests can mock the shell-out cleanly.
27395
+ *
27396
+ * "Low risk" here means: reversible by re-invoking with the
27397
+ * opposite flag (`--add-label` ↔ `--remove-label`), or strictly
27398
+ * additive (comment). The destructive verbs (`close`, `reopen`,
27399
+ * `delete`) land in phase 5 alongside the PR-level destructive
27400
+ * mutations, all gated through the y-confirm path.
27401
+ */
27402
+ async function runGhAction$1(runner, args, successMessage) {
27403
+ try {
27404
+ return successMessage(await runner(args));
27405
+ }
27406
+ catch (error) {
27407
+ return {
27408
+ ok: false,
27409
+ message: error.message,
27410
+ };
27411
+ }
27412
+ }
27413
+ function commentIssue(issueNumber, body, runner = defaultGhRunner) {
27414
+ if (!body.trim()) {
27415
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
27416
+ }
27417
+ return runGhAction$1(runner, ['issue', 'comment', String(issueNumber), '--body', body], (output) => ({
27418
+ ok: true,
27419
+ message: output.trim() || `Commented on issue #${issueNumber}`,
27420
+ }));
27421
+ }
27422
+ function addIssueLabel(issueNumber, label, runner = defaultGhRunner) {
27423
+ if (!label.trim()) {
27424
+ return Promise.resolve({ ok: false, message: 'Label name required' });
27425
+ }
27426
+ return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-label', label], () => ({
27427
+ ok: true,
27428
+ message: `Added label '${label}' to issue #${issueNumber}`,
27429
+ }));
27430
+ }
27431
+ function addIssueAssignee(issueNumber, assignee, runner = defaultGhRunner) {
27432
+ if (!assignee.trim()) {
27433
+ return Promise.resolve({ ok: false, message: 'Assignee login required' });
27434
+ }
27435
+ return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-assignee', assignee], () => ({
27436
+ ok: true,
27437
+ message: `Assigned ${assignee} to issue #${issueNumber}`,
27438
+ }));
27439
+ }
27440
+ /**
27441
+ * Destructive issue verbs (#882 phase 5). Both routed through the
27442
+ * y-confirm path in the workstation; the action functions themselves
27443
+ * make no extra guarantee — every gh-side error surfaces via the
27444
+ * standard `runGhAction` error wrapper.
27445
+ */
27446
+ function closeIssue(issueNumber, runner = defaultGhRunner) {
27447
+ return runGhAction$1(runner, ['issue', 'close', String(issueNumber)], (output) => ({
27448
+ ok: true,
27449
+ message: output.trim() || `Closed issue #${issueNumber}`,
27450
+ }));
27451
+ }
27452
+ function reopenIssue(issueNumber, runner = defaultGhRunner) {
27453
+ return runGhAction$1(runner, ['issue', 'reopen', String(issueNumber)], (output) => ({
27454
+ ok: true,
27455
+ message: output.trim() || `Reopened issue #${issueNumber}`,
27456
+ }));
27457
+ }
27458
+
27459
+ /**
27460
+ * Per-item PR detail fetcher (#882 inspector hydration). Mirrors
27461
+ * `issueDetailData.ts`'s shape — pulls body, comments, reviews,
27462
+ * and the status-check rollup on demand when the user rests the
27463
+ * cursor on a PR row.
27464
+ *
27465
+ * Distinct from the existing `pullRequestData.ts` which fetches
27466
+ * the CURRENT BRANCH's PR via `gh pr view` (no number arg). This
27467
+ * fetcher takes an explicit PR number so the triage view can
27468
+ * hydrate any cursored PR, not just the one matching the current
27469
+ * branch.
27470
+ */
27471
+ /**
27472
+ * `gh pr view <#> --json` field list. Subset of what
27473
+ * `pullRequestData.ts`'s `PULL_REQUEST_VIEW_JSON_FIELDS` includes —
27474
+ * the triage list payload already carries the structural metadata
27475
+ * (state, isDraft, branches, labels, etc.), so the detail fetch
27476
+ * only needs the heavy/expensive fields that the list omits.
27477
+ */
27478
+ const PULL_REQUEST_DETAIL_JSON_FIELDS = [
27479
+ 'number',
27480
+ 'body',
27481
+ 'comments',
27482
+ 'reviews',
27483
+ 'statusCheckRollup',
27484
+ ].join(',');
27485
+ function parseComments(value) {
27486
+ if (!Array.isArray(value))
27487
+ return [];
27488
+ return value.map((entry) => {
27489
+ const raw = entry;
27490
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27491
+ ? String(raw.author.login)
27492
+ : undefined;
27493
+ return {
27494
+ author,
27495
+ body: typeof raw.body === 'string' ? raw.body : '',
27496
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
27497
+ };
27498
+ });
27499
+ }
27500
+ function parseReviews(value) {
27501
+ if (!Array.isArray(value))
27502
+ return [];
27503
+ return value
27504
+ .map((entry) => {
27505
+ const raw = entry;
27506
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27507
+ ? String(raw.author.login)
27508
+ : undefined;
27509
+ return {
27510
+ author,
27511
+ state: typeof raw.state === 'string' ? raw.state : '',
27512
+ body: typeof raw.body === 'string' ? raw.body : '',
27513
+ submittedAt: typeof raw.submittedAt === 'string' ? raw.submittedAt : '',
27514
+ };
27515
+ })
27516
+ // gh occasionally returns review entries without an author when the
27517
+ // reviewer's account is deleted. Those are unactionable noise here;
27518
+ // strip them so the inspector doesn't render anonymous rows.
27519
+ .filter((review) => review.author || review.body);
27520
+ }
27521
+ function parseStatusCheckRollup(value) {
27522
+ if (!Array.isArray(value))
27523
+ return [];
27524
+ return value.map((entry) => {
27525
+ const raw = entry;
27526
+ return {
27527
+ name: String(raw.name || raw.context || 'check'),
27528
+ status: typeof raw.status === 'string' ? raw.status : undefined,
27529
+ conclusion: typeof raw.conclusion === 'string' ? raw.conclusion : undefined,
27530
+ };
27531
+ });
27532
+ }
27533
+ function parsePullRequestDetail(output) {
27534
+ const trimmed = output.trim();
27535
+ if (!trimmed)
27536
+ return undefined;
27537
+ const raw = JSON.parse(trimmed);
27538
+ if (typeof raw.number !== 'number')
27539
+ return undefined;
27540
+ return {
27541
+ number: raw.number,
27542
+ body: typeof raw.body === 'string' ? raw.body : '',
27543
+ comments: parseComments(raw.comments),
27544
+ reviews: parseReviews(raw.reviews),
27545
+ statusCheckRollup: parseStatusCheckRollup(raw.statusCheckRollup),
27546
+ };
27547
+ }
27548
+ async function getPullRequestDetail(pullRequestNumber, runner = defaultGhRunner) {
27549
+ try {
27550
+ const output = await runner([
27551
+ 'pr',
27552
+ 'view',
27553
+ String(pullRequestNumber),
27554
+ '--json',
27555
+ PULL_REQUEST_DETAIL_JSON_FIELDS,
27556
+ ]);
27557
+ const detail = parsePullRequestDetail(output);
27558
+ if (!detail) {
27559
+ return {
27560
+ ok: false,
27561
+ message: `Empty response from gh for pull request #${pullRequestNumber}`,
27562
+ };
27563
+ }
27564
+ return { ok: true, detail };
27565
+ }
27566
+ catch (error) {
27567
+ return {
27568
+ ok: false,
27569
+ message: error instanceof Error ? error.message : String(error),
27570
+ };
27571
+ }
27572
+ }
27573
+
27574
+ /**
27575
+ * `gh pr list --json` field list. Trimmer than `pullRequestData.ts`'s
27576
+ * single-PR field set — the triage list view doesn't need bodies,
27577
+ * statusCheckRollup, or per-review breakdowns (those live in the
27578
+ * inspector that opens when the user picks a row).
27579
+ */
27580
+ const PULL_REQUEST_LIST_JSON_FIELDS = [
27581
+ 'number',
27582
+ 'title',
27583
+ 'url',
27584
+ 'state',
27585
+ 'isDraft',
27586
+ 'headRefName',
27587
+ 'baseRefName',
27588
+ 'author',
27589
+ 'assignees',
27590
+ 'labels',
27591
+ 'reviewDecision',
27592
+ 'mergeable',
27593
+ 'mergeStateStatus',
27594
+ 'createdAt',
27595
+ 'updatedAt',
27596
+ ].join(',');
27597
+ function parsePullRequestListItems(output) {
27598
+ const trimmed = output.trim();
27599
+ if (!trimmed)
27600
+ return [];
27601
+ const raw = JSON.parse(trimmed);
27602
+ return raw.map((entry) => {
27603
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
27604
+ ? String(entry.author.login)
27605
+ : undefined;
27606
+ const assignees = Array.isArray(entry.assignees)
27607
+ ? entry.assignees
27608
+ .map((a) => (a && 'login' in a ? String(a.login) : ''))
27609
+ .filter(Boolean)
27610
+ : undefined;
27611
+ const labels = Array.isArray(entry.labels)
27612
+ ? entry.labels
27613
+ .map((l) => (l && 'name' in l ? String(l.name) : ''))
27614
+ .filter(Boolean)
27615
+ : undefined;
27616
+ return {
27617
+ number: entry.number,
27618
+ title: String(entry.title || ''),
27619
+ url: String(entry.url || ''),
27620
+ state: String(entry.state || ''),
27621
+ isDraft: Boolean(entry.isDraft),
27622
+ headRefName: String(entry.headRefName || ''),
27623
+ baseRefName: String(entry.baseRefName || ''),
27624
+ author,
27625
+ assignees,
27626
+ labels,
27627
+ reviewDecision: typeof entry.reviewDecision === 'string' ? entry.reviewDecision : undefined,
27628
+ mergeable: typeof entry.mergeable === 'string' ? entry.mergeable : undefined,
27629
+ mergeStateStatus: typeof entry.mergeStateStatus === 'string' ? entry.mergeStateStatus : undefined,
27630
+ createdAt: String(entry.createdAt || ''),
27631
+ updatedAt: String(entry.updatedAt || ''),
27632
+ };
27633
+ });
27634
+ }
27635
+ function buildGhArgs(filter) {
27636
+ const args = ['pr', 'list', '--json', PULL_REQUEST_LIST_JSON_FIELDS];
27637
+ if (filter.state)
27638
+ args.push('--state', filter.state);
27639
+ if (filter.assignee)
27640
+ args.push('--assignee', filter.assignee);
27641
+ if (filter.author)
27642
+ args.push('--author', filter.author);
27643
+ if (filter.label)
27644
+ args.push('--label', filter.label);
27645
+ if (filter.search)
27646
+ args.push('--search', filter.search);
27647
+ if (filter.draft)
27648
+ args.push('--draft');
27649
+ if (filter.base)
27650
+ args.push('--base', filter.base);
27651
+ if (filter.head)
27652
+ args.push('--head', filter.head);
27653
+ if (typeof filter.limit === 'number')
27654
+ args.push('--limit', String(filter.limit));
27655
+ return args;
27656
+ }
27657
+ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
27658
+ const repository = await getGitHubRepository(git);
27659
+ if (!repository) {
27660
+ return {
27661
+ available: false,
27662
+ authenticated: false,
27663
+ filter,
27664
+ message: 'No GitHub remote detected.',
27665
+ };
27666
+ }
27667
+ if (!(await isGhAuthenticated(runner))) {
27668
+ return {
27669
+ available: true,
27670
+ authenticated: false,
27671
+ repository,
27672
+ filter,
27673
+ message: 'GitHub CLI is missing or not authenticated.',
27674
+ };
27675
+ }
27676
+ try {
27677
+ const output = await runner(buildGhArgs(filter));
27678
+ return {
27679
+ available: true,
27680
+ authenticated: true,
27681
+ repository,
27682
+ filter,
27683
+ pullRequests: parsePullRequestListItems(output),
27684
+ };
27685
+ }
27686
+ catch (error) {
27687
+ return {
27688
+ available: true,
27689
+ authenticated: true,
27690
+ repository,
27691
+ filter,
27692
+ message: error instanceof Error ? error.message : 'Failed to fetch pull request list.',
27693
+ };
27694
+ }
27695
+ }
27696
+
26143
27697
  function parseCreatedPullRequestUrl(output) {
26144
27698
  return output
26145
27699
  .split('\n')
@@ -26240,6 +27794,77 @@ function commentPullRequest(body, runner = defaultGhRunner) {
26240
27794
  message: output.trim() || 'Comment added',
26241
27795
  }));
26242
27796
  }
27797
+ /**
27798
+ * Triage-view variants (#882 phase 4). Same shape as
27799
+ * `commentPullRequest` above but target a specific PR by number,
27800
+ * which is what the multi-PR triage list needs (the cursor isn't
27801
+ * necessarily on the current branch's PR). Kept as siblings rather
27802
+ * than overloads so the single-PR call sites stay untouched and
27803
+ * the API stays readable at the call site (no need to pass
27804
+ * `undefined` to skip the number arg).
27805
+ */
27806
+ function commentPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
27807
+ if (!body.trim()) {
27808
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
27809
+ }
27810
+ return runGhAction(runner, ['pr', 'comment', String(pullRequestNumber), '--body', body], (output) => ({
27811
+ ok: true,
27812
+ message: output.trim() || `Commented on pull request #${pullRequestNumber}`,
27813
+ }));
27814
+ }
27815
+ function addPullRequestLabel(pullRequestNumber, label, runner = defaultGhRunner) {
27816
+ if (!label.trim()) {
27817
+ return Promise.resolve({ ok: false, message: 'Label name required' });
27818
+ }
27819
+ return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-label', label], () => ({
27820
+ ok: true,
27821
+ message: `Added label '${label}' to pull request #${pullRequestNumber}`,
27822
+ }));
27823
+ }
27824
+ function addPullRequestAssignee(pullRequestNumber, assignee, runner = defaultGhRunner) {
27825
+ if (!assignee.trim()) {
27826
+ return Promise.resolve({ ok: false, message: 'Assignee login required' });
27827
+ }
27828
+ return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-assignee', assignee], () => ({
27829
+ ok: true,
27830
+ message: `Assigned ${assignee} to pull request #${pullRequestNumber}`,
27831
+ }));
27832
+ }
27833
+ /**
27834
+ * Destructive PR verbs targeting a specific number (#882 phase 5).
27835
+ * Siblings of the existing current-branch functions above; both
27836
+ * variants delegate to the same `runGhAction` wrapper so error
27837
+ * shaping stays uniform.
27838
+ */
27839
+ function mergePullRequestByNumber(pullRequestNumber, strategy, runner = defaultGhRunner) {
27840
+ return runGhAction(runner, ['pr', 'merge', String(pullRequestNumber), `--${strategy}`], (output) => ({
27841
+ ok: true,
27842
+ message: output.trim() ||
27843
+ `Merged pull request #${pullRequestNumber} with ${strategy}`,
27844
+ }));
27845
+ }
27846
+ function closePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
27847
+ return runGhAction(runner, ['pr', 'close', String(pullRequestNumber)], (output) => ({
27848
+ ok: true,
27849
+ message: output.trim() || `Closed pull request #${pullRequestNumber}`,
27850
+ }));
27851
+ }
27852
+ function approvePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
27853
+ return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--approve'], (output) => ({
27854
+ ok: true,
27855
+ message: output.trim() || `Approved pull request #${pullRequestNumber}`,
27856
+ }));
27857
+ }
27858
+ function requestChangesPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
27859
+ if (!body.trim()) {
27860
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
27861
+ }
27862
+ return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--request-changes', '--body', body], (output) => ({
27863
+ ok: true,
27864
+ message: output.trim() ||
27865
+ `Requested changes on pull request #${pullRequestNumber}`,
27866
+ }));
27867
+ }
26243
27868
 
26244
27869
  async function runAction(action, successMessage) {
26245
27870
  try {
@@ -27549,10 +29174,70 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
27549
29174
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
27550
29175
  }, 'tab-worktrees', visibleListCount);
27551
29176
  }
27552
- function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
29177
+ /**
29178
+ * Single-letter glyph for a sidebar tab in rail mode. Letters always
29179
+ * carry the meaning so this stays useful under ASCII; the rail is too
29180
+ * narrow to fit the full tab label. Pairs with `sidebarTabCount` for
29181
+ * the trailing count.
29182
+ */
29183
+ function sidebarTabRailGlyph(tab) {
29184
+ switch (tab) {
29185
+ case 'status':
29186
+ return 'S';
29187
+ case 'branches':
29188
+ return 'B';
29189
+ case 'tags':
29190
+ return 'T';
29191
+ case 'stashes':
29192
+ return '$';
29193
+ case 'worktrees':
29194
+ return 'W';
29195
+ default:
29196
+ return '·';
29197
+ }
29198
+ }
29199
+ /**
29200
+ * Rail-mode sidebar — shown on terminals < 100 columns when the
29201
+ * sidebar does not hold focus. Five vertically stacked tab glyphs
29202
+ * with optional counts; the active tab is bracketed. Pressing Tab to
29203
+ * focus the sidebar pops it back to the full accordion (the layout
29204
+ * un-rails it on focus, this renderer is never called in that case).
29205
+ */
29206
+ function renderSidebarRail(h, components, state, context, width, theme, focused, tabs) {
29207
+ const { Box, Text } = components;
29208
+ return h(Box, {
29209
+ borderColor: focusBorderColor(theme, focused),
29210
+ borderStyle: theme.borderStyle,
29211
+ flexDirection: 'column',
29212
+ width,
29213
+ paddingX: 1,
29214
+ }, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
29215
+ const isActive = tab === state.sidebarTab;
29216
+ const glyph = sidebarTabRailGlyph(tab);
29217
+ const count = sidebarTabCount(tab, context);
29218
+ // Count fits in 2 cells (rail content area is ~4 cells); 99+
29219
+ // collapses to `+` so we never overflow.
29220
+ const countText = count === undefined
29221
+ ? ''
29222
+ : count > 99
29223
+ ? '+'
29224
+ : String(count);
29225
+ const body = isActive ? `[${glyph}]` : ` ${glyph} `;
29226
+ const text = countText ? `${body}${countText}` : body;
29227
+ return h(Text, {
29228
+ key: `rail-${tab}`,
29229
+ bold: isActive,
29230
+ dimColor: !isActive,
29231
+ }, text);
29232
+ }));
29233
+ }
29234
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
27553
29235
  const { Box, Text } = components;
27554
29236
  const focused = state.focus === 'sidebar';
27555
29237
  const tabs = getLogInkSidebarTabs();
29238
+ if (railed) {
29239
+ return renderSidebarRail(h, components, state, context, width, theme, focused, tabs);
29240
+ }
27556
29241
  // Accordion layout — every tab's title is visible on its own line, but
27557
29242
  // only the active tab expands its content underneath. Switching tabs
27558
29243
  // (1-5 / [/]) collapses the previous and expands the next.
@@ -27839,6 +29524,34 @@ function formatLogInkSubmodulesEmpty({ filter }) {
27839
29524
  }
27840
29525
  return 'No submodules registered. Add one with `git submodule add <url> <path>` from the shell.';
27841
29526
  }
29527
+ function formatLogInkIssuesEmpty({ filter }) {
29528
+ if (filter.trim()) {
29529
+ return `No issues match filter '${filter}'. Press ctrl+u to clear.`;
29530
+ }
29531
+ return 'No issues match the current GitHub filter (default: open issues).';
29532
+ }
29533
+ function formatLogInkPullRequestTriageEmpty({ filter, }) {
29534
+ if (filter.trim()) {
29535
+ return `No pull requests match filter '${filter}'. Press ctrl+u to clear.`;
29536
+ }
29537
+ return 'No pull requests match the current GitHub filter (default: open PRs).';
29538
+ }
29539
+ /**
29540
+ * Surface-level fallback when the GitHub CLI is missing or not
29541
+ * authenticated. The triage views (#882) all share this empty-state
29542
+ * copy — the underlying problem is the same regardless of which
29543
+ * surface the user is on, and the recovery is identical.
29544
+ */
29545
+ function formatLogInkGitHubUnauthenticated({ resource, }) {
29546
+ return `${resource} require the GitHub CLI. Install \`gh\` and run \`gh auth login\` to enable triage.`;
29547
+ }
29548
+ /**
29549
+ * Surface-level fallback when the repo has no GitHub remote. Same
29550
+ * shared message across the triage surfaces.
29551
+ */
29552
+ function formatLogInkGitHubNoRemote({ resource, }) {
29553
+ return `${resource} require a GitHub remote (origin or fallback). None detected for this repo.`;
29554
+ }
27842
29555
 
27843
29556
  /**
27844
29557
  * Branches surface — promoted view listing local branches with sort,
@@ -28729,6 +30442,179 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
28729
30442
  : []));
28730
30443
  }
28731
30444
 
30445
+ /**
30446
+ * Strip refs that are already represented by a branch tip chip so the
30447
+ * trailing `[ref] [ref]` list doesn't repeat what the chip is already
30448
+ * showing. The chip carries the primary branch name; the trailing
30449
+ * list keeps everything else — including remote-tracking variants
30450
+ * (`origin/X`) and `origin/HEAD` — because those convey "remote is
30451
+ * also at this commit" info the chip alone doesn't.
30452
+ *
30453
+ * Removes:
30454
+ * - exact match of the chipped name (`main`, `feat/foo`)
30455
+ * - `HEAD -> <name>` for the chipped name
30456
+ * - bare `HEAD` when the chip is the HEAD branch (only paranoia;
30457
+ * git typically emits `HEAD -> name` not both, but a detached
30458
+ * fixup commit may have produced both)
30459
+ */
30460
+ function filterChippedRefs(refs, chip) {
30461
+ if (!chip)
30462
+ return refs;
30463
+ const headDecoration = `HEAD -> ${chip.name}`;
30464
+ return refs.filter((ref) => {
30465
+ if (ref === chip.name)
30466
+ return false;
30467
+ if (ref === headDecoration)
30468
+ return false;
30469
+ if (chip.isHead && ref === 'HEAD')
30470
+ return false;
30471
+ return true;
30472
+ });
30473
+ }
30474
+ function getBranchTipChip(refs) {
30475
+ for (const ref of refs) {
30476
+ if (ref.startsWith('HEAD -> ')) {
30477
+ const name = ref.slice('HEAD -> '.length).trim();
30478
+ if (name)
30479
+ return { name, isHead: true };
30480
+ }
30481
+ }
30482
+ for (const ref of refs) {
30483
+ if (ref === 'HEAD' ||
30484
+ ref.startsWith('HEAD -> ') ||
30485
+ ref.startsWith('tag: ') ||
30486
+ ref.includes('/')) {
30487
+ continue;
30488
+ }
30489
+ if (ref.trim())
30490
+ return { name: ref.trim(), isHead: false };
30491
+ }
30492
+ for (const ref of refs) {
30493
+ if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30494
+ continue;
30495
+ }
30496
+ if (ref.includes('/') && ref.trim()) {
30497
+ return { name: ref.trim(), isHead: false };
30498
+ }
30499
+ }
30500
+ return undefined;
30501
+ }
30502
+
30503
+ const PREFIX_PATTERN = /^([a-z]+)(\(([^)]+)\))?(!)?:\s+/;
30504
+ function parseConventionalCommitPrefix(message) {
30505
+ const match = PREFIX_PATTERN.exec(message);
30506
+ if (!match)
30507
+ return undefined;
30508
+ const [whole, type, , scope, breakingMarker] = match;
30509
+ return {
30510
+ prefix: whole,
30511
+ rest: message.slice(whole.length),
30512
+ type,
30513
+ scope: scope || undefined,
30514
+ breaking: Boolean(breakingMarker),
30515
+ };
30516
+ }
30517
+ /**
30518
+ * Pick the theme color used to paint a conventional-commit prefix.
30519
+ *
30520
+ * Rough mapping intent:
30521
+ * - feat → success (new capability, growth)
30522
+ * - fix → warning (was a problem; eye-catch)
30523
+ * - docs / refactor / perf → info / accent (intent-bearing change)
30524
+ * - test / style / build / ci → muted (mechanical / housekeeping)
30525
+ * - chore → muted
30526
+ * - revert → danger (signals "this undid something")
30527
+ *
30528
+ * Unknown types fall through to `accent` so a project-specific
30529
+ * convention (`wip:`, `release:`, etc.) still reads as the typed
30530
+ * prefix rather than blending into the subject. Returns `undefined`
30531
+ * under `theme.noColor` so the prefix stays plain — the textual
30532
+ * `feat:` carries the meaning by itself.
30533
+ *
30534
+ * Breaking changes (`!:`) override the type color with `danger` so
30535
+ * the row reads as "stop and look at this" regardless of which type
30536
+ * it is.
30537
+ */
30538
+ function getConventionalCommitColor(parsed, theme) {
30539
+ if (theme.noColor)
30540
+ return undefined;
30541
+ if (parsed.breaking)
30542
+ return theme.colors.danger;
30543
+ switch (parsed.type) {
30544
+ case 'feat':
30545
+ return theme.colors.success;
30546
+ case 'fix':
30547
+ return theme.colors.warning;
30548
+ case 'docs':
30549
+ case 'refactor':
30550
+ case 'perf':
30551
+ return theme.colors.info;
30552
+ case 'test':
30553
+ case 'style':
30554
+ case 'build':
30555
+ case 'ci':
30556
+ case 'chore':
30557
+ return theme.colors.muted;
30558
+ case 'revert':
30559
+ return theme.colors.danger;
30560
+ default:
30561
+ return theme.colors.accent;
30562
+ }
30563
+ }
30564
+
30565
+ /**
30566
+ * Date formatting helpers for the Ink TUI surfaces.
30567
+ *
30568
+ * The branch list already ships its own "X ago" formatter
30569
+ * (`formatBranchLastTouched` in iconography.ts) sized for a sidebar
30570
+ * row with room to breathe. The history surface needs a tighter
30571
+ * variant: the date column is fixed-width and competes with the
30572
+ * commit message for cells, so a 2-3 character form is the budget.
30573
+ *
30574
+ * Inputs match what `git log --date=short` produces:
30575
+ * `YYYY-MM-DD`. Caller passes `now` so tests can pin the reference
30576
+ * instant.
30577
+ *
30578
+ * Outputs (rounded toward the nearest unit, no `ago` suffix):
30579
+ * - `today` for same UTC day
30580
+ * - `1d` … `13d` for 1-13 days
30581
+ * - `2w` … `8w` for 2-8 weeks
30582
+ * - `2mo` … `11mo` for 2-11 months
30583
+ * - `2y`+ for older
30584
+ * - `''` for malformed inputs (caller renders nothing)
30585
+ *
30586
+ * Day comparison is in UTC so a commit dated "yesterday" never reads
30587
+ * "today" depending on the operator's timezone.
30588
+ */
30589
+ function formatCompactRelativeDate(iso, now) {
30590
+ if (!iso)
30591
+ return '';
30592
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
30593
+ if (!match)
30594
+ return '';
30595
+ const year = Number.parseInt(match[1], 10);
30596
+ const month = Number.parseInt(match[2], 10);
30597
+ const day = Number.parseInt(match[3], 10);
30598
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
30599
+ return '';
30600
+ const commitUtc = Date.UTC(year, month - 1, day);
30601
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
30602
+ const oneDay = 24 * 60 * 60 * 1000;
30603
+ const days = Math.floor((nowUtc - commitUtc) / oneDay);
30604
+ if (days <= 0)
30605
+ return 'today';
30606
+ if (days < 14)
30607
+ return `${days}d`;
30608
+ const weeks = Math.floor(days / 7);
30609
+ if (weeks < 9)
30610
+ return `${weeks}w`;
30611
+ const months = Math.floor(days / 30);
30612
+ if (months < 12)
30613
+ return `${months}mo`;
30614
+ const years = Math.floor(days / 365);
30615
+ return `${years}y`;
30616
+ }
30617
+
28732
30618
  /**
28733
30619
  * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
28734
30620
  * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
@@ -28978,6 +30864,54 @@ function getLaneColor(laneId, theme) {
28978
30864
  return palette[laneId % palette.length];
28979
30865
  }
28980
30866
 
30867
+ const MONTH_NAMES = [
30868
+ 'January', 'February', 'March', 'April', 'May', 'June',
30869
+ 'July', 'August', 'September', 'October', 'November', 'December',
30870
+ ];
30871
+ function getDateBucket(iso, now) {
30872
+ if (!iso)
30873
+ return { key: 'unknown', label: 'Unknown date' };
30874
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
30875
+ if (!match)
30876
+ return { key: 'unknown', label: 'Unknown date' };
30877
+ const year = Number.parseInt(match[1], 10);
30878
+ const month = Number.parseInt(match[2], 10);
30879
+ const day = Number.parseInt(match[3], 10);
30880
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
30881
+ return { key: 'unknown', label: 'Unknown date' };
30882
+ }
30883
+ const commitUtc = Date.UTC(year, month - 1, day);
30884
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
30885
+ const oneDay = 24 * 60 * 60 * 1000;
30886
+ const days = Math.floor((nowUtc - commitUtc) / oneDay);
30887
+ // Future-dated commits (clock skew, bad commit dates) collapse to
30888
+ // today rather than confusing the user with an "in the future"
30889
+ // bucket.
30890
+ if (days <= 0)
30891
+ return { key: 'today', label: 'Today' };
30892
+ if (days === 1)
30893
+ return { key: 'yesterday', label: 'Yesterday' };
30894
+ if (days < 7)
30895
+ return { key: 'this-week', label: 'This week' };
30896
+ if (days < 14)
30897
+ return { key: 'last-week', label: 'Last week' };
30898
+ // Inside the same calendar month → one "earlier this month" bucket
30899
+ // so the user sees a single section rather than per-day groupings
30900
+ // for a commit-heavy week.
30901
+ if (year === now.getUTCFullYear() && month - 1 === now.getUTCMonth()) {
30902
+ return { key: 'earlier-this-month', label: 'Earlier this month' };
30903
+ }
30904
+ // Older months use the calendar-month label so the bucket reads
30905
+ // naturally even years back (`April 2024`). The key embeds the
30906
+ // year+month so different months stay in distinct buckets without
30907
+ // colliding on month name alone.
30908
+ const monthLabel = MONTH_NAMES[month - 1] ?? `Month ${month}`;
30909
+ return {
30910
+ key: `month-${match[1]}-${match[2]}`,
30911
+ label: `${monthLabel} ${year}`,
30912
+ };
30913
+ }
30914
+
28981
30915
  /**
28982
30916
  * Pick the commit glyph based on parent count + HEAD-ness so the
28983
30917
  * renderer can flag merges and the current head visually. HEAD wins
@@ -29006,9 +30940,8 @@ function commitKey(commit) {
29006
30940
  function graphWidth(items) {
29007
30941
  return Math.max(1, ...items.map((item) => item.graph.length));
29008
30942
  }
29009
- function toCompactItems(state, visibleCount) {
29010
- const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
29011
- return state.filteredCommits.slice(start, start + visibleCount).map((commit, offset) => ({
30943
+ function makeCompactCommitItem(commit, selected) {
30944
+ return {
29012
30945
  type: 'commit',
29013
30946
  commit,
29014
30947
  graph: '*',
@@ -29017,24 +30950,197 @@ function toCompactItems(state, visibleCount) {
29017
30950
  // glance. Lane id stays undefined so the segment renders muted —
29018
30951
  // matching the legacy compact appearance, just with a richer glyph.
29019
30952
  laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
29020
- selected: start + offset === state.selectedIndex,
29021
- }));
30953
+ selected,
30954
+ };
30955
+ }
30956
+ function bucketHeaderItem(label) {
30957
+ return { type: 'bucket-header', graph: '', label };
30958
+ }
30959
+ function toCompactItems(state, visibleCount, bucketingNow) {
30960
+ const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
30961
+ const slice = state.filteredCommits.slice(start, start + visibleCount);
30962
+ if (!bucketingNow) {
30963
+ return slice.map((commit, offset) => makeCompactCommitItem(commit, start + offset === state.selectedIndex));
30964
+ }
30965
+ // With bucketing on: emit a sticky header above the first visible
30966
+ // commit and an additional header each time the bucket changes. The
30967
+ // header occupies one row from the visibleCount budget every time
30968
+ // it fires, so the visible commit count drops slightly in exchange
30969
+ // for always-on temporal orientation.
30970
+ const items = [];
30971
+ let prevBucket = undefined;
30972
+ for (let offset = 0; offset < slice.length && items.length < visibleCount; offset += 1) {
30973
+ const commit = slice[offset];
30974
+ const bucket = getDateBucket(commit.date, bucketingNow);
30975
+ if (bucket.key !== prevBucket) {
30976
+ items.push(bucketHeaderItem(bucket.label));
30977
+ prevBucket = bucket.key;
30978
+ if (items.length >= visibleCount)
30979
+ break;
30980
+ }
30981
+ items.push(makeCompactCommitItem(commit, start + offset === state.selectedIndex));
30982
+ }
30983
+ return items;
29022
30984
  }
29023
30985
  function isSelectedCommit(row, selected) {
29024
30986
  return row.type === 'commit' && selected ? commitKey(row) === commitKey(selected) : false;
29025
30987
  }
29026
- function toFullGraphItems(state, visibleCount) {
30988
+ /**
30989
+ * Build the vertical-only graph string that follows a commit row when
30990
+ * `withSpacers` is enabled. Every commit-cell glyph (`*`) is rewritten
30991
+ * to a lane bar (`|`) so the synthetic row continues every open lane
30992
+ * without re-rendering the commit dot. All other graph chars pass
30993
+ * through unchanged, so a commit graph like `* | |` becomes `| | |`.
30994
+ */
30995
+ function buildSpacerGraph(commitGraph) {
30996
+ return commitGraph.replace(/\*/g, '|');
30997
+ }
30998
+ /**
30999
+ * Walk `state.rows` and inject a synthetic spacer entry after every
31000
+ * commit row when `withSpacers` is true. The spacer is a graph-only
31001
+ * row that renders as `|` per active lane so consecutive commits have
31002
+ * a clear vertical rhythm without losing topology continuity.
31003
+ *
31004
+ * The spacer is suppressed in two cases where it would create visible
31005
+ * "tearing" on the graph column:
31006
+ *
31007
+ * 1. The next row is git's own graph-only topology row (`|\` /
31008
+ * `|/` / `| |`). That row already provides vertical breathing
31009
+ * AND draws the lane transition; sandwiching our spacer between
31010
+ * the commit and the transition produces an extra all-pipes row
31011
+ * that reads as misalignment.
31012
+ *
31013
+ * 2. The current commit's graph contains a backslash or forward
31014
+ * slash (the compressed forms git uses for `*\` / `*` followed
31015
+ * by slash, when it draws the fork on the same row as the
31016
+ * commit). The spacer's commit-glyph → lane-bar rewrite would
31017
+ * leave the diagonal intact, rendering a second corner glyph
31018
+ * immediately below the merge — a duplicate that looks like a
31019
+ * glyph stutter.
31020
+ *
31021
+ * When `withSpacers` is false the list is identity-mapped from
31022
+ * source rows, preserving the legacy zero-padding behavior for any
31023
+ * caller that wants raw git topology (filters, tests, etc.).
31024
+ */
31025
+ function commitGraphIsSimple(graph) {
31026
+ return !/[\\/]/.test(graph);
31027
+ }
31028
+ function expandRowsWithSpacers(rows, withSpacers) {
31029
+ const out = [];
31030
+ for (let i = 0; i < rows.length; i += 1) {
31031
+ const row = rows[i];
31032
+ out.push({ kind: 'source', row });
31033
+ if (!withSpacers || row.type !== 'commit')
31034
+ continue;
31035
+ if (!commitGraphIsSimple(row.graph || '*'))
31036
+ continue;
31037
+ const next = rows[i + 1];
31038
+ if (next && next.type === 'graph')
31039
+ continue;
31040
+ out.push({ kind: 'spacer', sourceCommit: row });
31041
+ }
31042
+ return out;
31043
+ }
31044
+ /**
31045
+ * Walk an already-expanded row list and inject `bucket-header`
31046
+ * entries immediately before each commit whose date bucket differs
31047
+ * from the previous commit's. The very first commit always gets a
31048
+ * header so the user lands inside a labeled section regardless of
31049
+ * where the scroll window starts. Non-commit entries (spacers, git
31050
+ * topology rows) pass through unchanged.
31051
+ */
31052
+ function injectBucketHeaders(rows, now) {
31053
+ const out = [];
31054
+ let prevBucket = undefined;
31055
+ for (const entry of rows) {
31056
+ if (entry.kind === 'source' && entry.row.type === 'commit') {
31057
+ const bucket = getDateBucket(entry.row.date, now);
31058
+ if (bucket.key !== prevBucket) {
31059
+ out.push({ kind: 'bucket-header', label: bucket.label });
31060
+ prevBucket = bucket.key;
31061
+ }
31062
+ }
31063
+ out.push(entry);
31064
+ }
31065
+ return out;
31066
+ }
31067
+ /**
31068
+ * Find the most recent bucket header at or above `start` so a slice
31069
+ * that begins mid-bucket can still surface its section label. Used
31070
+ * for the "sticky header" behavior — when the window scrolls past
31071
+ * the natural header position, prepend the header to the slice so
31072
+ * the user always sees which bucket they're in. Returns the label
31073
+ * to prepend, or `undefined` when no prepend is needed (either
31074
+ * `expanded[start]` is already a header, or there is no earlier
31075
+ * header in the list).
31076
+ */
31077
+ function findStickyBucketLabel(expanded, start) {
31078
+ if (start < expanded.length && expanded[start].kind === 'bucket-header')
31079
+ return undefined;
31080
+ for (let i = start - 1; i >= 0; i -= 1) {
31081
+ const entry = expanded[i];
31082
+ if (entry.kind === 'bucket-header')
31083
+ return entry.label;
31084
+ }
31085
+ return undefined;
31086
+ }
31087
+ function toFullGraphItems(state, visibleCount, options = {
31088
+ withSpacers: false,
31089
+ bucketingNow: undefined,
31090
+ }) {
29027
31091
  const selected = state.filteredCommits[state.selectedIndex];
29028
- const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
29029
- const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
31092
+ const withSpacers = expandRowsWithSpacers(state.rows, options.withSpacers);
31093
+ const expanded = options.bucketingNow
31094
+ ? injectBucketHeaders(withSpacers, options.bucketingNow)
31095
+ : withSpacers;
31096
+ const selectedExpandedIndex = expanded.findIndex((entry) => entry.kind === 'source' && isSelectedCommit(entry.row, selected));
31097
+ const start = clampWindowStart(selectedExpandedIndex >= 0 ? selectedExpandedIndex : 0, expanded.length, visibleCount);
29030
31098
  // Lane tracking is order-dependent — fast-forward the tracker through
29031
31099
  // every row above the visible window so lane ids stay stable as the
29032
31100
  // user scrolls. Without this, scrolling would re-color lanes from a
29033
- // fresh tracker each time.
31101
+ // fresh tracker each time. Spacers contribute their vertical-only
31102
+ // graph to the prefix so the tracker sees a no-op advance and lane
31103
+ // state stays consistent at the window boundary. Bucket headers
31104
+ // skip the tracker entirely since they have no graph string.
29034
31105
  const tracker = createLaneTrackerState();
29035
- const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
29036
- advanceTrackerThrough(allGraphs, tracker, start);
29037
- return state.rows.slice(start, start + visibleCount).map((row) => {
31106
+ const prefixGraphs = [];
31107
+ for (let k = 0; k < start; k += 1) {
31108
+ const entry = expanded[k];
31109
+ if (entry.kind === 'bucket-header')
31110
+ continue;
31111
+ if (entry.kind === 'spacer') {
31112
+ prefixGraphs.push(buildSpacerGraph(entry.sourceCommit.graph || '*'));
31113
+ continue;
31114
+ }
31115
+ prefixGraphs.push(entry.row.type === 'commit' ? entry.row.graph || '*' : entry.row.graph);
31116
+ }
31117
+ advanceTrackerThrough(prefixGraphs, tracker, prefixGraphs.length);
31118
+ // Sticky header — if the slice would start partway into a bucket
31119
+ // (most commonly when scrolling), prepend the bucket label so the
31120
+ // user keeps temporal context. The prepend costs one row from the
31121
+ // visible budget, so the slice itself shrinks by 1.
31122
+ const stickyLabel = options.bucketingNow
31123
+ ? findStickyBucketLabel(expanded, start)
31124
+ : undefined;
31125
+ const sliceCount = stickyLabel ? visibleCount - 1 : visibleCount;
31126
+ const sliced = expanded.slice(start, start + sliceCount);
31127
+ const finalEntries = stickyLabel
31128
+ ? [{ kind: 'bucket-header', label: stickyLabel }, ...sliced]
31129
+ : sliced;
31130
+ return finalEntries.map((entry) => {
31131
+ if (entry.kind === 'bucket-header') {
31132
+ return bucketHeaderItem(entry.label);
31133
+ }
31134
+ if (entry.kind === 'spacer') {
31135
+ const graph = buildSpacerGraph(entry.sourceCommit.graph || '*');
31136
+ return {
31137
+ type: 'graph',
31138
+ graph,
31139
+ laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false }),
31140
+ spacer: true,
31141
+ };
31142
+ }
31143
+ const { row } = entry;
29038
31144
  if (row.type === 'graph') {
29039
31145
  return {
29040
31146
  type: 'graph',
@@ -29053,10 +31159,18 @@ function toFullGraphItems(state, visibleCount) {
29053
31159
  };
29054
31160
  });
29055
31161
  }
29056
- function getVisibleLogInkHistory(state, visibleCount) {
31162
+ function getVisibleLogInkHistory(state, visibleCount, options = {}) {
31163
+ // Bucketing only makes sense for chronologically ordered output —
31164
+ // an active search filter shuffles commits by relevance, so the
31165
+ // adjacent-bucket invariant breaks down and the divider would
31166
+ // read as noise.
31167
+ const bucketingNow = state.filter ? undefined : options.dateBucketingNow;
29057
31168
  const items = state.fullGraph && !state.filter
29058
- ? toFullGraphItems(state, visibleCount)
29059
- : toCompactItems(state, visibleCount);
31169
+ ? toFullGraphItems(state, visibleCount, {
31170
+ withSpacers: Boolean(options.fullGraphSpacing),
31171
+ bucketingNow,
31172
+ })
31173
+ : toCompactItems(state, visibleCount, bucketingNow);
29060
31174
  return {
29061
31175
  graphWidth: graphWidth(items),
29062
31176
  items,
@@ -29082,6 +31196,96 @@ function formatInkRefLabels(refs) {
29082
31196
  * `formatHistoryFetchArgs`) lived in inkRuntime.ts only to support
29083
31197
  * this surface; they migrate together.
29084
31198
  */
31199
+ /**
31200
+ * How the date column should render for a given density tier:
31201
+ * - wide → absolute `YYYY-MM-DD`
31202
+ * - normal → compact relative form (`2d`, `3w`, `2mo`)
31203
+ * - tight → hidden entirely (column dropped)
31204
+ * - rail → caller picks `rowMode='stacked'`; this fn isn't consulted
31205
+ *
31206
+ * Compact mode (the user toggling away from the full graph) forces
31207
+ * the tight behavior regardless of density. Compact is the "scan
31208
+ * mode" — the date is the first thing the user is willing to drop in
31209
+ * exchange for more visible commits per screen.
31210
+ *
31211
+ * When `bucketed` is true the surface is rendering section dividers
31212
+ * (`── Today ──`) above commits, so the per-row date column would be
31213
+ * redundant. We drop it entirely and let the message column expand.
31214
+ */
31215
+ function pickDateText(commit, density, fullGraph, bucketed, now) {
31216
+ if (bucketed)
31217
+ return '';
31218
+ if (!fullGraph)
31219
+ return '';
31220
+ if (density === 'wide')
31221
+ return commit.date;
31222
+ if (density === 'normal')
31223
+ return formatCompactRelativeDate(commit.date, now);
31224
+ return '';
31225
+ }
31226
+ /**
31227
+ * Maximum cells the chip body (between the brackets) is allowed to
31228
+ * occupy. Anything longer is truncated with an ellipsis so a
31229
+ * `[origin/claude/issues-prs-cache]` chip — 32 cells of chrome —
31230
+ * doesn't eat the whole subject column on a narrow terminal. Picked
31231
+ * empirically: 20 cells fits common branch shapes (`feat/foo`,
31232
+ * `claude/graph-fidelity`, `main`) without truncation.
31233
+ */
31234
+ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31235
+ /**
31236
+ * Render a pill-style chip for a branch tip — colored background
31237
+ * with the branch name reverse-printed inside, so the chip reads as
31238
+ * a distinct visual category (block) rather than colored text (which
31239
+ * collides with `docs:`/`refactor:`/`perf:` conventional-commit
31240
+ * prefixes that also use `info`). Current branch (HEAD -> X) uses
31241
+ * success-green; other branch tips use info-blue.
31242
+ *
31243
+ * Implementation: `inverse: true` + `color: <accent>` is the
31244
+ * portable way to render "colored background with terminal-default
31245
+ * foreground" — it adapts to dark vs light terminals without
31246
+ * hardcoding a black/white fg. Tags are never chipped; they stay in
31247
+ * the trailing ref list. The chip emits its own trailing space so
31248
+ * callers concatenate it directly into the row without a separator.
31249
+ *
31250
+ * Selection styling is opt-out: when the row is selected, the outer
31251
+ * `inverse: true` + selection background already covers everything,
31252
+ * and a second `inverse` on the chip would flip it back to plain. We
31253
+ * drop the pill styling for selected rows and let the row's own
31254
+ * inverse highlight carry through cleanly.
31255
+ *
31256
+ * Returns the rendered node alongside its cell width AND the chip
31257
+ * descriptor so the caller can pass it to `filterChippedRefs` and
31258
+ * avoid emitting the same branch a second time in the trailing list.
31259
+ */
31260
+ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31261
+ const chip = getBranchTipChip(commit.refs);
31262
+ if (!chip)
31263
+ return { node: null, width: 0, chip };
31264
+ const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
31265
+ // Inner pill body is `name`; the trailing space sits OUTSIDE the
31266
+ // colored block so the bg doesn't bleed into the message column.
31267
+ // The brackets are gone — the colored block is its own visual
31268
+ // affordance and the brackets would add 2 cells of chrome that
31269
+ // duplicate the affordance.
31270
+ const body = ` ${truncated} `;
31271
+ // Selected row OR noColor mode → drop pill styling. Selected rows
31272
+ // get the row-level inverse highlight; noColor terminals fall
31273
+ // back to bracketed text so the chip still parses visually.
31274
+ if (selected || theme.noColor) {
31275
+ const fallbackLabel = `[${truncated}] `;
31276
+ return {
31277
+ node: h(Text, { key, bold: chip.isHead }, fallbackLabel),
31278
+ width: cellWidth(fallbackLabel),
31279
+ chip,
31280
+ };
31281
+ }
31282
+ const accent = chip.isHead ? theme.colors.success : theme.colors.info;
31283
+ return {
31284
+ node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31285
+ width: cellWidth(body) + 1,
31286
+ chip,
31287
+ };
31288
+ }
29085
31289
  function formatHistoryFetchArgs(args) {
29086
31290
  const parts = [];
29087
31291
  if (args.author)
@@ -29090,6 +31294,36 @@ function formatHistoryFetchArgs(args) {
29090
31294
  parts.push(`-- ${args.path}`);
29091
31295
  return parts.join(' ') || 'none';
29092
31296
  }
31297
+ /**
31298
+ * Render a commit subject with the conventional-commit prefix
31299
+ * (`feat:`, `fix(scope)!:`, …) painted in a type-specific color so
31300
+ * the eye can bucket commits by type while scanning.
31301
+ *
31302
+ * Truncation lives at the message level above this helper — the
31303
+ * caller has already shortened `text` to the available room. We just
31304
+ * split on the parsed prefix length and emit two spans. If the
31305
+ * shortened text is too narrow to include the full prefix (e.g. a
31306
+ * tight panel that cut into `feat`), we fall back to a single plain
31307
+ * span so the partial prefix doesn't read as a malformed colored
31308
+ * fragment.
31309
+ *
31310
+ * Returns the spans flat so the caller can splat them into the row's
31311
+ * outer Text alongside other segments without an extra wrapper.
31312
+ */
31313
+ function renderTypedSubject(h, Text, text, theme, key) {
31314
+ const parsed = parseConventionalCommitPrefix(text);
31315
+ if (!parsed) {
31316
+ return [h(Text, { key: `${key}-msg` }, text)];
31317
+ }
31318
+ if (text.length < parsed.prefix.length) {
31319
+ return [h(Text, { key: `${key}-msg` }, text)];
31320
+ }
31321
+ const color = getConventionalCommitColor(parsed, theme);
31322
+ return [
31323
+ h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
31324
+ h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
31325
+ ];
31326
+ }
29093
31327
  /**
29094
31328
  * Render `LaneSegment[]` as a flat list of Text spans, one per lane
29095
31329
  * (#791 stage 2). Each segment paints in its lane's palette color so
@@ -29135,8 +31369,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
29135
31369
  * Truncation is per-segment so the variable-length message field gets
29136
31370
  * the leftover budget after fixed segments are accounted for.
29137
31371
  */
29138
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments, isRecent = false) {
29139
- const refs = formatInkRefLabels(commit.refs);
31372
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
29140
31373
  // Total cells available to the row content. Earlier revisions used a
29141
31374
  // hardcoded 140 here, which let row content overflow whenever the
29142
31375
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -29144,7 +31377,19 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
29144
31377
  // continuation rather than its own commit (#830). Subtracting 4
29145
31378
  // accounts for the panel's left + right border + 1-cell padding.
29146
31379
  const totalWidth = Math.max(20, panelWidth - 4);
29147
- const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
31380
+ const dateText = pickDateText(commit, density, fullGraph, bucketed, now);
31381
+ const dateSegmentWidth = dateText ? dateText.length + 1 : 0;
31382
+ // Branch chip prefix — only renders in full-graph mode so compact
31383
+ // (scan) mode stays minimal. Chip occupies cells immediately after
31384
+ // the shortHash and before the message; truncation math reserves
31385
+ // its width before sizing the message column. Trailing refs filter
31386
+ // out whatever the chip already shows so the row doesn't print
31387
+ // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31388
+ const chip = fullGraph
31389
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
31390
+ : { node: null, width: 0, chip: undefined };
31391
+ const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31392
+ const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
29148
31393
  // Refs trail the message and shrink first when the row is narrow:
29149
31394
  // the user can always see the full ref list in the inspector, so
29150
31395
  // the headline subject keeps priority over decoration.
@@ -29182,7 +31427,81 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
29182
31427
  // the primary cue but boldness makes the row read as "this is
29183
31428
  // worth looking at" even without color.
29184
31429
  bold: selected || isRecent,
29185
- }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
31430
+ }, commit.shortHash), ' ',
31431
+ // Date column drops out entirely at `tight` density — no spacer
31432
+ // either, so the message column slides left into the freed cells.
31433
+ dateText
31434
+ ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
31435
+ : null,
31436
+ // Branch chip prefix (full-graph mode only) lands right before the
31437
+ // message so the eye reads "branch · subject" as a unit.
31438
+ chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
31439
+ }
31440
+ /**
31441
+ * Stacked variant used at `rowMode='stacked'` (rail tier). Each
31442
+ * commit takes two lines so the message never has to share its row
31443
+ * with the date / refs / hash on a sub-90-cell terminal:
31444
+ * line 1: graph · shortHash · subject
31445
+ * line 2: dim padding · date · refs
31446
+ *
31447
+ * Selection styling lives on the line-1 outer span; the secondary
31448
+ * line stays dim regardless of selection so it doesn't pull the eye
31449
+ * away from the subject.
31450
+ */
31451
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
31452
+ const totalWidth = Math.max(20, panelWidth - 4);
31453
+ const accent = theme.noColor ? undefined : theme.colors.accent;
31454
+ const muted = theme.noColor ? undefined : theme.colors.muted;
31455
+ const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
31456
+ // Line 1 — subject row. Mostly mirrors the single-line layout but
31457
+ // skips the date and refs so the message has the whole tail to
31458
+ // itself. Branch chip rides between the hash and the subject the
31459
+ // same way as the single-line variant, but only in full-graph mode.
31460
+ const recentMarkerWidth = isRecent ? 2 : 0;
31461
+ const chip = fullGraph
31462
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
31463
+ : { node: null, width: 0, chip: undefined };
31464
+ const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31465
+ const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
31466
+ const graphChildren = laneSegments && !theme.ascii
31467
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
31468
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
31469
+ const lineOne = h(Text, {
31470
+ key: `${commit.hash}-${index}-l1`,
31471
+ backgroundColor: selectedBg,
31472
+ inverse: selected,
31473
+ }, ...graphChildren, ' ', isRecent
31474
+ ? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
31475
+ : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
31476
+ // Line 2 — metadata row, padded to align with the start of the
31477
+ // shortHash on line 1 so the eye still groups them as one commit.
31478
+ // Selection background does not extend here so we don't get a thick
31479
+ // double-row highlight on a tight terminal. Trailing refs are
31480
+ // filtered against the chip so we don't repeat the branch tip both
31481
+ // as a leading chip and a trailing label.
31482
+ const indent = ' '.repeat(graphWidth + 1);
31483
+ const dateText = formatCompactRelativeDate(commit.date, now);
31484
+ const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31485
+ const metaRoom = Math.max(8, totalWidth - indent.length - (dateText ? dateText.length + 1 : 0));
31486
+ const refsTrunc = refs ? truncateCells(refs, metaRoom) : '';
31487
+ // If both pieces are empty (date unparseable + no refs), show a
31488
+ // bullet so the row's structure still reads as two-line and the
31489
+ // user doesn't think they hit a render bug.
31490
+ const metaContent = dateText || refsTrunc
31491
+ ? [
31492
+ dateText ? h(Text, { key: `${commit.hash}-${index}-l2-date` }, dateText) : null,
31493
+ dateText && refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-sep` }, ' ') : null,
31494
+ refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-refs` }, refsTrunc) : null,
31495
+ ].filter(Boolean)
31496
+ : [h(Text, { key: `${commit.hash}-${index}-l2-empty` }, '·')];
31497
+ const lineTwo = h(Text, {
31498
+ key: `${commit.hash}-${index}-l2`,
31499
+ dimColor: true,
31500
+ }, indent, ...metaContent);
31501
+ return h(Box, {
31502
+ key: `${commit.hash}-${index}-stack`,
31503
+ flexDirection: 'column',
31504
+ }, lineOne, lineTwo);
29186
31505
  }
29187
31506
  /**
29188
31507
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -29211,7 +31530,7 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
29211
31530
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
29212
31531
  }, truncateCells(label, 140));
29213
31532
  }
29214
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
31533
+ function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = new Date()) {
29215
31534
  const { Box, Text } = components;
29216
31535
  const focused = state.focus === 'commits';
29217
31536
  const worktree = context.worktree;
@@ -29227,8 +31546,26 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
29227
31546
  const showPendingRow = worktreeDirty &&
29228
31547
  !state.filter &&
29229
31548
  state.selectedIndex === 0;
29230
- const listRows = Math.max(3, bodyRows - (showPendingRow ? 5 : 4));
29231
- const visible = getVisibleLogInkHistory(state, listRows);
31549
+ // Stacked rows take two terminal lines each, so the visible item
31550
+ // budget is halved before the pending-row / chrome subtraction.
31551
+ // Full-graph mode injects a spacer row after every commit for
31552
+ // comfortable rhythm — the data-layer items still count 1 per row,
31553
+ // so the listRows budget passes straight through; the spacer rows
31554
+ // just consume some of that budget instead of additional commits.
31555
+ // Date bucketing is the new way the surface communicates "when" —
31556
+ // headers replace the per-row date column whenever the result set
31557
+ // is chronological (no active filter) AND the user has bucketing
31558
+ // enabled in `logTui.dateBucketing`. The filter check is the second
31559
+ // guardrail: even with bucketing enabled, an active search filter
31560
+ // shuffles commits by relevance so the adjacent-bucket invariant
31561
+ // breaks down and the dividers would read as noise.
31562
+ const fullGraphSpacing = state.fullGraph && !state.filter;
31563
+ const dateBucketingNow = !dateBucketingEnabled || state.filter ? undefined : now;
31564
+ const chromeRows = showPendingRow ? 5 : 4;
31565
+ const listRows = rowMode === 'stacked'
31566
+ ? Math.max(2, Math.floor((bodyRows - chromeRows) / 2))
31567
+ : Math.max(3, bodyRows - chromeRows);
31568
+ const visible = getVisibleLogInkHistory(state, listRows, { fullGraphSpacing, dateBucketingNow });
29232
31569
  const loadState = loadingMoreCommits
29233
31570
  ? 'loading older commits'
29234
31571
  : hasMoreCommits
@@ -29263,23 +31600,51 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
29263
31600
  totalCommits: state.commits.length,
29264
31601
  }))
29265
31602
  : visible.items.map((item, index) => {
31603
+ if (item.type === 'bucket-header') {
31604
+ // Section divider — `── Today ────────────`. The label is
31605
+ // bold to anchor the eye, the surrounding rule is dim so
31606
+ // the divider reads as chrome rather than competing with
31607
+ // commit content. Rule fills the panel's interior width
31608
+ // (minus border + padding); the label rides inside it.
31609
+ const contentWidth = Math.max(10, width - 4);
31610
+ const labelCells = cellWidth(item.label) + 2; // pad the label with surrounding spaces
31611
+ const ruleAfter = Math.max(0, contentWidth - 3 - labelCells);
31612
+ return h(Text, {
31613
+ key: `bucket-${index}-${item.label}`,
31614
+ dimColor: true,
31615
+ }, h(Text, undefined, '── '), h(Text, { bold: true }, item.label), h(Text, undefined, ' '), h(Text, undefined, '─'.repeat(ruleAfter)));
31616
+ }
29266
31617
  if (item.type === 'graph') {
29267
- // Graph-only rows are git's lane-closure scaffolding (`|/`,
29268
- // `|\`, etc.) — they're real topology but visually they look
29269
- // like blank rows that the user might wonder if they
29270
- // accidentally skipped a commit on (#831). Render dim-on-dim
29271
- // so they retreat as connectors rather than competing with
29272
- // commit rows for the eye's attention.
31618
+ // Graph-only rows split into two visual categories:
31619
+ //
31620
+ // - git's own lane-closure scaffolding (`|/`, `|\`, etc.)
31621
+ // stays dim-on-dim so it reads as connector chrome that
31622
+ // recedes behind the commits it joins (#831). The eye
31623
+ // should never confuse a fork/close row for a commit
31624
+ // somebody accidentally skipped.
31625
+ //
31626
+ // - synthetic spacers we inject between linear commits
31627
+ // (`spacer: true`) render at FULL lane brightness so the
31628
+ // trunk lane bar visibly connects consecutive commits.
31629
+ // They are explicitly NOT scaffolding — they exist to
31630
+ // make linear-history rhythm read as one continuous lane.
31631
+ const isSpacer = item.spacer === true;
29273
31632
  if (item.laneSegments && !theme.ascii) {
29274
- return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
31633
+ return h(Text, {
31634
+ key: `graph-${index}-${item.graph}`,
31635
+ dimColor: !isSpacer,
31636
+ }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: !isSpacer }));
29275
31637
  }
29276
31638
  return h(Text, {
29277
31639
  key: `graph-${index}-${item.graph}`,
29278
31640
  color: theme.noColor ? undefined : theme.colors.muted,
29279
- dimColor: true,
31641
+ dimColor: !isSpacer,
29280
31642
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
29281
31643
  }
29282
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments, recentCommitsSet.has(item.commit.hash));
31644
+ if (rowMode === 'stacked') {
31645
+ return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
31646
+ }
31647
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
29283
31648
  }));
29284
31649
  }
29285
31650
 
@@ -29654,6 +32019,138 @@ function wrapErrorMessage(message, maxWidth) {
29654
32019
  return lines;
29655
32020
  }
29656
32021
 
32022
+ /**
32023
+ * Issues triage surface (#882 phase 3). Read-only list view rendered
32024
+ * in the main panel when `state.activeView === 'issues'`. Mirrors the
32025
+ * branches / tags surface pattern: pure renderer, no hooks, no async.
32026
+ * Data flows in via `context.issueList`; the cursor position lives at
32027
+ * `state.selectedIssueIndex`.
32028
+ *
32029
+ * Per-row actions (assign, label, comment, close) and AI summarize
32030
+ * land in phase 4-6. This phase ships navigation only.
32031
+ */
32032
+ function stateColor$1(theme, state) {
32033
+ if (theme.noColor)
32034
+ return undefined;
32035
+ switch (state.toUpperCase()) {
32036
+ case 'OPEN':
32037
+ return theme.colors.success;
32038
+ case 'CLOSED':
32039
+ return theme.colors.danger;
32040
+ default:
32041
+ return theme.colors.muted;
32042
+ }
32043
+ }
32044
+ function matchesIssueFilter(issue, filter) {
32045
+ if (!filter)
32046
+ return true;
32047
+ return matchesPromotedFilter([
32048
+ `#${issue.number}`,
32049
+ issue.title,
32050
+ issue.author || '',
32051
+ ...(issue.labels || []),
32052
+ ...(issue.assignees || []),
32053
+ ], filter);
32054
+ }
32055
+ function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
32056
+ const { Box, Text } = components;
32057
+ const focused = state.focus === 'commits';
32058
+ const overview = context.issueList;
32059
+ const loading = isLogInkContextKeyLoading(contextStatus, 'issueList');
32060
+ // Resolve the "what should the panel say" headline first, then fan
32061
+ // out to the row body. The chrome (border + title + headerRight) is
32062
+ // identical across loading / unavailable / unauthenticated / empty
32063
+ // / populated states; only the body changes.
32064
+ let headerRight = '';
32065
+ let bodyLines = [];
32066
+ if (loading || !overview) {
32067
+ headerRight = 'loading issues';
32068
+ bodyLines = [
32069
+ h(Text, { key: 'issues-loading', dimColor: true }, formatLogInkLoading({ resource: 'issues' })),
32070
+ ];
32071
+ }
32072
+ else if (!overview.available) {
32073
+ headerRight = 'unavailable';
32074
+ bodyLines = [
32075
+ h(Text, { key: 'issues-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Issues' })),
32076
+ ];
32077
+ }
32078
+ else if (!overview.authenticated) {
32079
+ headerRight = 'gh not authenticated';
32080
+ bodyLines = [
32081
+ h(Text, { key: 'issues-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Issues' })),
32082
+ ];
32083
+ }
32084
+ else if (overview.message && !overview.issues) {
32085
+ headerRight = 'error';
32086
+ bodyLines = [
32087
+ h(Text, { key: 'issues-error', dimColor: true, color: theme.noColor ? undefined : theme.colors.danger }, overview.message),
32088
+ ];
32089
+ }
32090
+ else {
32091
+ const all = overview.issues || [];
32092
+ const visible = state.filter
32093
+ ? all.filter((issue) => matchesIssueFilter(issue, state.filter))
32094
+ : all;
32095
+ const selected = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
32096
+ const listRows = Math.max(4, bodyRows - 4);
32097
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
32098
+ const windowed = visible.slice(startIndex, startIndex + listRows);
32099
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
32100
+ const presetLabel = `▼ ${ISSUE_FILTER_LABELS[state.selectedIssueFilter]}`;
32101
+ const repoLabel = overview.repository
32102
+ ? `${overview.repository.owner}/${overview.repository.name}`
32103
+ : '';
32104
+ headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
32105
+ if (visible.length === 0) {
32106
+ bodyLines = [
32107
+ h(Text, { key: 'issues-empty', dimColor: true }, formatLogInkIssuesEmpty({ filter: state.filter })),
32108
+ ];
32109
+ }
32110
+ else {
32111
+ // Column widths derived from the visible window so columns stay
32112
+ // aligned without one outlier title pushing the rest sideways.
32113
+ // Capped to keep the title column from being squeezed out on
32114
+ // narrow terminals.
32115
+ const numberColWidth = Math.min(6, Math.max(...windowed.map((i) => `#${i.number}`.length), 3));
32116
+ const authorColWidth = Math.min(16, Math.max(...windowed.map((i) => (i.author || '').length), 4));
32117
+ bodyLines = windowed.map((issue, offset) => {
32118
+ const index = startIndex + offset;
32119
+ const isSelected = index === selected;
32120
+ const cursor = isSelected ? '>' : ' ';
32121
+ const numStr = `#${issue.number}`.padEnd(numberColWidth);
32122
+ const stateStr = issue.state.toLowerCase().padEnd(6);
32123
+ const authorStr = (issue.author || '').padEnd(authorColWidth);
32124
+ const commentsStr = typeof issue.comments === 'number' && issue.comments > 0
32125
+ ? ` ${issue.comments}c`
32126
+ : '';
32127
+ // The title column gets whatever is left after the prefix
32128
+ // columns. Truncate so the row stays a single visual line.
32129
+ const head = `${cursor} `;
32130
+ const prefix = `${numStr} ${stateStr} ${authorStr} `;
32131
+ const titleBudget = Math.max(8, width - 4 - head.length - prefix.length - commentsStr.length);
32132
+ const titleStr = truncateCells(issue.title, titleBudget);
32133
+ const labelStr = (issue.labels || []).length
32134
+ ? ` [${(issue.labels || []).join(' ')}]`
32135
+ : '';
32136
+ return h(Text, {
32137
+ key: `issue-${index}`,
32138
+ bold: isSelected,
32139
+ dimColor: !isSelected,
32140
+ }, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, { color: stateColor$1(theme, issue.state), dimColor: !isSelected }, stateStr + ' '), h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: !isSelected }, authorStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr), h(Text, { dimColor: true }, commentsStr));
32141
+ });
32142
+ }
32143
+ }
32144
+ return h(Box, {
32145
+ borderColor: focusBorderColor(theme, focused),
32146
+ borderStyle: theme.borderStyle,
32147
+ flexDirection: 'column',
32148
+ flexShrink: 0,
32149
+ paddingX: 1,
32150
+ width,
32151
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Issues', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
32152
+ }
32153
+
29657
32154
  /**
29658
32155
  * Normalize gh's two parallel signals (`status` for in-flight check
29659
32156
  * runs, `conclusion` for completed runs and status contexts) into a
@@ -29918,6 +32415,205 @@ function renderPullRequestSurface(h, components, state, context, contextStatus,
29918
32415
  : []));
29919
32416
  }
29920
32417
 
32418
+ /**
32419
+ * Pull-request triage surface (#882 phase 3). Read-only list view
32420
+ * rendered in the main panel when `state.activeView ===
32421
+ * 'pull-request-triage'`. Distinct from the existing single-PR action
32422
+ * panel (`'pull-request'`, chord `gp`) — this is the multi-PR list
32423
+ * surface (chord `gP`).
32424
+ *
32425
+ * Pure renderer; data flows in via `context.pullRequestList`. Per-row
32426
+ * actions (merge, approve, request-changes, close, comment) and AI
32427
+ * summarize land in phase 4-6.
32428
+ */
32429
+ function stateColor(theme, state, isDraft) {
32430
+ if (theme.noColor)
32431
+ return undefined;
32432
+ if (isDraft)
32433
+ return theme.colors.muted;
32434
+ switch (state.toUpperCase()) {
32435
+ case 'OPEN':
32436
+ return theme.colors.success;
32437
+ case 'CLOSED':
32438
+ return theme.colors.danger;
32439
+ case 'MERGED':
32440
+ return theme.colors.accent;
32441
+ default:
32442
+ return theme.colors.muted;
32443
+ }
32444
+ }
32445
+ function reviewGlyph(decision) {
32446
+ switch (decision) {
32447
+ case 'APPROVED':
32448
+ return '✓';
32449
+ case 'CHANGES_REQUESTED':
32450
+ return '✗';
32451
+ case 'REVIEW_REQUIRED':
32452
+ return '?';
32453
+ default:
32454
+ return ' ';
32455
+ }
32456
+ }
32457
+ function mergeableGlyph(mergeStateStatus, mergeable) {
32458
+ if (mergeStateStatus === 'CLEAN')
32459
+ return '●';
32460
+ if (mergeStateStatus === 'BLOCKED')
32461
+ return '●';
32462
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
32463
+ return '●';
32464
+ if (mergeStateStatus === 'BEHIND')
32465
+ return '●';
32466
+ if (mergeStateStatus === 'UNSTABLE')
32467
+ return '●';
32468
+ return '·';
32469
+ }
32470
+ function mergeableColor(theme, mergeStateStatus, mergeable) {
32471
+ if (theme.noColor)
32472
+ return undefined;
32473
+ if (mergeStateStatus === 'CLEAN')
32474
+ return theme.colors.success;
32475
+ if (mergeStateStatus === 'BLOCKED' || mergeStateStatus === 'UNSTABLE')
32476
+ return theme.colors.warning;
32477
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
32478
+ return theme.colors.danger;
32479
+ if (mergeStateStatus === 'BEHIND')
32480
+ return theme.colors.accent;
32481
+ return theme.colors.muted;
32482
+ }
32483
+ function reviewColor(theme, decision) {
32484
+ if (theme.noColor)
32485
+ return undefined;
32486
+ switch (decision) {
32487
+ case 'APPROVED':
32488
+ return theme.colors.success;
32489
+ case 'CHANGES_REQUESTED':
32490
+ return theme.colors.danger;
32491
+ case 'REVIEW_REQUIRED':
32492
+ return theme.colors.warning;
32493
+ default:
32494
+ return theme.colors.muted;
32495
+ }
32496
+ }
32497
+ function matchesPullRequestFilter(pr, filter) {
32498
+ if (!filter)
32499
+ return true;
32500
+ return matchesPromotedFilter([
32501
+ `#${pr.number}`,
32502
+ pr.title,
32503
+ pr.author || '',
32504
+ pr.headRefName,
32505
+ pr.baseRefName,
32506
+ ...(pr.labels || []),
32507
+ ...(pr.assignees || []),
32508
+ ], filter);
32509
+ }
32510
+ function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
32511
+ const { Box, Text } = components;
32512
+ const focused = state.focus === 'commits';
32513
+ const overview = context.pullRequestList;
32514
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequestList');
32515
+ let headerRight = '';
32516
+ let bodyLines = [];
32517
+ if (loading || !overview) {
32518
+ headerRight = 'loading pull requests';
32519
+ bodyLines = [
32520
+ h(Text, { key: 'pr-triage-loading', dimColor: true }, formatLogInkLoading({ resource: 'pull requests' })),
32521
+ ];
32522
+ }
32523
+ else if (!overview.available) {
32524
+ headerRight = 'unavailable';
32525
+ bodyLines = [
32526
+ h(Text, { key: 'pr-triage-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Pull requests' })),
32527
+ ];
32528
+ }
32529
+ else if (!overview.authenticated) {
32530
+ headerRight = 'gh not authenticated';
32531
+ bodyLines = [
32532
+ h(Text, { key: 'pr-triage-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Pull requests' })),
32533
+ ];
32534
+ }
32535
+ else if (overview.message && !overview.pullRequests) {
32536
+ headerRight = 'error';
32537
+ bodyLines = [
32538
+ h(Text, {
32539
+ key: 'pr-triage-error',
32540
+ dimColor: true,
32541
+ color: theme.noColor ? undefined : theme.colors.danger,
32542
+ }, overview.message),
32543
+ ];
32544
+ }
32545
+ else {
32546
+ const all = overview.pullRequests || [];
32547
+ const visible = state.filter
32548
+ ? all.filter((pr) => matchesPullRequestFilter(pr, state.filter))
32549
+ : all;
32550
+ const selected = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
32551
+ const listRows = Math.max(4, bodyRows - 4);
32552
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
32553
+ const windowed = visible.slice(startIndex, startIndex + listRows);
32554
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
32555
+ const presetLabel = `▼ ${PULL_REQUEST_FILTER_LABELS[state.selectedPullRequestFilter]}`;
32556
+ const repoLabel = overview.repository
32557
+ ? `${overview.repository.owner}/${overview.repository.name}`
32558
+ : '';
32559
+ headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
32560
+ if (visible.length === 0) {
32561
+ bodyLines = [
32562
+ h(Text, { key: 'pr-triage-empty', dimColor: true }, formatLogInkPullRequestTriageEmpty({ filter: state.filter })),
32563
+ ];
32564
+ }
32565
+ else {
32566
+ const numberColWidth = Math.min(6, Math.max(...windowed.map((p) => `#${p.number}`.length), 3));
32567
+ const authorColWidth = Math.min(16, Math.max(...windowed.map((p) => (p.author || '').length), 4));
32568
+ const branchColWidth = Math.min(24, Math.max(...windowed.map((p) => p.headRefName.length), 6));
32569
+ bodyLines = windowed.map((pr, offset) => {
32570
+ const index = startIndex + offset;
32571
+ const isSelected = index === selected;
32572
+ const cursor = isSelected ? '>' : ' ';
32573
+ const numStr = `#${pr.number}`.padEnd(numberColWidth);
32574
+ const stateLabel = pr.isDraft ? 'draft' : pr.state.toLowerCase();
32575
+ const stateStr = stateLabel.padEnd(6);
32576
+ const mergeStr = mergeableGlyph(pr.mergeStateStatus, pr.mergeable);
32577
+ const reviewStr = reviewGlyph(pr.reviewDecision);
32578
+ const authorStr = (pr.author || '').padEnd(authorColWidth);
32579
+ const branchStr = truncateCells(pr.headRefName, branchColWidth).padEnd(branchColWidth);
32580
+ const labelStr = (pr.labels || []).length
32581
+ ? ` [${(pr.labels || []).join(' ')}]`
32582
+ : '';
32583
+ const head = `${cursor} `;
32584
+ const prefix = `${numStr} ${stateStr} ${mergeStr} ${reviewStr} ${authorStr} ${branchStr} `;
32585
+ const titleBudget = Math.max(8, width - 4 - head.length - prefix.length);
32586
+ const titleStr = truncateCells(pr.title, titleBudget);
32587
+ return h(Text, {
32588
+ key: `pr-triage-${index}`,
32589
+ bold: isSelected,
32590
+ dimColor: !isSelected,
32591
+ }, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, {
32592
+ color: stateColor(theme, pr.state, pr.isDraft),
32593
+ dimColor: !isSelected,
32594
+ }, stateStr + ' '), h(Text, {
32595
+ color: mergeableColor(theme, pr.mergeStateStatus, pr.mergeable),
32596
+ dimColor: !isSelected,
32597
+ }, mergeStr + ' '), h(Text, {
32598
+ color: reviewColor(theme, pr.reviewDecision),
32599
+ dimColor: !isSelected,
32600
+ }, reviewStr + ' '), h(Text, {
32601
+ color: theme.noColor ? undefined : theme.colors.accent,
32602
+ dimColor: !isSelected,
32603
+ }, authorStr + ' '), h(Text, { dimColor: true }, branchStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr));
32604
+ });
32605
+ }
32606
+ }
32607
+ return h(Box, {
32608
+ borderColor: focusBorderColor(theme, focused),
32609
+ borderStyle: theme.borderStyle,
32610
+ flexDirection: 'column',
32611
+ flexShrink: 0,
32612
+ paddingX: 1,
32613
+ width,
32614
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull requests', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
32615
+ }
32616
+
29921
32617
  /**
29922
32618
  * Reflog surface (#781). Renders `git reflog` chronologically — every
29923
32619
  * HEAD movement (commit, checkout, merge, reset, …) with relative time,
@@ -30497,7 +33193,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
30497
33193
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30498
33194
  * of #890. No behavior change.
30499
33195
  */
30500
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame) {
33196
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled) {
30501
33197
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
30502
33198
  // detail) when active, because the content — multiple commit groups
30503
33199
  // with file lists, rationale, hunks — needs the full center width
@@ -30541,13 +33237,19 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
30541
33237
  if (state.activeView === 'pull-request') {
30542
33238
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30543
33239
  }
33240
+ if (state.activeView === 'pull-request-triage') {
33241
+ return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
33242
+ }
33243
+ if (state.activeView === 'issues') {
33244
+ return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
33245
+ }
30544
33246
  if (state.activeView === 'conflicts') {
30545
33247
  return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30546
33248
  }
30547
33249
  if (state.activeView === 'changelog') {
30548
33250
  return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30549
33251
  }
30550
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
33252
+ return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
30551
33253
  }
30552
33254
 
30553
33255
  /**
@@ -30645,6 +33347,230 @@ function formatStashPreview(stash, options = {}) {
30645
33347
  }
30646
33348
  return out;
30647
33349
  }
33350
+ /* ------------------------- detail-section helpers ----------------------- */
33351
+ /**
33352
+ * Render the first `maxLines` non-empty lines of an issue / PR body
33353
+ * as preview lines. Returns an empty array when the body itself is
33354
+ * empty (or whitespace only) so callers can `out.push(...body(...))`
33355
+ * without an extra guard. Trailer appears only when content was
33356
+ * actually truncated.
33357
+ */
33358
+ function bodyExcerptLines(body, maxLines) {
33359
+ if (!body.trim())
33360
+ return [];
33361
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
33362
+ // Drop leading blanks so the excerpt opens on the first real line
33363
+ // rather than rendering an awkward "blank line, then body".
33364
+ while (lines.length > 0 && !lines[0].trim())
33365
+ lines.shift();
33366
+ const shown = lines.slice(0, maxLines);
33367
+ const truncated = lines.length > maxLines;
33368
+ const out = [
33369
+ heading('Body'),
33370
+ ...shown.map((l) => line(l)),
33371
+ ];
33372
+ if (truncated) {
33373
+ out.push(dim(`… ${lines.length - maxLines} more line(s)`));
33374
+ }
33375
+ return out;
33376
+ }
33377
+ function shortenLine(value, maxLength) {
33378
+ const flattened = value.replace(/\s+/g, ' ').trim();
33379
+ if (flattened.length <= maxLength)
33380
+ return flattened;
33381
+ return `${flattened.slice(0, Math.max(0, maxLength - 1))}…`;
33382
+ }
33383
+ function commentsSection(comments, maxShown) {
33384
+ if (comments.length === 0)
33385
+ return [];
33386
+ const recent = comments.slice(-3);
33387
+ const out = [heading(`Comments (${comments.length})`)];
33388
+ for (const comment of recent) {
33389
+ const who = comment.author || 'anonymous';
33390
+ out.push(line(`@${who}: ${shortenLine(comment.body, 80)}`));
33391
+ }
33392
+ if (comments.length > recent.length) {
33393
+ out.push(dim(`… ${comments.length - recent.length} earlier comment(s)`));
33394
+ }
33395
+ return out;
33396
+ }
33397
+ function reviewsSection(reviews) {
33398
+ if (reviews.length === 0)
33399
+ return [];
33400
+ const out = [heading(`Reviews (${reviews.length})`)];
33401
+ for (const review of reviews) {
33402
+ const who = review.author || 'anonymous';
33403
+ const stateLabel = (review.state || 'commented').toLowerCase().replace(/_/g, ' ');
33404
+ const inlineBody = review.body ? ` — ${shortenLine(review.body, 60)}` : '';
33405
+ out.push(line(`@${who} (${stateLabel})${inlineBody}`));
33406
+ }
33407
+ return out;
33408
+ }
33409
+ function statusChecksSection(checks) {
33410
+ if (checks.length === 0)
33411
+ return [];
33412
+ const grouped = {
33413
+ success: 0,
33414
+ failure: 0,
33415
+ pending: 0,
33416
+ other: 0,
33417
+ };
33418
+ for (const check of checks) {
33419
+ const result = check.conclusion?.toLowerCase() ?? check.status?.toLowerCase() ?? '';
33420
+ if (result === 'success')
33421
+ grouped.success++;
33422
+ else if (result === 'failure' || result === 'cancelled' || result === 'timed_out')
33423
+ grouped.failure++;
33424
+ else if (result === 'pending' || result === 'queued' || result === 'in_progress')
33425
+ grouped.pending++;
33426
+ else
33427
+ grouped.other++;
33428
+ }
33429
+ const parts = [];
33430
+ if (grouped.success)
33431
+ parts.push(`${grouped.success} pass`);
33432
+ if (grouped.failure)
33433
+ parts.push(`${grouped.failure} fail`);
33434
+ if (grouped.pending)
33435
+ parts.push(`${grouped.pending} pending`);
33436
+ if (grouped.other)
33437
+ parts.push(`${grouped.other} other`);
33438
+ return [
33439
+ heading(`Checks (${checks.length})`),
33440
+ line(parts.join(' · ')),
33441
+ ];
33442
+ }
33443
+ /* -------------------------------- issue -------------------------------- */
33444
+ /**
33445
+ * Format an issue triage entry into preview lines (#882 phase 3,
33446
+ * body + comments added in the inspector-hydration follow-up).
33447
+ * The list payload from `gh issue list --json` carries metadata
33448
+ * only; the optional `detail` argument is filled by the runtime's
33449
+ * debounced hydration effect when the cursor rests on a row, and
33450
+ * unlocks the body / comments sections.
33451
+ */
33452
+ function formatIssueTriagePreview(issue, detail) {
33453
+ if (!issue) {
33454
+ return [dim('Select an issue to preview.')];
33455
+ }
33456
+ const out = [
33457
+ heading(`#${issue.number} · ${issue.title}`),
33458
+ blank(),
33459
+ line(`State: ${issue.state.toLowerCase()}`),
33460
+ ];
33461
+ if (issue.author)
33462
+ out.push(line(`Author: ${issue.author}`));
33463
+ if (issue.assignees && issue.assignees.length > 0) {
33464
+ out.push(line(`Assigned: ${issue.assignees.join(', ')}`));
33465
+ }
33466
+ if (issue.labels && issue.labels.length > 0) {
33467
+ out.push(line(`Labels: ${issue.labels.join(', ')}`));
33468
+ }
33469
+ if (typeof issue.comments === 'number') {
33470
+ out.push(line(`Comments: ${issue.comments}`));
33471
+ }
33472
+ out.push(blank());
33473
+ if (issue.createdAt)
33474
+ out.push(line(`Created: ${issue.createdAt}`));
33475
+ if (issue.updatedAt)
33476
+ out.push(line(`Updated: ${issue.updatedAt}`));
33477
+ out.push(blank());
33478
+ out.push(dim(issue.url));
33479
+ // Hydrated sections (body + recent comments). Inserted only when
33480
+ // the runtime has finished the per-cursor-rest detail fetch and
33481
+ // populated the cache.
33482
+ if (detail) {
33483
+ const body = bodyExcerptLines(detail.body, 6);
33484
+ if (body.length > 0) {
33485
+ out.push(blank());
33486
+ out.push(...body);
33487
+ }
33488
+ const comments = commentsSection(detail.comments);
33489
+ if (comments.length > 0) {
33490
+ out.push(blank());
33491
+ out.push(...comments);
33492
+ }
33493
+ }
33494
+ else if (typeof issue.comments === 'number' && issue.comments > 0) {
33495
+ // Pre-hydration affordance — tell the user the body / comments
33496
+ // section is coming, so a 250ms wait doesn't look like a bug.
33497
+ out.push(blank());
33498
+ out.push(dim('Loading body + comments…'));
33499
+ }
33500
+ return out;
33501
+ }
33502
+ /* ----------------------------- pull request ---------------------------- */
33503
+ /**
33504
+ * Format a pull-request triage entry into preview lines (#882 phase 3,
33505
+ * body / comments / reviews / checks added in the inspector-hydration
33506
+ * follow-up). Optional `detail` argument is filled by the runtime's
33507
+ * debounced hydration effect when the cursor rests on a row.
33508
+ */
33509
+ function formatPullRequestTriagePreview(pr, detail) {
33510
+ if (!pr) {
33511
+ return [dim('Select a pull request to preview.')];
33512
+ }
33513
+ const out = [
33514
+ heading(`#${pr.number} · ${pr.title}`),
33515
+ blank(),
33516
+ line(`State: ${pr.isDraft ? 'draft' : pr.state.toLowerCase()}`),
33517
+ ];
33518
+ if (pr.author)
33519
+ out.push(line(`Author: ${pr.author}`));
33520
+ out.push(line(`Branches: ${pr.headRefName} → ${pr.baseRefName}`));
33521
+ if (pr.mergeable || pr.mergeStateStatus) {
33522
+ const merge = [pr.mergeable, pr.mergeStateStatus].filter(Boolean).join(' / ');
33523
+ out.push(line(`Mergeable: ${merge.toLowerCase()}`));
33524
+ }
33525
+ if (pr.reviewDecision) {
33526
+ out.push(line(`Review: ${pr.reviewDecision.toLowerCase().replace(/_/g, ' ')}`));
33527
+ }
33528
+ if (pr.assignees && pr.assignees.length > 0) {
33529
+ out.push(line(`Assigned: ${pr.assignees.join(', ')}`));
33530
+ }
33531
+ if (pr.labels && pr.labels.length > 0) {
33532
+ out.push(line(`Labels: ${pr.labels.join(', ')}`));
33533
+ }
33534
+ out.push(blank());
33535
+ if (pr.createdAt)
33536
+ out.push(line(`Created: ${pr.createdAt}`));
33537
+ if (pr.updatedAt)
33538
+ out.push(line(`Updated: ${pr.updatedAt}`));
33539
+ out.push(blank());
33540
+ out.push(dim(pr.url));
33541
+ // Hydrated sections — body, status checks, reviews, comments.
33542
+ // Status checks come BEFORE reviews because failing CI is usually
33543
+ // what a triager wants to see first; reviews come second because
33544
+ // they're the human-judgment layer on top.
33545
+ if (detail) {
33546
+ const body = bodyExcerptLines(detail.body, 6);
33547
+ if (body.length > 0) {
33548
+ out.push(blank());
33549
+ out.push(...body);
33550
+ }
33551
+ const checks = statusChecksSection(detail.statusCheckRollup);
33552
+ if (checks.length > 0) {
33553
+ out.push(blank());
33554
+ out.push(...checks);
33555
+ }
33556
+ const reviews = reviewsSection(detail.reviews);
33557
+ if (reviews.length > 0) {
33558
+ out.push(blank());
33559
+ out.push(...reviews);
33560
+ }
33561
+ const comments = commentsSection(detail.comments);
33562
+ if (comments.length > 0) {
33563
+ out.push(blank());
33564
+ out.push(...comments);
33565
+ }
33566
+ }
33567
+ else {
33568
+ // Pre-hydration affordance — same as the issue preview.
33569
+ out.push(blank());
33570
+ out.push(dim('Loading body + reviews + comments…'));
33571
+ }
33572
+ return out;
33573
+ }
30648
33574
 
30649
33575
  /**
30650
33576
  * Detail / inspector / preview surface family.
@@ -30683,6 +33609,15 @@ function formatStashPreview(stash, options = {}) {
30683
33609
  * minimum from `getLogInkLayout`) so an overflowing label never wraps and
30684
33610
  * collides with the next row.
30685
33611
  */
33612
+ /**
33613
+ * Format the file-count portion of the inspector stats line. Pluralize
33614
+ * "files" only when the count is not 1 so `1 file +12/-22` reads
33615
+ * naturally instead of `1 files`.
33616
+ */
33617
+ function formatCommitStatLine(stats) {
33618
+ const label = stats.filesChanged === 1 ? 'file' : 'files';
33619
+ return `${stats.filesChanged} ${label} +${stats.insertions}/-${stats.deletions}`;
33620
+ }
30686
33621
  function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
30687
33622
  const actions = getInspectorActions(context);
30688
33623
  if (!actions.length)
@@ -30857,7 +33792,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
30857
33792
  cursorActive: focused && state.inspectorTab === 'actions',
30858
33793
  }));
30859
33794
  }
30860
- const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
33795
+ const statLine = formatCommitStatLine(detail.stats);
30861
33796
  // P5.1 — link the commit hash and each ref out to GitHub when we know
30862
33797
  // the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
30863
33798
  // whether to wrap or fall through to plain text.
@@ -30977,7 +33912,7 @@ function renderCommitDiffDetail(h, components, state, detail, loading, width, th
30977
33912
  dimColor: index > 1,
30978
33913
  }, truncateCells(line, width - 4))));
30979
33914
  }
30980
- const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
33915
+ const statLine = formatCommitStatLine(detail.stats);
30981
33916
  const headerLines = [
30982
33917
  detail.message,
30983
33918
  '',
@@ -31206,6 +34141,64 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
31206
34141
  ? null
31207
34142
  : h(Text, { key: 'commit-state', dimColor: true }, truncateCells(stateLine, width - 4)));
31208
34143
  }
34144
+ /**
34145
+ * Issue triage preview pane (#882 phase 3). Mirrors the branch / tag /
34146
+ * stash preview pattern — `renderPreviewPanel` chrome, formatter pulls
34147
+ * the cursored item via `state.selectedIssueIndex`. Shown when
34148
+ * `state.activeView === 'issues'`.
34149
+ */
34150
+ function renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
34151
+ const { Box, Text } = components;
34152
+ if (isLogInkContextKeyLoading(contextStatus, 'issueList')) {
34153
+ return renderPreviewPanel(h, { Box, Text }, 'Issue preview', [{ text: formatLogInkLoading({ resource: 'issues' }), emphasis: 'dim' }], width, theme, focused);
34154
+ }
34155
+ const all = context.issueList?.issues || [];
34156
+ const visible = state.filter
34157
+ ? all.filter((issue) => matchesPromotedFilter([
34158
+ `#${issue.number}`,
34159
+ issue.title,
34160
+ issue.author || '',
34161
+ ...(issue.labels || []),
34162
+ ...(issue.assignees || []),
34163
+ ], state.filter))
34164
+ : all;
34165
+ const index = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
34166
+ const issue = visible[index];
34167
+ const detail = issue
34168
+ ? context.issueDetailByNumber?.get(issue.number)
34169
+ : undefined;
34170
+ return renderPreviewPanel(h, { Box, Text }, 'Issue preview', formatIssueTriagePreview(issue, detail), width, theme, focused);
34171
+ }
34172
+ /**
34173
+ * Pull-request triage preview pane (#882 phase 3). Shown when
34174
+ * `state.activeView === 'pull-request-triage'`. Distinct from the
34175
+ * single-PR action panel's right pane (which renders the full
34176
+ * inspector with status checks, reviews, and action keys).
34177
+ */
34178
+ function renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
34179
+ const { Box, Text } = components;
34180
+ if (isLogInkContextKeyLoading(contextStatus, 'pullRequestList')) {
34181
+ return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', [{ text: formatLogInkLoading({ resource: 'pull requests' }), emphasis: 'dim' }], width, theme, focused);
34182
+ }
34183
+ const all = context.pullRequestList?.pullRequests || [];
34184
+ const visible = state.filter
34185
+ ? all.filter((pr) => matchesPromotedFilter([
34186
+ `#${pr.number}`,
34187
+ pr.title,
34188
+ pr.author || '',
34189
+ pr.headRefName,
34190
+ pr.baseRefName,
34191
+ ...(pr.labels || []),
34192
+ ...(pr.assignees || []),
34193
+ ], state.filter))
34194
+ : all;
34195
+ const index = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
34196
+ const pr = visible[index];
34197
+ const detail = pr
34198
+ ? context.pullRequestDetailByNumber?.get(pr.number)
34199
+ : undefined;
34200
+ return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', formatPullRequestTriagePreview(pr, detail), width, theme, focused);
34201
+ }
31209
34202
 
31210
34203
  /**
31211
34204
  * Detail-panel dispatcher. Routes to the right detail / overlay
@@ -31221,8 +34214,41 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
31221
34214
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
31222
34215
  * of #890. No behavior change.
31223
34216
  */
31224
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
34217
+ /**
34218
+ * Rail-mode inspector — shown on terminals < 100 columns when the
34219
+ * detail panel does not hold focus. The full inspector (commit body,
34220
+ * file list, actions) does not survive truncation to ~4 content cells
34221
+ * so we collapse to a stack with the panel label and the selected
34222
+ * commit's shortHash. Focus pops the panel back to its expanded
34223
+ * widths via the layout, so this renderer is only reached at rest.
34224
+ *
34225
+ * Help / overlay states are still handled by their own renderers
34226
+ * above; this short-circuit only kicks in for the regular "view the
34227
+ * commit" cases.
34228
+ */
34229
+ function renderInspectorRail(h, components, state, detail, width, theme, focused) {
34230
+ const { Box, Text } = components;
34231
+ // Prefer the loaded detail's hash (canonical) but fall back to the
34232
+ // selected list row's shortHash so the rail isn't blank on the
34233
+ // first render before getCommitDetail resolves.
34234
+ const selectedRow = getSelectedInkCommit(state);
34235
+ const hashText = detail?.hash.slice(0, 4)
34236
+ ?? selectedRow?.shortHash.slice(0, 4)
34237
+ ?? '····';
34238
+ return h(Box, {
34239
+ borderColor: focusBorderColor(theme, focused),
34240
+ borderStyle: theme.borderStyle,
34241
+ flexDirection: 'column',
34242
+ width,
34243
+ paddingX: 1,
34244
+ }, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
34245
+ }
34246
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false) {
31225
34247
  const focused = state.focus === 'detail';
34248
+ // Overlays (help / palette / input / confirmation / chord) take
34249
+ // precedence over rail because they always claim the panel's width
34250
+ // via the help-overlay layout branch — and railing those would
34251
+ // defeat their whole purpose (the user is reading them).
31226
34252
  if (state.showHelp) {
31227
34253
  return renderHelpPanel(h, components, state, width, theme, focused);
31228
34254
  }
@@ -31248,6 +34274,15 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
31248
34274
  if (state.pendingKey && !state.splitPlan) {
31249
34275
  return renderChordOverlay(h, components, state, width, theme, focused);
31250
34276
  }
34277
+ // Rail mode applies only after every overlay above has had its say
34278
+ // — those would all be unreadable at 4 cells of content. The layout
34279
+ // also clears `railed` whenever the inspector takes focus, so we
34280
+ // can safely short-circuit the per-view dispatch here without
34281
+ // worrying about hiding the panel from a user who's actively
34282
+ // reading it.
34283
+ if (railed) {
34284
+ return renderInspectorRail(h, components, state, detail, width, theme, focused);
34285
+ }
31251
34286
  // The synthetic "(+) new commit" row routes the inspector through the
31252
34287
  // worktree summary so the user sees what's staged / unstaged at a glance
31253
34288
  // — same surface as the compose view's right panel.
@@ -31288,6 +34323,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
31288
34323
  if (state.activeView === 'submodules') {
31289
34324
  return renderSubmodulePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
31290
34325
  }
34326
+ if (state.activeView === 'issues') {
34327
+ return renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
34328
+ }
34329
+ if (state.activeView === 'pull-request-triage') {
34330
+ return renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
34331
+ }
31291
34332
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
31292
34333
  }
31293
34334
 
@@ -31508,7 +34549,7 @@ function enrichFilterActionWithRectification(action, state, context) {
31508
34549
  }
31509
34550
  }
31510
34551
  function LogInkApp(deps) {
31511
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34552
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
31512
34553
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
31513
34554
  const h = React.createElement;
31514
34555
  const { exit } = useApp();
@@ -31702,6 +34743,35 @@ function LogInkApp(deps) {
31702
34743
  return all;
31703
34744
  return all.filter((entry) => matchesPromotedFilter([entry.name, entry.path, entry.trackingBranch || '', entry.url || ''], state.filter));
31704
34745
  }, [context.submodules?.entries, state.filter]);
34746
+ // Issues + PR triage filtered lists (#882 phase 3). Same memo
34747
+ // pattern as the other promoted views — collapses per-keystroke
34748
+ // filter work to one pass per (data, filter) change.
34749
+ const filteredIssueList = React.useMemo(() => {
34750
+ const all = context.issueList?.issues || [];
34751
+ if (!state.filter)
34752
+ return all;
34753
+ return all.filter((issue) => matchesPromotedFilter([
34754
+ `#${issue.number}`,
34755
+ issue.title,
34756
+ issue.author || '',
34757
+ ...(issue.labels || []),
34758
+ ...(issue.assignees || []),
34759
+ ], state.filter));
34760
+ }, [context.issueList?.issues, state.filter]);
34761
+ const filteredPullRequestTriageList = React.useMemo(() => {
34762
+ const all = context.pullRequestList?.pullRequests || [];
34763
+ if (!state.filter)
34764
+ return all;
34765
+ return all.filter((pr) => matchesPromotedFilter([
34766
+ `#${pr.number}`,
34767
+ pr.title,
34768
+ pr.author || '',
34769
+ pr.headRefName,
34770
+ pr.baseRefName,
34771
+ ...(pr.labels || []),
34772
+ ...(pr.assignees || []),
34773
+ ], state.filter));
34774
+ }, [context.pullRequestList?.pullRequests, state.filter]);
31705
34775
  const dispatch = React.useCallback((action) => {
31706
34776
  setState((current) => applyLogInkAction(current, action));
31707
34777
  }, []);
@@ -32085,6 +35155,149 @@ function LogInkApp(deps) {
32085
35155
  active = false;
32086
35156
  };
32087
35157
  }, [git, state.activeView, context.pullRequest]);
35158
+ // Lazy-load the issue triage list (#882 phase 3, filter-aware
35159
+ // since phase 6). Fires on entry to the view AND on filter
35160
+ // preset changes (`f` cycles the preset; the dep on
35161
+ // `state.selectedIssueFilter` triggers the refetch). The
35162
+ // existing `context.issueList` guard collapses to a no-op when
35163
+ // the preset hasn't changed and data is already loaded.
35164
+ React.useEffect(() => {
35165
+ if (state.activeView !== 'issues')
35166
+ return;
35167
+ if (context.issueList)
35168
+ return;
35169
+ let active = true;
35170
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
35171
+ const filter = issueFilterForPreset(state.selectedIssueFilter);
35172
+ void safe(getIssueList(git, filter)).then((value) => {
35173
+ if (!active)
35174
+ return;
35175
+ setContext((current) => ({
35176
+ ...current,
35177
+ issueList: value,
35178
+ }));
35179
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
35180
+ });
35181
+ return () => {
35182
+ active = false;
35183
+ };
35184
+ }, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
35185
+ // Filter cycling: when the preset changes, drop the cached list
35186
+ // so the effect above re-fires with the new filter. Done as a
35187
+ // separate effect (rather than folded into the cycle reducer)
35188
+ // because the reducer is pure — fs / network side-effects live
35189
+ // in `useEffect`.
35190
+ React.useEffect(() => {
35191
+ if (state.activeView !== 'issues')
35192
+ return;
35193
+ setContext((current) => (current.issueList ? { ...current, issueList: undefined } : current));
35194
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
35195
+ // We deliberately depend ONLY on the preset — not on
35196
+ // activeView — so re-entering the view doesn't re-fire and
35197
+ // discard the just-loaded data. The activeView guard above
35198
+ // keeps us from clearing data while the user is on a
35199
+ // different surface.
35200
+ }, [state.selectedIssueFilter]);
35201
+ // Lazy-load the PR triage list (#882 phase 3, filter-aware
35202
+ // since phase 6). Same pattern as the issue effect above.
35203
+ React.useEffect(() => {
35204
+ if (state.activeView !== 'pull-request-triage')
35205
+ return;
35206
+ if (context.pullRequestList)
35207
+ return;
35208
+ let active = true;
35209
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
35210
+ const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
35211
+ void safe(getPullRequestList(git, filter)).then((value) => {
35212
+ if (!active)
35213
+ return;
35214
+ setContext((current) => ({
35215
+ ...current,
35216
+ pullRequestList: value,
35217
+ }));
35218
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
35219
+ });
35220
+ return () => {
35221
+ active = false;
35222
+ };
35223
+ }, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
35224
+ React.useEffect(() => {
35225
+ if (state.activeView !== 'pull-request-triage')
35226
+ return;
35227
+ setContext((current) => current.pullRequestList ? { ...current, pullRequestList: undefined } : current);
35228
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
35229
+ }, [state.selectedPullRequestFilter]);
35230
+ // Per-item inspector hydration (#882 follow-up to phase 6). When
35231
+ // the user rests the cursor on an issue / PR row for ~250ms, fetch
35232
+ // the body + comments (+ reviews + status checks for PRs) and
35233
+ // cache the result keyed by number. Cursoring back to a previously-
35234
+ // fetched item shows the cached entry instantly; rapid j/k
35235
+ // navigation never fires a `gh` call because the debounce timer
35236
+ // resets on every cursor move.
35237
+ //
35238
+ // The cache lives on `context.{issueDetailByNumber,
35239
+ // pullRequestDetailByNumber}` so it survives the per-keystroke
35240
+ // re-renders. It's intentionally Maps — `new Map(prev).set(k, v)`
35241
+ // keeps the immutable update story simple, and entries persist
35242
+ // until either the list is invalidated (post-mutation) or the
35243
+ // process exits.
35244
+ const DETAIL_HYDRATION_DELAY_MS = 250;
35245
+ React.useEffect(() => {
35246
+ if (state.activeView !== 'issues')
35247
+ return;
35248
+ const cursored = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
35249
+ if (!cursored)
35250
+ return;
35251
+ if (context.issueDetailByNumber?.has(cursored.number))
35252
+ return;
35253
+ let active = true;
35254
+ const timer = setTimeout(async () => {
35255
+ const result = await getIssueDetail(cursored.number);
35256
+ if (!active || !result.ok)
35257
+ return;
35258
+ setContext((current) => ({
35259
+ ...current,
35260
+ issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
35261
+ }));
35262
+ }, DETAIL_HYDRATION_DELAY_MS);
35263
+ return () => {
35264
+ active = false;
35265
+ clearTimeout(timer);
35266
+ };
35267
+ }, [
35268
+ state.activeView,
35269
+ state.selectedIssueIndex,
35270
+ filteredIssueList,
35271
+ context.issueDetailByNumber,
35272
+ ]);
35273
+ React.useEffect(() => {
35274
+ if (state.activeView !== 'pull-request-triage')
35275
+ return;
35276
+ const cursored = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
35277
+ if (!cursored)
35278
+ return;
35279
+ if (context.pullRequestDetailByNumber?.has(cursored.number))
35280
+ return;
35281
+ let active = true;
35282
+ const timer = setTimeout(async () => {
35283
+ const result = await getPullRequestDetail(cursored.number);
35284
+ if (!active || !result.ok)
35285
+ return;
35286
+ setContext((current) => ({
35287
+ ...current,
35288
+ pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
35289
+ }));
35290
+ }, DETAIL_HYDRATION_DELAY_MS);
35291
+ return () => {
35292
+ active = false;
35293
+ clearTimeout(timer);
35294
+ };
35295
+ }, [
35296
+ state.activeView,
35297
+ state.selectedPullRequestTriageIndex,
35298
+ filteredPullRequestTriageList,
35299
+ context.pullRequestDetailByNumber,
35300
+ ]);
32088
35301
  React.useEffect(() => {
32089
35302
  let active = true;
32090
35303
  async function loadDetail() {
@@ -33031,6 +36244,56 @@ function LogInkApp(deps) {
33031
36244
  const effectiveTarget = expectedTarget || target;
33032
36245
  return applyHunkPatch(git, patchText, { target: effectiveTarget });
33033
36246
  };
36247
+ // #882 phase 4 — post-mutation cache invalidation for the
36248
+ // issue / PR triage views. Each helper does two things:
36249
+ // 1. Clears the in-memory `context.issueList` /
36250
+ // `context.pullRequestList` entry so the view's `useEffect`
36251
+ // retriggers on the next render and the user sees their
36252
+ // change reflected immediately.
36253
+ // 2. Wipes the disk cache so a follow-up `coco issues` /
36254
+ // `coco prs` CLI call doesn't serve stale data from the
36255
+ // 5-minute TTL window. Sledgehammer rather than scalpel —
36256
+ // clearing per (repo, filter) tuple would require more
36257
+ // bookkeeping than the cache is worth.
36258
+ const invalidateIssueListCaches = (issueNumber) => {
36259
+ setContext((current) => {
36260
+ const next = { ...current, issueList: undefined };
36261
+ // Drop only the mutated issue's detail entry so other
36262
+ // hydrated entries survive — they're still accurate. When
36263
+ // no number is given (rare), wipe the whole detail map.
36264
+ if (current.issueDetailByNumber) {
36265
+ if (typeof issueNumber === 'number') {
36266
+ const trimmed = new Map(current.issueDetailByNumber);
36267
+ trimmed.delete(issueNumber);
36268
+ next.issueDetailByNumber = trimmed;
36269
+ }
36270
+ else {
36271
+ next.issueDetailByNumber = undefined;
36272
+ }
36273
+ }
36274
+ return next;
36275
+ });
36276
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
36277
+ clearGitHubListCache();
36278
+ };
36279
+ const invalidatePullRequestListCaches = (pullRequestNumber) => {
36280
+ setContext((current) => {
36281
+ const next = { ...current, pullRequestList: undefined };
36282
+ if (current.pullRequestDetailByNumber) {
36283
+ if (typeof pullRequestNumber === 'number') {
36284
+ const trimmed = new Map(current.pullRequestDetailByNumber);
36285
+ trimmed.delete(pullRequestNumber);
36286
+ next.pullRequestDetailByNumber = trimmed;
36287
+ }
36288
+ else {
36289
+ next.pullRequestDetailByNumber = undefined;
36290
+ }
36291
+ }
36292
+ return next;
36293
+ });
36294
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
36295
+ clearGitHubListCache();
36296
+ };
33034
36297
  const handlers = {
33035
36298
  'create-branch': async () => {
33036
36299
  const name = payload?.trim();
@@ -33548,6 +36811,181 @@ function LogInkApp(deps) {
33548
36811
  return { ok: false, message: 'Comment body required' };
33549
36812
  return commentPullRequest(body);
33550
36813
  },
36814
+ // #882 phase 4 — triage-view low-risk mutations. Each picks
36815
+ // the cursored item from the *filtered* list (matching what
36816
+ // the user sees on screen), runs the corresponding `gh` action,
36817
+ // and on success clears both the in-memory context entry and
36818
+ // the disk cache so the next view entry refetches. Comment
36819
+ // is additive; label / assign are toggleable via re-invocation
36820
+ // with --remove-* (deferred to phase 5 as part of the y-confirm
36821
+ // suite). Open / yank don't mutate so they skip the
36822
+ // invalidation step entirely.
36823
+ 'triage-issue-open': async () => {
36824
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36825
+ if (!issue)
36826
+ return { ok: false, message: 'No issue under cursor' };
36827
+ try {
36828
+ await defaultOpenUrlRunner(issue.url);
36829
+ return { ok: true, message: `Opened ${issue.url}` };
36830
+ }
36831
+ catch (error) {
36832
+ return { ok: false, message: error.message };
36833
+ }
36834
+ },
36835
+ 'triage-issue-comment': async () => {
36836
+ const body = payload?.trim();
36837
+ if (!body)
36838
+ return { ok: false, message: 'Comment body required' };
36839
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36840
+ if (!issue)
36841
+ return { ok: false, message: 'No issue under cursor' };
36842
+ const result = await commentIssue(issue.number, body);
36843
+ if (result.ok)
36844
+ invalidateIssueListCaches(issue.number);
36845
+ return result;
36846
+ },
36847
+ 'triage-issue-label': async () => {
36848
+ const label = payload?.trim();
36849
+ if (!label)
36850
+ return { ok: false, message: 'Label name required' };
36851
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36852
+ if (!issue)
36853
+ return { ok: false, message: 'No issue under cursor' };
36854
+ const result = await addIssueLabel(issue.number, label);
36855
+ if (result.ok)
36856
+ invalidateIssueListCaches(issue.number);
36857
+ return result;
36858
+ },
36859
+ 'triage-issue-assign': async () => {
36860
+ const assignee = payload?.trim();
36861
+ if (!assignee)
36862
+ return { ok: false, message: 'Assignee login required' };
36863
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36864
+ if (!issue)
36865
+ return { ok: false, message: 'No issue under cursor' };
36866
+ const result = await addIssueAssignee(issue.number, assignee);
36867
+ if (result.ok)
36868
+ invalidateIssueListCaches(issue.number);
36869
+ return result;
36870
+ },
36871
+ 'triage-pr-open': async () => {
36872
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36873
+ if (!pr)
36874
+ return { ok: false, message: 'No pull request under cursor' };
36875
+ try {
36876
+ await defaultOpenUrlRunner(pr.url);
36877
+ return { ok: true, message: `Opened ${pr.url}` };
36878
+ }
36879
+ catch (error) {
36880
+ return { ok: false, message: error.message };
36881
+ }
36882
+ },
36883
+ 'triage-pr-comment': async () => {
36884
+ const body = payload?.trim();
36885
+ if (!body)
36886
+ return { ok: false, message: 'Comment body required' };
36887
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36888
+ if (!pr)
36889
+ return { ok: false, message: 'No pull request under cursor' };
36890
+ const result = await commentPullRequestByNumber(pr.number, body);
36891
+ if (result.ok)
36892
+ invalidatePullRequestListCaches(pr.number);
36893
+ return result;
36894
+ },
36895
+ 'triage-pr-label': async () => {
36896
+ const label = payload?.trim();
36897
+ if (!label)
36898
+ return { ok: false, message: 'Label name required' };
36899
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36900
+ if (!pr)
36901
+ return { ok: false, message: 'No pull request under cursor' };
36902
+ const result = await addPullRequestLabel(pr.number, label);
36903
+ if (result.ok)
36904
+ invalidatePullRequestListCaches(pr.number);
36905
+ return result;
36906
+ },
36907
+ 'triage-pr-assign': async () => {
36908
+ const assignee = payload?.trim();
36909
+ if (!assignee)
36910
+ return { ok: false, message: 'Assignee login required' };
36911
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36912
+ if (!pr)
36913
+ return { ok: false, message: 'No pull request under cursor' };
36914
+ const result = await addPullRequestAssignee(pr.number, assignee);
36915
+ if (result.ok)
36916
+ invalidatePullRequestListCaches(pr.number);
36917
+ return result;
36918
+ },
36919
+ // #882 phase 5 — destructive triage mutations. Each is gated
36920
+ // through the y-confirm path so the user sees a prompt before
36921
+ // anything ships. The runner reads the cursored item from the
36922
+ // filtered list at confirm-time; the cursor can't move while
36923
+ // the confirmation overlay is up so there's no stale-target
36924
+ // window. Cache invalidation matches the phase-4 pattern.
36925
+ 'triage-issue-close': async () => {
36926
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36927
+ if (!issue)
36928
+ return { ok: false, message: 'No issue under cursor' };
36929
+ const result = await closeIssue(issue.number);
36930
+ if (result.ok)
36931
+ invalidateIssueListCaches(issue.number);
36932
+ return result;
36933
+ },
36934
+ 'triage-issue-reopen': async () => {
36935
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36936
+ if (!issue)
36937
+ return { ok: false, message: 'No issue under cursor' };
36938
+ const result = await reopenIssue(issue.number);
36939
+ if (result.ok)
36940
+ invalidateIssueListCaches(issue.number);
36941
+ return result;
36942
+ },
36943
+ 'triage-pr-merge': async () => {
36944
+ const strategy = payload?.trim();
36945
+ if (!strategy || !isPullRequestMergeStrategy(strategy)) {
36946
+ return {
36947
+ ok: false,
36948
+ message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.`,
36949
+ };
36950
+ }
36951
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36952
+ if (!pr)
36953
+ return { ok: false, message: 'No pull request under cursor' };
36954
+ const result = await mergePullRequestByNumber(pr.number, strategy);
36955
+ if (result.ok)
36956
+ invalidatePullRequestListCaches(pr.number);
36957
+ return result;
36958
+ },
36959
+ 'triage-pr-close': async () => {
36960
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36961
+ if (!pr)
36962
+ return { ok: false, message: 'No pull request under cursor' };
36963
+ const result = await closePullRequestByNumber(pr.number);
36964
+ if (result.ok)
36965
+ invalidatePullRequestListCaches(pr.number);
36966
+ return result;
36967
+ },
36968
+ 'triage-pr-approve': async () => {
36969
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36970
+ if (!pr)
36971
+ return { ok: false, message: 'No pull request under cursor' };
36972
+ const result = await approvePullRequestByNumber(pr.number);
36973
+ if (result.ok)
36974
+ invalidatePullRequestListCaches(pr.number);
36975
+ return result;
36976
+ },
36977
+ 'triage-pr-request-changes': async () => {
36978
+ const body = payload?.trim();
36979
+ if (!body)
36980
+ return { ok: false, message: 'Review body required for change-request' };
36981
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36982
+ if (!pr)
36983
+ return { ok: false, message: 'No pull request under cursor' };
36984
+ const result = await requestChangesPullRequestByNumber(pr.number, body);
36985
+ if (result.ok)
36986
+ invalidatePullRequestListCaches(pr.number);
36987
+ return result;
36988
+ },
33551
36989
  // Status surface group-level batch ops (#791 follow-up). The
33552
36990
  // input handler dispatches these when the user presses Enter on a
33553
36991
  // group header. We re-derive the file list from the live
@@ -33703,6 +37141,29 @@ function LogInkApp(deps) {
33703
37141
  }
33704
37142
  }
33705
37143
  }
37144
+ else if (view === 'issues') {
37145
+ // #882 phase 4 — y yanks the cursored issue's URL so the user
37146
+ // can paste it into Slack / a PR description / etc. without
37147
+ // dropping back to the browser. Short form (`Y`) is a no-op
37148
+ // here — there's no compact identifier worth a second key.
37149
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
37150
+ if (issue) {
37151
+ value = issue.url;
37152
+ label = `issue #${issue.number} URL`;
37153
+ }
37154
+ }
37155
+ else if (view === 'pull-request-triage') {
37156
+ // #882 phase 4 — same URL-yank pattern for the multi-PR list.
37157
+ // Distinct from `pull-request` (single, current-branch); that
37158
+ // view falls through to the generic "Nothing to yank" path
37159
+ // below since the action panel already exposes O for browser
37160
+ // open.
37161
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
37162
+ if (pr) {
37163
+ value = pr.url;
37164
+ label = `pull request #${pr.number} URL`;
37165
+ }
37166
+ }
33706
37167
  else if (view === 'bisect') {
33707
37168
  // #879 item 3 — yank the first-bad commit sha from the
33708
37169
  // completion panel. The headline answer is what the user
@@ -33773,6 +37234,8 @@ function LogInkApp(deps) {
33773
37234
  context.submodules,
33774
37235
  context.tags,
33775
37236
  dispatch,
37237
+ filteredIssueList,
37238
+ filteredPullRequestTriageList,
33776
37239
  selected,
33777
37240
  selectedDetailFile,
33778
37241
  stashDiffLines,
@@ -33785,6 +37248,8 @@ function LogInkApp(deps) {
33785
37248
  state.filteredCommits,
33786
37249
  state.selectedBranchIndex,
33787
37250
  state.selectedIndex,
37251
+ state.selectedIssueIndex,
37252
+ state.selectedPullRequestTriageIndex,
33788
37253
  state.selectedStashIndex,
33789
37254
  state.selectedSubmoduleIndex,
33790
37255
  state.selectedTagIndex,
@@ -34005,6 +37470,10 @@ function LogInkApp(deps) {
34005
37470
  const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
34006
37471
  const submoduleVisibleCount = filteredSubmoduleList.length;
34007
37472
  filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
37473
+ const issueVisibleCount = filteredIssueList.length;
37474
+ const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
37475
+ const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
37476
+ const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
34008
37477
  const worktreeVisibleCount = filteredWorktreeList.length;
34009
37478
  // When the diff view is showing a stash patch, swap the previewLineCount
34010
37479
  // to the stash diff length so the existing pageDetailPreview path
@@ -34036,6 +37505,10 @@ function LogInkApp(deps) {
34036
37505
  reflogCount: reflogVisibleCount,
34037
37506
  reflogSelectedHash,
34038
37507
  submoduleCount: submoduleVisibleCount,
37508
+ issueCount: issueVisibleCount,
37509
+ issueSelectedUrl,
37510
+ pullRequestTriageCount: pullRequestTriageVisibleCount,
37511
+ pullRequestTriageSelectedUrl,
34039
37512
  stashSelectedRef,
34040
37513
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
34041
37514
  stashDiffSelectedPath,
@@ -34203,7 +37676,7 @@ function LogInkApp(deps) {
34203
37676
  if (showOnboarding) {
34204
37677
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
34205
37678
  }
34206
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
37679
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed)), renderFooter(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
34207
37680
  }
34208
37681
 
34209
37682
  /**
@@ -34512,6 +37985,9 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
34512
37985
  appLabel: options.appLabel || 'coco log',
34513
37986
  git,
34514
37987
  idleTipsEnabled: Boolean(options.idleTips),
37988
+ // Resolve undefined → true so the default flips on automatically.
37989
+ // An explicit `false` from config opts out.
37990
+ dateBucketingEnabled: options.dateBucketing !== false,
34515
37991
  ink,
34516
37992
  initialView: options.initialView || 'history',
34517
37993
  logArgv: options.logArgv,
@@ -34676,6 +38152,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
34676
38152
  await startInkInteractiveLog(git, initialRows, {}, {
34677
38153
  appLabel: 'coco',
34678
38154
  idleTips: config.logTui?.idleTips,
38155
+ dateBucketing: config.logTui?.dateBucketing,
34679
38156
  initialView: 'history',
34680
38157
  loadRows,
34681
38158
  logArgv,
@@ -34697,13 +38174,14 @@ async function startCocoUi(argv) {
34697
38174
  await startInkInteractiveLog(git, cachedRows || [], {}, {
34698
38175
  appLabel: 'coco',
34699
38176
  idleTips: config.logTui?.idleTips,
38177
+ dateBucketing: config.logTui?.dateBucketing,
34700
38178
  initialView: argv.view || 'history',
34701
38179
  loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
34702
38180
  logArgv,
34703
38181
  theme: createUiTheme(config, argv),
34704
38182
  });
34705
38183
  }
34706
- const handler$3 = async (argv) => {
38184
+ const handler$4 = async (argv) => {
34707
38185
  await startCocoUi(argv);
34708
38186
  };
34709
38187
 
@@ -34796,7 +38274,7 @@ function formatCommitDetail(detail, format) {
34796
38274
  ].join('\n');
34797
38275
  }
34798
38276
 
34799
- const handler$2 = async (argv) => {
38277
+ const handler$3 = async (argv) => {
34800
38278
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
34801
38279
  // shared helper. After this returns, `process.cwd()` and the git
34802
38280
  // instance are both bound to the targeted repo.
@@ -34831,11 +38309,169 @@ const handler$2 = async (argv) => {
34831
38309
  };
34832
38310
 
34833
38311
  var log = {
34834
- command: command$3,
38312
+ command: command$4,
34835
38313
  desc: 'Explore commit history with a branch graph, filters, and commit details.',
38314
+ builder: builder$4,
38315
+ handler: commandExecutor(handler$3),
38316
+ options: options$4,
38317
+ };
38318
+
38319
+ const command$3 = 'prs';
38320
+ const options$3 = {
38321
+ state: {
38322
+ type: 'string',
38323
+ choices: ['open', 'closed', 'merged', 'all'],
38324
+ description: 'Filter by PR state.',
38325
+ default: 'open',
38326
+ },
38327
+ assignee: {
38328
+ type: 'string',
38329
+ description: 'Filter by assignee GitHub login (or `@me`).',
38330
+ },
38331
+ author: {
38332
+ type: 'string',
38333
+ description: 'Filter by author GitHub login.',
38334
+ },
38335
+ label: {
38336
+ type: 'string',
38337
+ description: 'Filter by label name (comma-separated for AND).',
38338
+ },
38339
+ search: {
38340
+ type: 'string',
38341
+ description: 'Free-form GitHub PR search query.',
38342
+ },
38343
+ base: {
38344
+ type: 'string',
38345
+ description: 'Filter to PRs targeting a specific base branch.',
38346
+ },
38347
+ head: {
38348
+ type: 'string',
38349
+ description: 'Filter to PRs originating from a specific head branch.',
38350
+ },
38351
+ draft: {
38352
+ type: 'boolean',
38353
+ description: 'Limit to draft PRs only.',
38354
+ default: false,
38355
+ },
38356
+ mine: {
38357
+ type: 'boolean',
38358
+ description: 'Shorthand for `--assignee @me`.',
38359
+ default: false,
38360
+ },
38361
+ limit: {
38362
+ type: 'number',
38363
+ description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
38364
+ },
38365
+ json: {
38366
+ type: 'boolean',
38367
+ description: 'Print machine-readable JSON instead of a formatted table.',
38368
+ default: false,
38369
+ },
38370
+ refresh: {
38371
+ type: 'boolean',
38372
+ description: 'Force fresh `gh` call (writes through to cache).',
38373
+ default: false,
38374
+ },
38375
+ 'no-cache': {
38376
+ type: 'boolean',
38377
+ description: 'Skip the disk cache entirely (no read, no write).',
38378
+ default: false,
38379
+ },
38380
+ };
38381
+ const builder$3 = (yargs) => {
38382
+ return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
38383
+ };
38384
+
38385
+ const handler$2 = async (argv, logger) => {
38386
+ const git = applyRepoFlag(argv);
38387
+ const repoPath = process.cwd();
38388
+ const filter = {
38389
+ state: argv.state,
38390
+ assignee: argv.mine ? '@me' : argv.assignee,
38391
+ author: argv.author,
38392
+ label: argv.label,
38393
+ search: argv.search,
38394
+ base: argv.base,
38395
+ head: argv.head,
38396
+ draft: argv.draft,
38397
+ limit: argv.limit,
38398
+ };
38399
+ const cacheEnabled = !argv.noCache;
38400
+ let prs;
38401
+ let fromCache = false;
38402
+ let cacheAgeMs;
38403
+ const repository = await getGitHubRepository(git);
38404
+ if (cacheEnabled && !argv.refresh) {
38405
+ const cached = readCachedList('prs', repoPath, filter);
38406
+ if (cached?.fresh) {
38407
+ prs = cached.payload.items;
38408
+ fromCache = true;
38409
+ cacheAgeMs = cached.ageMs;
38410
+ }
38411
+ }
38412
+ if (!prs) {
38413
+ const overview = await getPullRequestList(git, filter);
38414
+ if (!overview.available) {
38415
+ logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
38416
+ commandExit(1);
38417
+ return;
38418
+ }
38419
+ if (!overview.authenticated) {
38420
+ logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
38421
+ logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable PR triage.'));
38422
+ commandExit(1);
38423
+ return;
38424
+ }
38425
+ if (overview.message) {
38426
+ logger.log(chalk.red(overview.message));
38427
+ commandExit(1);
38428
+ return;
38429
+ }
38430
+ prs = overview.pullRequests || [];
38431
+ if (cacheEnabled) {
38432
+ writeCachedList(repoPath, filter, { kind: 'prs', items: prs });
38433
+ }
38434
+ }
38435
+ if (argv.json) {
38436
+ logger.log(JSON.stringify(prs, null, 2));
38437
+ return;
38438
+ }
38439
+ if (repository) {
38440
+ const filterParts = [];
38441
+ if (filter.state && filter.state !== 'open')
38442
+ filterParts.push(`state=${filter.state}`);
38443
+ if (filter.assignee)
38444
+ filterParts.push(`assignee=${filter.assignee}`);
38445
+ if (filter.author)
38446
+ filterParts.push(`author=${filter.author}`);
38447
+ if (filter.label)
38448
+ filterParts.push(`label=${filter.label}`);
38449
+ if (filter.search)
38450
+ filterParts.push(`search=${JSON.stringify(filter.search)}`);
38451
+ if (filter.base)
38452
+ filterParts.push(`base=${filter.base}`);
38453
+ if (filter.head)
38454
+ filterParts.push(`head=${filter.head}`);
38455
+ if (filter.draft)
38456
+ filterParts.push('draft');
38457
+ const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
38458
+ const cacheTag = fromCache && typeof cacheAgeMs === 'number'
38459
+ ? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
38460
+ : '';
38461
+ logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
38462
+ chalk.dim(` · ${prs.length} pull request${prs.length === 1 ? '' : 's'}`) +
38463
+ suffix +
38464
+ cacheTag);
38465
+ logger.log('');
38466
+ }
38467
+ logger.log(formatPullRequestList(prs));
38468
+ };
38469
+
38470
+ var prs = {
38471
+ command: command$3,
38472
+ desc: 'List GitHub pull requests for the current repository (read-only triage)',
34836
38473
  builder: builder$3,
34837
38474
  handler: commandExecutor(handler$2),
34838
- options: options$3,
34839
38475
  };
34840
38476
 
34841
38477
  const RecapLlmResponseSchema = objectType({
@@ -35737,7 +39373,7 @@ var ui = {
35737
39373
  command,
35738
39374
  desc: 'Open the Coco Git workstation TUI.',
35739
39375
  builder,
35740
- handler: commandExecutor(handler$3),
39376
+ handler: commandExecutor(handler$4),
35741
39377
  options,
35742
39378
  };
35743
39379
 
@@ -35768,6 +39404,8 @@ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
35768
39404
  y.command(log.command, log.desc, log.builder, log.handler);
35769
39405
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
35770
39406
  y.command(cache.command, cache.desc, cache.builder, cache.handler);
39407
+ y.command(issues.command, issues.desc, issues.builder, issues.handler);
39408
+ y.command(prs.command, prs.desc, prs.builder, prs.handler);
35771
39409
  async function main() {
35772
39410
  await runPrefetchFromEnv();
35773
39411
  y.help().parse(process.argv.slice(2));
@@ -36231,7 +39869,9 @@ exports.changelog = changelog;
36231
39869
  exports.commit = commit;
36232
39870
  exports.doctor = doctor;
36233
39871
  exports.init = init;
39872
+ exports.issues = issues;
36234
39873
  exports.log = log;
39874
+ exports.prs = prs;
36235
39875
  exports.recap = recap;
36236
39876
  exports.types = types;
36237
39877
  exports.ui = ui;