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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.50.0";
64
+ const BUILD_VERSION = "0.51.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -128,11 +128,11 @@ function removeUndefined(obj) {
128
128
  return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
129
129
  }
130
130
 
131
- const dynamicImport$2 = new Function('specifier', 'return import(specifier)');
131
+ const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
132
132
  let promptsPromise;
133
133
  function loadInquirerPrompts() {
134
134
  if (!promptsPromise) {
135
- promptsPromise = dynamicImport$2('@inquirer/prompts');
135
+ promptsPromise = dynamicImport$1('@inquirer/prompts');
136
136
  }
137
137
  return promptsPromise;
138
138
  }
@@ -1123,6 +1123,11 @@ const schema$1 = {
1123
1123
  "idleTips": {
1124
1124
  "type": "boolean",
1125
1125
  "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."
1126
+ },
1127
+ "dateBucketing": {
1128
+ "type": "boolean",
1129
+ "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.",
1130
+ "default": true
1126
1131
  }
1127
1132
  },
1128
1133
  "additionalProperties": false,
@@ -1579,6 +1584,9 @@ const schema$1 = {
1579
1584
  "commit": {
1580
1585
  "$ref": "#/definitions/LLMModel"
1581
1586
  },
1587
+ "commitSplit": {
1588
+ "$ref": "#/definitions/LLMModel"
1589
+ },
1582
1590
  "changelog": {
1583
1591
  "$ref": "#/definitions/LLMModel"
1584
1592
  },
@@ -2488,9 +2496,10 @@ const CACHE_SUBCOMMANDS = [
2488
2496
  'parsers',
2489
2497
  'prefetch',
2490
2498
  'clear-parsers',
2499
+ 'clear-github',
2491
2500
  ];
2492
- const command$8 = 'cache <subcommand> [languages..]';
2493
- const builder$8 = (yargs) => {
2501
+ const command$a = 'cache <subcommand> [languages..]';
2502
+ const builder$a = (yargs) => {
2494
2503
  return yargs
2495
2504
  .positional('subcommand', {
2496
2505
  describe: 'Cache action to run',
@@ -2502,7 +2511,7 @@ const builder$8 = (yargs) => {
2502
2511
  type: 'string',
2503
2512
  array: true,
2504
2513
  })
2505
- .usage(getCommandUsageHeader(command$8));
2514
+ .usage(getCommandUsageHeader(command$a));
2506
2515
  };
2507
2516
 
2508
2517
  /**
@@ -2952,15 +2961,15 @@ async function runPrefetchFromEnv(options) {
2952
2961
  * cache file under ~500 KB on a typical repo (each entry is a
2953
2962
  * sha256 hash + 200-500-byte summary).
2954
2963
  */
2955
- const CACHE_SCHEMA_VERSION$1 = 1;
2956
- const CACHE_DIR_NAME$1 = 'diff-summaries';
2964
+ const CACHE_SCHEMA_VERSION$2 = 1;
2965
+ const CACHE_DIR_NAME$2 = 'diff-summaries';
2957
2966
  const CACHE_ENTRY_HARD_CAP = 500;
2958
- function resolveCacheDir$4() {
2967
+ function resolveCacheDir$5() {
2959
2968
  const xdg = process.env.XDG_CACHE_HOME;
2960
2969
  if (xdg && xdg.trim().length > 0) {
2961
- return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
2970
+ return path$1.join(xdg, 'coco', CACHE_DIR_NAME$2);
2962
2971
  }
2963
- return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
2972
+ return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$2);
2964
2973
  }
2965
2974
  function repoKey$3(repoPath) {
2966
2975
  // sha256 here is a non-security cache-key derivation — deterministic
@@ -2970,7 +2979,7 @@ function repoKey$3(repoPath) {
2970
2979
  return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
2971
2980
  }
2972
2981
  function getDiffSummaryCachePath(repoPath) {
2973
- return path$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
2982
+ return path$1.join(resolveCacheDir$5(), `summaries.${repoKey$3(repoPath)}.json`);
2974
2983
  }
2975
2984
  /**
2976
2985
  * Build the cache key for a (diff, model, prompt) tuple. sha256
@@ -2987,7 +2996,7 @@ function readEnvelope(filePath) {
2987
2996
  try {
2988
2997
  const raw = fs$1.readFileSync(filePath, 'utf8');
2989
2998
  const parsed = JSON.parse(raw);
2990
- if (parsed.version !== CACHE_SCHEMA_VERSION$1)
2999
+ if (parsed.version !== CACHE_SCHEMA_VERSION$2)
2991
3000
  return undefined;
2992
3001
  if (!parsed.entries || typeof parsed.entries !== 'object')
2993
3002
  return undefined;
@@ -3009,7 +3018,7 @@ function readDiffSummary(repoPath, key) {
3009
3018
  function writeDiffSummary(repoPath, key, entry) {
3010
3019
  const filePath = getDiffSummaryCachePath(repoPath);
3011
3020
  const existing = readEnvelope(filePath) || {
3012
- version: CACHE_SCHEMA_VERSION$1,
3021
+ version: CACHE_SCHEMA_VERSION$2,
3013
3022
  savedAt: new Date().toISOString(),
3014
3023
  entries: {},
3015
3024
  };
@@ -3078,6 +3087,132 @@ function clearDiffSummaryCache(repoPath) {
3078
3087
  }
3079
3088
  }
3080
3089
 
3090
+ /**
3091
+ * Disk-backed cache for `coco issues` / `coco prs` list fetches
3092
+ * (#882 phase 2). Triage is bursty — a user runs `coco issues` a
3093
+ * dozen times in a few minutes, then doesn't touch it for hours —
3094
+ * so a short TTL (default 5 minutes) buys a lot of latency back
3095
+ * without serving stale data outside that window.
3096
+ *
3097
+ * Best-effort, same as `overviewCache.ts`: read failures fall back
3098
+ * to "no cache" (the fetcher does a fresh `gh` call), write failures
3099
+ * are swallowed silently (next call just re-fetches). The cache is
3100
+ * never load-bearing — `gh` is always the source of truth.
3101
+ *
3102
+ * Keying: `{kind}.{repoHash}.{filterHash}.json` where:
3103
+ * - `kind` is `'issues'` or `'prs'` so the two surfaces don't
3104
+ * collide.
3105
+ * - `repoHash` is a stable short hash of the absolute repo path
3106
+ * (same scheme as `overviewCache.ts`).
3107
+ * - `filterHash` is a stable short hash of the canonicalized
3108
+ * filter object so different `--state` / `--assignee` / `--label`
3109
+ * combinations cache independently.
3110
+ *
3111
+ * No PII in filenames; no auth context is hashed; no
3112
+ * collision-resistance against an adversary is required.
3113
+ */
3114
+ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
3115
+ const CACHE_SCHEMA_VERSION$1 = 1;
3116
+ const CACHE_DIR_NAME$1 = 'github';
3117
+ function resolveCacheDir$4() {
3118
+ const xdg = process.env.XDG_CACHE_HOME;
3119
+ if (xdg && xdg.trim().length > 0) {
3120
+ return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
3121
+ }
3122
+ return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
3123
+ }
3124
+ function shortHash$1(input) {
3125
+ // sha1 here is a non-security cache-key derivation — we just need a
3126
+ // deterministic short identifier so two repos / filters at different
3127
+ // values never collide in the cache directory. No PII or auth
3128
+ // context is hashed and no collision-resistance against an adversary
3129
+ // is required.
3130
+ // DevSkim: ignore DS126858
3131
+ return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
3132
+ }
3133
+ /**
3134
+ * Canonicalize the filter object into a stable string before hashing.
3135
+ * Sorts keys + drops undefined entries so equivalent filters
3136
+ * (`{state: 'open'}` and `{state: 'open', limit: undefined}`) hash to
3137
+ * the same key and share cached data.
3138
+ */
3139
+ function canonicalizeFilter(filter) {
3140
+ const entries = Object.entries(filter)
3141
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
3142
+ .sort(([a], [b]) => a.localeCompare(b));
3143
+ return JSON.stringify(entries);
3144
+ }
3145
+ function getCachePath(kind, repoPath, filter) {
3146
+ const repoHash = shortHash$1(repoPath);
3147
+ const filterHash = shortHash$1(canonicalizeFilter(filter));
3148
+ return path$1.join(resolveCacheDir$4(), `${kind}.${repoHash}.${filterHash}.json`);
3149
+ }
3150
+ function readCachedList(kind, repoPath, filter, ttlMs = DEFAULT_CACHE_TTL_MS) {
3151
+ try {
3152
+ const raw = fs$1.readFileSync(getCachePath(kind, repoPath, filter), 'utf8');
3153
+ const parsed = JSON.parse(raw);
3154
+ if (parsed.version !== CACHE_SCHEMA_VERSION$1)
3155
+ return undefined;
3156
+ if (!parsed.payload || parsed.payload.kind !== kind)
3157
+ return undefined;
3158
+ if (!Array.isArray(parsed.payload.items))
3159
+ return undefined;
3160
+ const savedAt = new Date(parsed.savedAt);
3161
+ if (Number.isNaN(savedAt.getTime()))
3162
+ return undefined;
3163
+ const ageMs = Date.now() - savedAt.getTime();
3164
+ return {
3165
+ payload: parsed.payload,
3166
+ savedAt,
3167
+ ageMs,
3168
+ fresh: ageMs < ttlMs,
3169
+ };
3170
+ }
3171
+ catch {
3172
+ return undefined;
3173
+ }
3174
+ }
3175
+ function writeCachedList(repoPath, filter, payload) {
3176
+ const file = getCachePath(payload.kind, repoPath, filter);
3177
+ const envelope = {
3178
+ version: CACHE_SCHEMA_VERSION$1,
3179
+ savedAt: new Date().toISOString(),
3180
+ payload,
3181
+ };
3182
+ try {
3183
+ fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
3184
+ fs$1.writeFileSync(file, JSON.stringify(envelope));
3185
+ }
3186
+ catch {
3187
+ // Best-effort persistence; swallow.
3188
+ }
3189
+ }
3190
+ /**
3191
+ * Drop every cached file under the github cache directory. Used by
3192
+ * `--no-cache` / explicit purge commands. Best-effort: ENOENT on a
3193
+ * never-populated cache directory is treated as success.
3194
+ */
3195
+ function clearGitHubListCache() {
3196
+ const dir = resolveCacheDir$4();
3197
+ let removed = 0;
3198
+ try {
3199
+ const entries = fs$1.readdirSync(dir);
3200
+ for (const entry of entries) {
3201
+ try {
3202
+ fs$1.unlinkSync(path$1.join(dir, entry));
3203
+ removed++;
3204
+ }
3205
+ catch {
3206
+ // Skip individual file failures; keep counting the rest.
3207
+ }
3208
+ }
3209
+ }
3210
+ catch {
3211
+ // Directory missing → nothing to clear, treat as success.
3212
+ }
3213
+ return { removed };
3214
+ }
3215
+
3081
3216
  /**
3082
3217
  * Retrieves a SimpleGit instance for a repository.
3083
3218
  *
@@ -3250,7 +3385,7 @@ async function promptLanguageSelection(logger) {
3250
3385
  });
3251
3386
  return picked;
3252
3387
  }
3253
- const handler$8 = async (argv, logger) => {
3388
+ const handler$a = async (argv, logger) => {
3254
3389
  const subcommand = argv.subcommand;
3255
3390
  const positionalLanguages = (argv.languages || [])
3256
3391
  .map((s) => s.trim())
@@ -3327,6 +3462,16 @@ const handler$8 = async (argv, logger) => {
3327
3462
  }
3328
3463
  return;
3329
3464
  }
3465
+ if (subcommand === 'clear-github') {
3466
+ const result = clearGitHubListCache();
3467
+ if (result.removed === 0) {
3468
+ logger.log(chalk.dim('No GitHub triage cache to clear.'));
3469
+ return;
3470
+ }
3471
+ logger.log(chalk.green(`✓ cleared ${result.removed} cached GitHub triage list${result.removed === 1 ? '' : 's'}`));
3472
+ logger.log(chalk.dim('Cleared from ~/.cache/coco/github/'));
3473
+ return;
3474
+ }
3330
3475
  if (subcommand === 'clear-parsers') {
3331
3476
  const languages = listManifestLanguages();
3332
3477
  let cleared = 0;
@@ -3345,15 +3490,15 @@ const handler$8 = async (argv, logger) => {
3345
3490
  return;
3346
3491
  }
3347
3492
  logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
3348
- logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers'));
3493
+ logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
3349
3494
  process.exitCode = 1;
3350
3495
  };
3351
3496
 
3352
3497
  var cache = {
3353
- command: command$8,
3498
+ command: command$a,
3354
3499
  desc: 'Manage the diff-summary cache (clear, info)',
3355
- builder: builder$8,
3356
- handler: commandExecutor(handler$8),
3500
+ builder: builder$a,
3501
+ handler: commandExecutor(handler$a),
3357
3502
  };
3358
3503
 
3359
3504
  var util;
@@ -7142,11 +7287,11 @@ const ChangelogResponseSchema = objectType({
7142
7287
  title: stringType(),
7143
7288
  content: stringType(),
7144
7289
  });
7145
- const command$7 = 'changelog';
7290
+ const command$9 = 'changelog';
7146
7291
  /**
7147
7292
  * Command line options via yargs
7148
7293
  */
7149
- const options$7 = {
7294
+ const options$9 = {
7150
7295
  range: {
7151
7296
  type: 'string',
7152
7297
  alias: 'r',
@@ -7193,8 +7338,8 @@ const options$7 = {
7193
7338
  description: 'Toggle interactive mode',
7194
7339
  },
7195
7340
  };
7196
- const builder$7 = (yargs) => {
7197
- return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
7341
+ const builder$9 = (yargs) => {
7342
+ return yargs.options(options$9).usage(getCommandUsageHeader(command$9));
7198
7343
  };
7199
7344
 
7200
7345
  /**
@@ -7398,6 +7543,12 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7398
7543
  cost: {
7399
7544
  summarize: 'gpt-4.1-nano',
7400
7545
  commit: 'gpt-4.1-mini',
7546
+ // `commitSplit` floors at mini even in cost mode. The split
7547
+ // planner emits structured JSON with strict cross-group
7548
+ // constraints (files appear exactly once, hunks fully cover or
7549
+ // not at all). Nano-class models fail those constraints often
7550
+ // enough that the cost win is eaten by the 3-retry budget.
7551
+ commitSplit: 'gpt-4.1-mini',
7401
7552
  changelog: 'gpt-4.1-mini',
7402
7553
  review: 'gpt-4.1-mini',
7403
7554
  recap: 'gpt-4.1-nano',
@@ -7407,6 +7558,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7407
7558
  balanced: {
7408
7559
  summarize: 'gpt-4.1-mini',
7409
7560
  commit: 'gpt-4.1-mini',
7561
+ commitSplit: 'gpt-4.1',
7410
7562
  changelog: 'gpt-4.1',
7411
7563
  review: 'gpt-4.1',
7412
7564
  recap: 'gpt-4.1-mini',
@@ -7416,6 +7568,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
7416
7568
  quality: {
7417
7569
  summarize: 'gpt-4.1-mini',
7418
7570
  commit: 'gpt-4.1',
7571
+ commitSplit: 'gpt-4.1',
7419
7572
  changelog: 'gpt-4.1',
7420
7573
  review: 'gpt-4.1',
7421
7574
  recap: 'gpt-4.1',
@@ -7427,6 +7580,8 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7427
7580
  cost: {
7428
7581
  summarize: 'claude-3-5-haiku-latest',
7429
7582
  commit: 'claude-3-5-haiku-latest',
7583
+ // Floor at sonnet — see note on OpenAI commitSplit above.
7584
+ commitSplit: 'claude-3-5-sonnet-latest',
7430
7585
  changelog: 'claude-3-5-sonnet-latest',
7431
7586
  review: 'claude-3-5-sonnet-latest',
7432
7587
  recap: 'claude-3-5-haiku-latest',
@@ -7436,6 +7591,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7436
7591
  balanced: {
7437
7592
  summarize: 'claude-3-5-haiku-latest',
7438
7593
  commit: 'claude-3-5-sonnet-latest',
7594
+ commitSplit: 'claude-3-7-sonnet-latest',
7439
7595
  changelog: 'claude-3-5-sonnet-latest',
7440
7596
  review: 'claude-3-7-sonnet-latest',
7441
7597
  recap: 'claude-3-5-sonnet-latest',
@@ -7445,6 +7601,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
7445
7601
  quality: {
7446
7602
  summarize: 'claude-3-5-sonnet-latest',
7447
7603
  commit: 'claude-3-7-sonnet-latest',
7604
+ commitSplit: 'claude-sonnet-4-0',
7448
7605
  changelog: 'claude-3-7-sonnet-latest',
7449
7606
  review: 'claude-sonnet-4-0',
7450
7607
  recap: 'claude-3-7-sonnet-latest',
@@ -7456,6 +7613,8 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7456
7613
  cost: {
7457
7614
  summarize: 'llama3.2:3b',
7458
7615
  commit: 'llama3.1:8b',
7616
+ // Floor at the coder-tuned 14b — see note on OpenAI commitSplit above.
7617
+ commitSplit: 'qwen2.5-coder:14b',
7459
7618
  changelog: 'llama3.1:8b',
7460
7619
  review: 'qwen2.5-coder:7b',
7461
7620
  recap: 'llama3.2:3b',
@@ -7465,6 +7624,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7465
7624
  balanced: {
7466
7625
  summarize: 'llama3.1:8b',
7467
7626
  commit: 'qwen2.5-coder:14b',
7627
+ commitSplit: 'qwen2.5-coder:32b',
7468
7628
  changelog: 'qwen2.5-coder:14b',
7469
7629
  review: 'qwen2.5-coder:32b',
7470
7630
  recap: 'llama3.1:8b',
@@ -7474,6 +7634,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
7474
7634
  quality: {
7475
7635
  summarize: 'qwen2.5-coder:14b',
7476
7636
  commit: 'qwen2.5-coder:32b',
7637
+ commitSplit: 'qwen2.5-coder:32b',
7477
7638
  changelog: 'qwen2.5-coder:32b',
7478
7639
  review: 'qwen2.5-coder:32b',
7479
7640
  recap: 'qwen2.5-coder:14b',
@@ -7489,6 +7650,7 @@ const DYNAMIC_DEFAULTS = {
7489
7650
  const DYNAMIC_MODEL_TASKS = [
7490
7651
  'summarize',
7491
7652
  'commit',
7653
+ 'commitSplit',
7492
7654
  'changelog',
7493
7655
  'review',
7494
7656
  'recap',
@@ -9202,7 +9364,9 @@ function summarizeRustStructuralDiff(fileDiff) {
9202
9364
  * on every diff — the regex fallback is the correct steady state
9203
9365
  * in that case.
9204
9366
  */
9205
- const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
9367
+ async function loadTreeSitterModule() {
9368
+ return import('web-tree-sitter');
9369
+ }
9206
9370
  /**
9207
9371
  * Locate the bundled .wasm files. Tries the dist layout first (the
9208
9372
  * common case for installed packages), then falls back to the
@@ -9283,7 +9447,7 @@ async function ensureRuntime() {
9283
9447
  return undefined;
9284
9448
  let mod;
9285
9449
  try {
9286
- mod = await dynamicImport$1('web-tree-sitter');
9450
+ mod = await loadTreeSitterModule();
9287
9451
  }
9288
9452
  catch {
9289
9453
  return undefined;
@@ -14034,7 +14198,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
14034
14198
  }
14035
14199
  return results;
14036
14200
  }
14037
- const handler$7 = async (argv, logger) => {
14201
+ const handler$9 = async (argv, logger) => {
14038
14202
  const git = applyRepoFlag(argv);
14039
14203
  const config = loadConfig(argv);
14040
14204
  const key = getApiKeyForModel(config);
@@ -14259,11 +14423,11 @@ const handler$7 = async (argv, logger) => {
14259
14423
  };
14260
14424
 
14261
14425
  var changelog = {
14262
- command: command$7,
14426
+ command: command$9,
14263
14427
  desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
14264
- builder: builder$7,
14265
- handler: commandExecutor(handler$7),
14266
- options: options$7,
14428
+ builder: builder$9,
14429
+ handler: commandExecutor(handler$9),
14430
+ options: options$9,
14267
14431
  };
14268
14432
 
14269
14433
  const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
@@ -14280,11 +14444,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
14280
14444
  body: stringType().describe("Body of the commit message")
14281
14445
  // .max(280, "Body must be 280 characters or less"),
14282
14446
  }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
14283
- const command$6 = 'commit';
14447
+ const command$8 = 'commit';
14284
14448
  /**
14285
14449
  * Command line options via yargs
14286
14450
  */
14287
- const options$6 = {
14451
+ const options$8 = {
14288
14452
  i: {
14289
14453
  alias: 'interactive',
14290
14454
  description: 'Toggle interactive mode',
@@ -14356,8 +14520,8 @@ const options$6 = {
14356
14520
  default: false,
14357
14521
  },
14358
14522
  };
14359
- const builder$6 = (yargs) => {
14360
- return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
14523
+ const builder$8 = (yargs) => {
14524
+ return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
14361
14525
  };
14362
14526
 
14363
14527
  /**
@@ -14971,6 +15135,73 @@ function formatPlanValidationIssuesError(issues) {
14971
15135
  .filter(Boolean)
14972
15136
  .join('; ');
14973
15137
  }
15138
+ /**
15139
+ * Salvage a plan that lists the same file in `files[]` of more than
15140
+ * one group. Weaker models (e.g. `gpt-4.1-nano`) hit this often when
15141
+ * the staged set has many files — they re-assert files across groups
15142
+ * even though the prompt forbids it.
15143
+ *
15144
+ * Recovery: walk groups in plan order, keep the FIRST occurrence of
15145
+ * each file path, drop subsequent occurrences. Plan-order is used
15146
+ * because the LLM tends to put the most thematically-correct
15147
+ * assignment in the first group it considered the file for; later
15148
+ * appearances are usually accidental re-emissions.
15149
+ *
15150
+ * If dropping a duplicate leaves a group with empty `files[]` AND
15151
+ * empty `hunks[]`, `dropEmptyGroups` (run last) filters it out so
15152
+ * the apply path never sees a group with nothing to commit.
15153
+ *
15154
+ * Returns a NEW plan object — original is not mutated.
15155
+ */
15156
+ function rescueDuplicateFiles(plan) {
15157
+ const seen = new Set();
15158
+ let mutated = false;
15159
+ const rescuedGroups = plan.groups.map((group) => {
15160
+ const keptFiles = [];
15161
+ for (const file of group.files || []) {
15162
+ if (seen.has(file)) {
15163
+ mutated = true;
15164
+ continue;
15165
+ }
15166
+ seen.add(file);
15167
+ keptFiles.push(file);
15168
+ }
15169
+ return { ...group, files: keptFiles };
15170
+ });
15171
+ if (!mutated)
15172
+ return plan;
15173
+ return { ...plan, groups: rescuedGroups };
15174
+ }
15175
+ /**
15176
+ * Salvage a plan that lists the same hunk ID in `hunks[]` of more
15177
+ * than one group. Same failure mode as duplicate files but for the
15178
+ * hunk-level assignments.
15179
+ *
15180
+ * Recovery: keep the FIRST occurrence of each hunk ID across groups
15181
+ * (plan order), drop subsequent ones. `dropEmptyGroups` handles any
15182
+ * group left fully empty.
15183
+ *
15184
+ * Returns a NEW plan object — original is not mutated.
15185
+ */
15186
+ function rescueDuplicateHunks(plan) {
15187
+ const seen = new Set();
15188
+ let mutated = false;
15189
+ const rescuedGroups = plan.groups.map((group) => {
15190
+ const keptHunks = [];
15191
+ for (const hunkId of group.hunks || []) {
15192
+ if (seen.has(hunkId)) {
15193
+ mutated = true;
15194
+ continue;
15195
+ }
15196
+ seen.add(hunkId);
15197
+ keptHunks.push(hunkId);
15198
+ }
15199
+ return { ...group, hunks: keptHunks };
15200
+ });
15201
+ if (!mutated)
15202
+ return plan;
15203
+ return { ...plan, groups: rescuedGroups };
15204
+ }
14974
15205
  /**
14975
15206
  * Salvage a plan that references hunk IDs not in the inventory by
14976
15207
  * promoting those hunks to file-level assignments. The LLM commonly
@@ -15234,31 +15465,39 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
15234
15465
  });
15235
15466
  // Rescue passes. Run in order — order matters:
15236
15467
  //
15237
- // 1. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
15468
+ // 1. rescueDuplicateFiles / rescueDuplicateHunks: weak models
15469
+ // (e.g. gpt-4.1-nano) repeatedly re-assert the same file or
15470
+ // hunk across multiple groups. Keep the first occurrence,
15471
+ // drop the rest. Run FIRST so downstream rescues see a
15472
+ // deduplicated plan and don't re-process redundant entries.
15473
+ //
15474
+ // 2. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
15238
15475
  // against an empty inventory (all staged files are new).
15239
15476
  // Promote those to file-level assignments.
15240
15477
  //
15241
- // 2. rescueMixedFiles (#919): LLM commonly puts a file in
15478
+ // 3. rescueMixedFiles (#919): LLM commonly puts a file in
15242
15479
  // `files[]` of group A AND uses its hunks in `hunks[]` of
15243
15480
  // group B. Drop the hunks (the file-level claim is more
15244
15481
  // specific). Must run AFTER phantom-hunk rescue because the
15245
15482
  // rescue itself can create mixed-files situations.
15246
15483
  //
15247
- // 3. rescueMissingFiles (#921): LLM occasionally forgets a
15484
+ // 4. rescueMissingFiles (#921): LLM occasionally forgets a
15248
15485
  // staged file across every group. Append a synthetic "misc"
15249
15486
  // group so the plan covers every staged file.
15250
15487
  //
15251
- // 4. dropEmptyGroups: rescueMixedFiles can leave a group with
15252
- // empty files[] AND empty hunks[] when it had only hunks
15253
- // that got dropped. Apply-time, an empty group means
15254
- // `git commit` with nothing staged, which throws and
15255
- // aborts mid-loop after the up-front `git reset` has
15256
- // already wiped the index. Filter the empty groups out
15257
- // LAST so the apply path can't hit them.
15488
+ // 5. dropEmptyGroups: earlier rescues can leave a group with
15489
+ // empty files[] AND empty hunks[] when their only contents
15490
+ // got dropped. Apply-time, an empty group means `git commit`
15491
+ // with nothing staged, which throws and aborts mid-loop
15492
+ // after the up-front `git reset` has already wiped the
15493
+ // index. Filter the empty groups out LAST so the apply path
15494
+ // can't hit them.
15258
15495
  //
15259
15496
  // All rescues are no-ops when there's nothing to rescue, so
15260
15497
  // running them unconditionally costs nothing on healthy plans.
15261
- const phantomRescued = rescuePhantomHunks(rawPlan, staged, hunkInventory);
15498
+ const dedupedFiles = rescueDuplicateFiles(rawPlan);
15499
+ const dedupedHunks = rescueDuplicateHunks(dedupedFiles);
15500
+ const phantomRescued = rescuePhantomHunks(dedupedHunks, staged, hunkInventory);
15262
15501
  const mixedRescued = rescueMixedFiles(phantomRescued, hunkInventory);
15263
15502
  const missingRescued = rescueMissingFiles(mixedRescued, staged, hunkInventory);
15264
15503
  const plan = dropEmptyGroups(missingRescued);
@@ -15607,7 +15846,7 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
15607
15846
  * and apply would risk drift (small LLM nondeterminism, staged-state
15608
15847
  * changes the user didn't intend, etc.).
15609
15848
  */
15610
- async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, }) {
15849
+ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
15611
15850
  const changes = await getChanges({
15612
15851
  git,
15613
15852
  options: {
@@ -15680,8 +15919,10 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15680
15919
  // the conventional-commits ruleset (or generic style).
15681
15920
  }
15682
15921
  }
15922
+ const resolvedPlanLlm = planLlm ?? llm;
15923
+ const resolvedPlanModel = planService?.model ?? config.service.model;
15683
15924
  const { plan } = await generateValidatedCommitSplitPlan({
15684
- llm,
15925
+ llm: resolvedPlanLlm,
15685
15926
  prompt: COMMIT_SPLIT_PROMPT,
15686
15927
  variables: {
15687
15928
  file_inventory: fileInventory,
@@ -15699,15 +15940,24 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
15699
15940
  metadata: {
15700
15941
  command: 'commit',
15701
15942
  provider: config.service.provider,
15702
- model: String(config.service.model),
15943
+ model: String(resolvedPlanModel),
15703
15944
  conventional: useConventional,
15704
15945
  },
15705
15946
  maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
15706
15947
  });
15707
15948
  return { plan, context: { changes, hunkInventory } };
15708
15949
  }
15709
- async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, }) {
15710
- const result = await prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm });
15950
+ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
15951
+ const result = await prepareCommitSplitPlan({
15952
+ argv,
15953
+ config,
15954
+ git,
15955
+ logger,
15956
+ tokenizer,
15957
+ llm,
15958
+ planLlm,
15959
+ planService,
15960
+ });
15711
15961
  if ('empty' in result) {
15712
15962
  return 'No staged changes found.';
15713
15963
  }
@@ -15726,13 +15976,14 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
15726
15976
  return formatCommitSplitPlan(plan);
15727
15977
  }
15728
15978
 
15729
- const handler$6 = async (argv, logger) => {
15979
+ const handler$8 = async (argv, logger) => {
15730
15980
  const git = applyRepoFlag(argv);
15731
15981
  const config = loadConfig(argv);
15732
15982
  const key = getApiKeyForModel(config);
15733
15983
  const { provider } = getModelAndProviderFromConfig(config);
15734
15984
  const commitService = resolveDynamicService(config, 'commit');
15735
15985
  const summaryService = resolveDynamicService(config, 'summarize');
15986
+ const splitService = resolveDynamicService(config, 'commitSplit');
15736
15987
  const model = commitService.model;
15737
15988
  if (config.service.authentication.type !== 'None' && !key) {
15738
15989
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
@@ -15741,6 +15992,12 @@ const handler$6 = async (argv, logger) => {
15741
15992
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
15742
15993
  const llm = getLlm(provider, model, { ...config, service: commitService });
15743
15994
  const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
15995
+ // The split planner uses a dedicated LLM because its output schema
15996
+ // is far stricter than the regular commit-message path (every staged
15997
+ // file claimed exactly once, no cross-group duplication, hunk-vs-
15998
+ // file mode exclusivity). Weak models fail those constraints often
15999
+ // enough that the `cost` preference floors `commitSplit` at mini.
16000
+ const splitLlm = getLlm(provider, splitService.model, { ...config, service: splitService });
15744
16001
  const INTERACTIVE = argv.interactive || isInteractive(config);
15745
16002
  if (INTERACTIVE) {
15746
16003
  if (!config.hideCocoBanner) {
@@ -15759,6 +16016,7 @@ const handler$6 = async (argv, logger) => {
15759
16016
  color: 'green',
15760
16017
  });
15761
16018
  if (isCommitSplitCommand(argv)) {
16019
+ logger.verbose(`→ split planner: ${provider} (${splitService.model})`, { color: 'green' });
15762
16020
  const splitResult = await handleCommitSplit({
15763
16021
  argv,
15764
16022
  config,
@@ -15766,6 +16024,8 @@ const handler$6 = async (argv, logger) => {
15766
16024
  logger,
15767
16025
  tokenizer,
15768
16026
  llm,
16027
+ planLlm: splitLlm,
16028
+ planService: splitService,
15769
16029
  });
15770
16030
  const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
15771
16031
  await handleResult({
@@ -16191,23 +16451,23 @@ IMPORTANT RULES:
16191
16451
  };
16192
16452
 
16193
16453
  var commit = {
16194
- command: command$6,
16454
+ command: command$8,
16195
16455
  desc: 'Summarize the staged changes in a commit message.',
16196
- builder: builder$6,
16197
- handler: commandExecutor(handler$6),
16198
- options: options$6,
16456
+ builder: builder$8,
16457
+ handler: commandExecutor(handler$8),
16458
+ options: options$8,
16199
16459
  };
16200
16460
 
16201
- const command$5 = 'doctor';
16202
- const options$5 = {
16461
+ const command$7 = 'doctor';
16462
+ const options$7 = {
16203
16463
  fix: {
16204
16464
  description: 'Attempt to auto-fix detected issues and write the updated config',
16205
16465
  type: 'boolean',
16206
16466
  default: false,
16207
16467
  },
16208
16468
  };
16209
- const builder$5 = (yargs) => {
16210
- return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
16469
+ const builder$7 = (yargs) => {
16470
+ return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
16211
16471
  };
16212
16472
 
16213
16473
  /**
@@ -16486,7 +16746,7 @@ function formatSourceInfo(sources) {
16486
16746
  }
16487
16747
  return lines;
16488
16748
  }
16489
- const handler$5 = async (argv, logger) => {
16749
+ const handler$7 = async (argv, logger) => {
16490
16750
  // Honor the global --repo flag so `coco doctor --repo <X>`
16491
16751
  // inspects X's config sources, not the launcher's cwd. The chdir
16492
16752
  // has to happen before loadConfig so `findUp` walks the targeted
@@ -16593,17 +16853,17 @@ const handler$5 = async (argv, logger) => {
16593
16853
  };
16594
16854
 
16595
16855
  var doctor = {
16596
- command: command$5,
16856
+ command: command$7,
16597
16857
  desc: 'Check your coco configuration for common issues and suggest fixes',
16598
- builder: builder$5,
16599
- handler: commandExecutor(handler$5),
16858
+ builder: builder$7,
16859
+ handler: commandExecutor(handler$7),
16600
16860
  };
16601
16861
 
16602
- const command$4 = 'init';
16862
+ const command$6 = 'init';
16603
16863
  /**
16604
16864
  * Command line options via yargs
16605
16865
  */
16606
- const options$4 = {
16866
+ const options$6 = {
16607
16867
  scope: {
16608
16868
  type: 'string',
16609
16869
  description: 'configure coco for the current user or project?',
@@ -16615,8 +16875,8 @@ const options$4 = {
16615
16875
  default: false,
16616
16876
  },
16617
16877
  };
16618
- const builder$4 = (yargs) => {
16619
- return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
16878
+ const builder$6 = (yargs) => {
16879
+ return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
16620
16880
  };
16621
16881
 
16622
16882
  /**
@@ -16971,7 +17231,7 @@ const questions = {
16971
17231
  }),
16972
17232
  };
16973
17233
 
16974
- const handler$4 = async (argv, logger) => {
17234
+ const handler$6 = async (argv, logger) => {
16975
17235
  // Honor the global --repo flag so `coco init --repo <X> --scope project`
16976
17236
  // writes the project config to X, not the launcher's cwd. The
16977
17237
  // chdir has to happen before getProjectConfigFilePath resolves
@@ -17155,15 +17415,417 @@ async function installCommitlintPackages(scope, logger) {
17155
17415
  }
17156
17416
 
17157
17417
  var init = {
17158
- command: command$4,
17418
+ command: command$6,
17159
17419
  desc: 'install & configure coco globally or for the current project',
17160
- builder: builder$4,
17161
- handler: commandExecutor(handler$4),
17162
- options: options$4,
17420
+ builder: builder$6,
17421
+ handler: commandExecutor(handler$6),
17422
+ options: options$6,
17163
17423
  };
17164
17424
 
17165
- const command$3 = 'log';
17166
- const options$3 = {
17425
+ const command$5 = 'issues';
17426
+ const options$5 = {
17427
+ state: {
17428
+ type: 'string',
17429
+ choices: ['open', 'closed', 'all'],
17430
+ description: 'Filter by issue state.',
17431
+ default: 'open',
17432
+ },
17433
+ assignee: {
17434
+ type: 'string',
17435
+ description: 'Filter by assignee GitHub login (or `@me`).',
17436
+ },
17437
+ author: {
17438
+ type: 'string',
17439
+ description: 'Filter by author GitHub login.',
17440
+ },
17441
+ label: {
17442
+ type: 'string',
17443
+ description: 'Filter by label name (comma-separated for AND).',
17444
+ },
17445
+ search: {
17446
+ type: 'string',
17447
+ description: 'Free-form GitHub issue search query.',
17448
+ },
17449
+ mine: {
17450
+ type: 'boolean',
17451
+ description: 'Shorthand for `--assignee @me`.',
17452
+ default: false,
17453
+ },
17454
+ limit: {
17455
+ type: 'number',
17456
+ description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
17457
+ },
17458
+ json: {
17459
+ type: 'boolean',
17460
+ description: 'Print machine-readable JSON instead of a formatted table.',
17461
+ default: false,
17462
+ },
17463
+ refresh: {
17464
+ type: 'boolean',
17465
+ description: 'Force fresh `gh` call (writes through to cache).',
17466
+ default: false,
17467
+ },
17468
+ 'no-cache': {
17469
+ type: 'boolean',
17470
+ description: 'Skip the disk cache entirely (no read, no write).',
17471
+ default: false,
17472
+ },
17473
+ };
17474
+ const builder$5 = (yargs) => {
17475
+ return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
17476
+ };
17477
+
17478
+ const execFileAsync = promisify(execFile);
17479
+ function parseGitHubRemoteUrl$1(url) {
17480
+ const trimmed = url.trim().replace(/\.git$/, '');
17481
+ const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
17482
+ const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
17483
+ const match = sshMatch || httpsMatch;
17484
+ if (!match) {
17485
+ return undefined;
17486
+ }
17487
+ return {
17488
+ owner: match[1],
17489
+ name: match[2],
17490
+ };
17491
+ }
17492
+ async function defaultGhRunner(args) {
17493
+ const result = await execFileAsync('gh', args);
17494
+ return result.stdout;
17495
+ }
17496
+ async function getGitHubRepository(git) {
17497
+ const remotes = await git.getRemotes(true);
17498
+ const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
17499
+ const url = remote?.refs.push || remote?.refs.fetch;
17500
+ return url ? parseGitHubRemoteUrl$1(url) : undefined;
17501
+ }
17502
+ /**
17503
+ * Probe `gh auth status` and return whether the GitHub CLI is
17504
+ * installed AND authenticated. Used by every data fetcher to short-
17505
+ * circuit before issuing real API calls — keeps the failure-mode
17506
+ * messaging consistent ("CLI missing or not authenticated") instead
17507
+ * of leaking through as a generic spawn error.
17508
+ */
17509
+ async function isGhAuthenticated(runner) {
17510
+ try {
17511
+ await runner(['auth', 'status', '--hostname', 'github.com']);
17512
+ return true;
17513
+ }
17514
+ catch {
17515
+ return false;
17516
+ }
17517
+ }
17518
+
17519
+ /**
17520
+ * Pad a (possibly already-colored) string to `width` visible columns.
17521
+ * We can't naively `String#padEnd` after coloring because ANSI escape
17522
+ * codes inflate `.length` past the visible width. The pattern here is
17523
+ * "measure the plain string, color last" — every formatter below
17524
+ * computes column widths from raw values and passes the visible
17525
+ * length explicitly so `padToVisible` only needs to add spaces.
17526
+ */
17527
+ function padToVisible(colored, visibleLength, width) {
17528
+ if (visibleLength >= width)
17529
+ return colored;
17530
+ return colored + ' '.repeat(width - visibleLength);
17531
+ }
17532
+ const STATE_COLORS = {
17533
+ OPEN: chalk.green,
17534
+ CLOSED: chalk.red,
17535
+ MERGED: chalk.magenta,
17536
+ };
17537
+ function colorState(state) {
17538
+ const fn = STATE_COLORS[state.toUpperCase()] || chalk.dim;
17539
+ return fn(state.toLowerCase());
17540
+ }
17541
+ function formatLabels(labels) {
17542
+ if (!labels || labels.length === 0)
17543
+ return '';
17544
+ return chalk.dim(labels.map((l) => `[${l}]`).join(' '));
17545
+ }
17546
+ function formatReviewDecision(decision) {
17547
+ if (!decision)
17548
+ return ' ';
17549
+ switch (decision) {
17550
+ case 'APPROVED':
17551
+ return chalk.green('✓');
17552
+ case 'CHANGES_REQUESTED':
17553
+ return chalk.red('✗');
17554
+ case 'REVIEW_REQUIRED':
17555
+ return chalk.yellow('?');
17556
+ default:
17557
+ return chalk.dim(decision.slice(0, 1));
17558
+ }
17559
+ }
17560
+ function formatMergeable(mergeable, mergeStateStatus) {
17561
+ if (mergeStateStatus === 'CLEAN')
17562
+ return chalk.green('●');
17563
+ if (mergeStateStatus === 'BLOCKED')
17564
+ return chalk.yellow('●');
17565
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
17566
+ return chalk.red('●');
17567
+ if (mergeStateStatus === 'BEHIND')
17568
+ return chalk.cyan('●');
17569
+ if (mergeStateStatus === 'UNSTABLE')
17570
+ return chalk.yellow('●');
17571
+ return chalk.dim('●');
17572
+ }
17573
+ function formatIssueList(items) {
17574
+ if (items.length === 0) {
17575
+ return chalk.dim('No issues match the current filter.');
17576
+ }
17577
+ const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
17578
+ const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
17579
+ const stateWidth = 6;
17580
+ return items
17581
+ .map((issue) => {
17582
+ const numRaw = `#${issue.number}`;
17583
+ const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
17584
+ const stateRaw = issue.state.toLowerCase();
17585
+ const state = padToVisible(colorState(issue.state), stateRaw.length, stateWidth);
17586
+ const authorRaw = issue.author || '';
17587
+ const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
17588
+ const comments = typeof issue.comments === 'number' && issue.comments > 0
17589
+ ? chalk.dim(` ${issue.comments}c`)
17590
+ : '';
17591
+ const labels = formatLabels(issue.labels);
17592
+ const parts = [num, state, author, issue.title];
17593
+ if (labels)
17594
+ parts.push(labels);
17595
+ return parts.join(' ') + comments;
17596
+ })
17597
+ .join('\n');
17598
+ }
17599
+ function formatPullRequestList(items) {
17600
+ if (items.length === 0) {
17601
+ return chalk.dim('No pull requests match the current filter.');
17602
+ }
17603
+ const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
17604
+ const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
17605
+ const headWidth = Math.min(Math.max(...items.map((i) => i.headRefName.length), 1), 28);
17606
+ const stateWidth = 6;
17607
+ return items
17608
+ .map((pr) => {
17609
+ const numRaw = `#${pr.number}`;
17610
+ const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
17611
+ const stateRaw = pr.isDraft ? 'draft' : pr.state.toLowerCase();
17612
+ const stateColored = pr.isDraft ? chalk.dim('draft') : colorState(pr.state);
17613
+ const state = padToVisible(stateColored, stateRaw.length, stateWidth);
17614
+ const mergeable = formatMergeable(pr.mergeable, pr.mergeStateStatus);
17615
+ const review = formatReviewDecision(pr.reviewDecision);
17616
+ const authorRaw = pr.author || '';
17617
+ const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
17618
+ const branchTruncated = pr.headRefName.length > headWidth
17619
+ ? pr.headRefName.slice(0, headWidth - 1) + '…'
17620
+ : pr.headRefName;
17621
+ const branch = padToVisible(chalk.dim(branchTruncated), branchTruncated.length, headWidth);
17622
+ const labels = formatLabels(pr.labels);
17623
+ const parts = [num, state, mergeable, review, author, branch, pr.title];
17624
+ if (labels)
17625
+ parts.push(labels);
17626
+ return parts.join(' ');
17627
+ })
17628
+ .join('\n');
17629
+ }
17630
+
17631
+ /**
17632
+ * `gh issue list --json` field list. Centralized so any future
17633
+ * re-fetch (refresh, cache invalidation) requests the same shape and
17634
+ * the parser can rely on every field being present (even if optional).
17635
+ */
17636
+ const ISSUE_LIST_JSON_FIELDS = [
17637
+ 'number',
17638
+ 'title',
17639
+ 'url',
17640
+ 'state',
17641
+ 'author',
17642
+ 'assignees',
17643
+ 'labels',
17644
+ 'comments',
17645
+ 'createdAt',
17646
+ 'updatedAt',
17647
+ ].join(',');
17648
+ function parseIssueListItems(output) {
17649
+ const trimmed = output.trim();
17650
+ if (!trimmed)
17651
+ return [];
17652
+ const raw = JSON.parse(trimmed);
17653
+ return raw.map((entry) => {
17654
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
17655
+ ? String(entry.author.login)
17656
+ : undefined;
17657
+ const assignees = Array.isArray(entry.assignees)
17658
+ ? entry.assignees
17659
+ .map((a) => (a && 'login' in a ? String(a.login) : ''))
17660
+ .filter(Boolean)
17661
+ : undefined;
17662
+ const labels = Array.isArray(entry.labels)
17663
+ ? entry.labels
17664
+ .map((l) => (l && 'name' in l ? String(l.name) : ''))
17665
+ .filter(Boolean)
17666
+ : undefined;
17667
+ return {
17668
+ number: entry.number,
17669
+ title: String(entry.title || ''),
17670
+ url: String(entry.url || ''),
17671
+ state: String(entry.state || ''),
17672
+ author,
17673
+ assignees,
17674
+ labels,
17675
+ comments: typeof entry.comments === 'number' ? entry.comments : undefined,
17676
+ createdAt: String(entry.createdAt || ''),
17677
+ updatedAt: String(entry.updatedAt || ''),
17678
+ };
17679
+ });
17680
+ }
17681
+ function buildGhArgs$1(filter) {
17682
+ const args = ['issue', 'list', '--json', ISSUE_LIST_JSON_FIELDS];
17683
+ if (filter.state)
17684
+ args.push('--state', filter.state);
17685
+ if (filter.assignee)
17686
+ args.push('--assignee', filter.assignee);
17687
+ if (filter.author)
17688
+ args.push('--author', filter.author);
17689
+ if (filter.label)
17690
+ args.push('--label', filter.label);
17691
+ if (filter.search)
17692
+ args.push('--search', filter.search);
17693
+ if (typeof filter.limit === 'number')
17694
+ args.push('--limit', String(filter.limit));
17695
+ return args;
17696
+ }
17697
+ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
17698
+ const repository = await getGitHubRepository(git);
17699
+ if (!repository) {
17700
+ return {
17701
+ available: false,
17702
+ authenticated: false,
17703
+ filter,
17704
+ message: 'No GitHub remote detected.',
17705
+ };
17706
+ }
17707
+ if (!(await isGhAuthenticated(runner))) {
17708
+ return {
17709
+ available: true,
17710
+ authenticated: false,
17711
+ repository,
17712
+ filter,
17713
+ message: 'GitHub CLI is missing or not authenticated.',
17714
+ };
17715
+ }
17716
+ try {
17717
+ const output = await runner(buildGhArgs$1(filter));
17718
+ return {
17719
+ available: true,
17720
+ authenticated: true,
17721
+ repository,
17722
+ filter,
17723
+ issues: parseIssueListItems(output),
17724
+ };
17725
+ }
17726
+ catch (error) {
17727
+ return {
17728
+ available: true,
17729
+ authenticated: true,
17730
+ repository,
17731
+ filter,
17732
+ message: error instanceof Error ? error.message : 'Failed to fetch issue list.',
17733
+ };
17734
+ }
17735
+ }
17736
+
17737
+ const handler$5 = async (argv, logger) => {
17738
+ const git = applyRepoFlag(argv);
17739
+ // `applyRepoFlag` chdir'd to the repo path (or kept process.cwd
17740
+ // when --repo was omitted), so the cache key derives from a stable
17741
+ // absolute path either way.
17742
+ const repoPath = process.cwd();
17743
+ const filter = {
17744
+ state: argv.state,
17745
+ assignee: argv.mine ? '@me' : argv.assignee,
17746
+ author: argv.author,
17747
+ label: argv.label,
17748
+ search: argv.search,
17749
+ limit: argv.limit,
17750
+ };
17751
+ const cacheEnabled = !argv.noCache;
17752
+ let issues;
17753
+ let fromCache = false;
17754
+ let cacheAgeMs;
17755
+ // Repository metadata is needed for the header in both code paths
17756
+ // (cache hit and fresh fetch). The cache hit path skips
17757
+ // `getIssueList` entirely, so probe it directly here. Cheap — no
17758
+ // network, just a single `git remote` parse.
17759
+ const repository = await getGitHubRepository(git);
17760
+ if (cacheEnabled && !argv.refresh) {
17761
+ const cached = readCachedList('issues', repoPath, filter);
17762
+ if (cached?.fresh) {
17763
+ issues = cached.payload.items;
17764
+ fromCache = true;
17765
+ cacheAgeMs = cached.ageMs;
17766
+ }
17767
+ }
17768
+ if (!issues) {
17769
+ const overview = await getIssueList(git, filter);
17770
+ if (!overview.available) {
17771
+ logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
17772
+ commandExit(1);
17773
+ return;
17774
+ }
17775
+ if (!overview.authenticated) {
17776
+ logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
17777
+ logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable issue triage.'));
17778
+ commandExit(1);
17779
+ return;
17780
+ }
17781
+ if (overview.message) {
17782
+ logger.log(chalk.red(overview.message));
17783
+ commandExit(1);
17784
+ return;
17785
+ }
17786
+ issues = overview.issues || [];
17787
+ if (cacheEnabled) {
17788
+ writeCachedList(repoPath, filter, { kind: 'issues', items: issues });
17789
+ }
17790
+ }
17791
+ if (argv.json) {
17792
+ logger.log(JSON.stringify(issues, null, 2));
17793
+ return;
17794
+ }
17795
+ if (repository) {
17796
+ const filterParts = [];
17797
+ if (filter.state && filter.state !== 'open')
17798
+ filterParts.push(`state=${filter.state}`);
17799
+ if (filter.assignee)
17800
+ filterParts.push(`assignee=${filter.assignee}`);
17801
+ if (filter.author)
17802
+ filterParts.push(`author=${filter.author}`);
17803
+ if (filter.label)
17804
+ filterParts.push(`label=${filter.label}`);
17805
+ if (filter.search)
17806
+ filterParts.push(`search=${JSON.stringify(filter.search)}`);
17807
+ const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
17808
+ const cacheTag = fromCache && typeof cacheAgeMs === 'number'
17809
+ ? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
17810
+ : '';
17811
+ logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
17812
+ chalk.dim(` · ${issues.length} issue${issues.length === 1 ? '' : 's'}`) +
17813
+ suffix +
17814
+ cacheTag);
17815
+ logger.log('');
17816
+ }
17817
+ logger.log(formatIssueList(issues));
17818
+ };
17819
+
17820
+ var issues = {
17821
+ command: command$5,
17822
+ desc: 'List GitHub issues for the current repository (read-only triage)',
17823
+ builder: builder$5,
17824
+ handler: commandExecutor(handler$5),
17825
+ };
17826
+
17827
+ const command$4 = 'log';
17828
+ const options$4 = {
17167
17829
  i: {
17168
17830
  description: 'Open the interactive terminal log UI',
17169
17831
  type: 'boolean',
@@ -17226,8 +17888,8 @@ const options$3 = {
17226
17888
  default: 'compact',
17227
17889
  },
17228
17890
  };
17229
- const builder$3 = (yargs) => {
17230
- return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
17891
+ const builder$4 = (yargs) => {
17892
+ return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
17231
17893
  };
17232
17894
 
17233
17895
  /**
@@ -17801,30 +18463,6 @@ async function getBranchOverview(git) {
17801
18463
  };
17802
18464
  }
17803
18465
 
17804
- const execFileAsync = promisify(execFile);
17805
- function parseGitHubRemoteUrl$1(url) {
17806
- const trimmed = url.trim().replace(/\.git$/, '');
17807
- const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
17808
- const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
17809
- const match = sshMatch || httpsMatch;
17810
- if (!match) {
17811
- return undefined;
17812
- }
17813
- return {
17814
- owner: match[1],
17815
- name: match[2],
17816
- };
17817
- }
17818
- async function defaultGhRunner(args) {
17819
- const result = await execFileAsync('gh', args);
17820
- return result.stdout;
17821
- }
17822
- async function getGitHubRepository(git) {
17823
- const remotes = await git.getRemotes(true);
17824
- const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
17825
- const url = remote?.refs.push || remote?.refs.fetch;
17826
- return url ? parseGitHubRemoteUrl$1(url) : undefined;
17827
- }
17828
18466
  function parsePullRequestInfo(output) {
17829
18467
  const trimmed = output.trim();
17830
18468
  if (!trimmed) {
@@ -17903,10 +18541,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
17903
18541
  message: 'No GitHub remote detected.',
17904
18542
  };
17905
18543
  }
17906
- try {
17907
- await runner(['auth', 'status', '--hostname', 'github.com']);
17908
- }
17909
- catch {
18544
+ if (!(await isGhAuthenticated(runner))) {
17910
18545
  return {
17911
18546
  available: true,
17912
18547
  authenticated: false,
@@ -19783,6 +20418,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19783
20418
  const key = getApiKeyForModel(config);
19784
20419
  const { provider } = getModelAndProviderFromConfig(config);
19785
20420
  const commitService = resolveDynamicService(config, 'commit');
20421
+ const splitService = resolveDynamicService(config, 'commitSplit');
19786
20422
  const model = commitService.model;
19787
20423
  if (config.service.authentication.type !== 'None' && !key) {
19788
20424
  return {
@@ -19793,6 +20429,10 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19793
20429
  try {
19794
20430
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
19795
20431
  const llm = getLlm(provider, model, { ...config, service: commitService });
20432
+ const planLlm = getLlm(provider, splitService.model, {
20433
+ ...config,
20434
+ service: splitService,
20435
+ });
19796
20436
  const result = await prepareCommitSplitPlan({
19797
20437
  argv,
19798
20438
  config,
@@ -19800,6 +20440,8 @@ async function runCommitSplitPlanWorkflow(input = {}) {
19800
20440
  logger,
19801
20441
  tokenizer,
19802
20442
  llm,
20443
+ planLlm,
20444
+ planService: splitService,
19803
20445
  });
19804
20446
  if ('empty' in result) {
19805
20447
  return {
@@ -19941,7 +20583,7 @@ async function runPullRequestBodyWorkflow(input = {}) {
19941
20583
  const argv = createChangelogArgv({ branch: baseBranch });
19942
20584
  let raw = '';
19943
20585
  try {
19944
- raw = await captureStdout(() => handler$7(argv, new Logger({
20586
+ raw = await captureStdout(() => handler$9(argv, new Logger({
19945
20587
  verbose: true,
19946
20588
  silent: false,
19947
20589
  })));
@@ -20004,7 +20646,7 @@ async function runChangelogTextWorkflow(input = {}) {
20004
20646
  const argv = createChangelogArgv(input);
20005
20647
  let raw = '';
20006
20648
  try {
20007
- raw = await captureStdout(() => handler$7(argv, new Logger({
20649
+ raw = await captureStdout(() => handler$9(argv, new Logger({
20008
20650
  verbose: true,
20009
20651
  silent: false,
20010
20652
  })));
@@ -20026,10 +20668,12 @@ async function runChangelogTextWorkflow(input = {}) {
20026
20668
  const LOG_INK_CONTEXT_KEYS = [
20027
20669
  'bisect',
20028
20670
  'branches',
20671
+ 'issueList',
20029
20672
  'lfs',
20030
20673
  'operation',
20031
20674
  'provider',
20032
20675
  'pullRequest',
20676
+ 'pullRequestList',
20033
20677
  'reflog',
20034
20678
  'stashes',
20035
20679
  'submodules',
@@ -20475,6 +21119,60 @@ function getLogInkWorkflowActions() {
20475
21119
  kind: 'normal',
20476
21120
  requiresConfirmation: false,
20477
21121
  },
21122
+ // #882 phase 5 — triage-view destructive verbs. Each routed
21123
+ // through the y-confirm path so single-keystroke `x` / `a` /
21124
+ // `R` / `m` never silently rewrites publicly-visible state.
21125
+ // The runner reads the cursored item from the filtered list
21126
+ // at confirm-time — the cursor can't move while the
21127
+ // confirmation overlay is up, so no stale-target risk.
21128
+ {
21129
+ id: 'triage-issue-close',
21130
+ key: '',
21131
+ label: 'Close issue',
21132
+ description: 'Close the cursored issue on the triage list view.',
21133
+ kind: 'destructive',
21134
+ requiresConfirmation: true,
21135
+ },
21136
+ {
21137
+ id: 'triage-issue-reopen',
21138
+ key: '',
21139
+ label: 'Reopen issue',
21140
+ description: 'Reopen the cursored issue on the triage list view.',
21141
+ kind: 'normal',
21142
+ requiresConfirmation: true,
21143
+ },
21144
+ {
21145
+ id: 'triage-pr-merge',
21146
+ key: '',
21147
+ label: 'Merge pull request',
21148
+ description: 'Merge the cursored pull request on the triage list view (prompts for merge / squash / rebase, then confirms).',
21149
+ kind: 'destructive',
21150
+ requiresConfirmation: true,
21151
+ },
21152
+ {
21153
+ id: 'triage-pr-close',
21154
+ key: '',
21155
+ label: 'Close pull request',
21156
+ description: 'Close the cursored pull request on the triage list view without merging.',
21157
+ kind: 'destructive',
21158
+ requiresConfirmation: true,
21159
+ },
21160
+ {
21161
+ id: 'triage-pr-approve',
21162
+ key: '',
21163
+ label: 'Approve pull request',
21164
+ description: 'Submit an approving review on the cursored pull request.',
21165
+ kind: 'normal',
21166
+ requiresConfirmation: true,
21167
+ },
21168
+ {
21169
+ id: 'triage-pr-request-changes',
21170
+ key: '',
21171
+ label: 'Request changes on pull request',
21172
+ description: 'Submit a change-request review on the cursored pull request (prompts for body, then confirms).',
21173
+ kind: 'normal',
21174
+ requiresConfirmation: true,
21175
+ },
20478
21176
  {
20479
21177
  // Per-view-only: scoped to the history view in inkInput so `R`
20480
21178
  // doesn't fire elsewhere (it's also `R` for rename in branches
@@ -20870,6 +21568,20 @@ const LOG_INK_KEY_BINDINGS = [
20870
21568
  description: 'Push the dedicated pull-request action panel for the current branch.',
20871
21569
  contexts: ['normal'],
20872
21570
  },
21571
+ {
21572
+ id: 'navigatePullRequestTriage',
21573
+ keys: ['gP'],
21574
+ label: 'PR triage',
21575
+ description: 'Push the multi-PR triage list view (#882). Capital P disambiguates from `gp` which targets the single-PR panel for the current branch.',
21576
+ contexts: ['normal'],
21577
+ },
21578
+ {
21579
+ id: 'navigateIssues',
21580
+ keys: ['gi'],
21581
+ label: 'issues',
21582
+ description: 'Push the issue triage list view (#882).',
21583
+ contexts: ['normal'],
21584
+ },
20873
21585
  {
20874
21586
  id: 'navigateConflicts',
20875
21587
  keys: ['gx'],
@@ -21048,6 +21760,8 @@ const GLOBAL_BINDING_IDS = [
21048
21760
  'navigateStash',
21049
21761
  'navigateWorktrees',
21050
21762
  'navigatePullRequest',
21763
+ 'navigatePullRequestTriage',
21764
+ 'navigateIssues',
21051
21765
  'navigateConflicts',
21052
21766
  'navigateReflog',
21053
21767
  'navigateBisect',
@@ -21285,6 +21999,24 @@ function getLogInkFooterHints(options) {
21285
21999
  global: NORMAL_GLOBAL_HINTS,
21286
22000
  };
21287
22001
  }
22002
+ if (options.activeView === 'issues') {
22003
+ return {
22004
+ // #882 phase 4-6 — read + additive mutations + destructive
22005
+ // (gated through y-confirm) + filter cycling. AI summarize
22006
+ // (`I`) deferred to a follow-up.
22007
+ contextual: ['↑/↓ issues', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'x close*', 'X reopen', 'esc back'],
22008
+ global: NORMAL_GLOBAL_HINTS,
22009
+ };
22010
+ }
22011
+ if (options.activeView === 'pull-request-triage') {
22012
+ return {
22013
+ // #882 phase 4-6 — full PR action panel scoped to the triage
22014
+ // list + filter cycling. AI summarize (`I`) deferred to a
22015
+ // follow-up.
22016
+ 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'],
22017
+ global: NORMAL_GLOBAL_HINTS,
22018
+ };
22019
+ }
21288
22020
  if (options.activeView === 'submodules') {
21289
22021
  return {
21290
22022
  contextual: ['↑/↓ entries', 'y yank path', 'Y yank sha', '/ filter', 'esc back'],
@@ -21465,6 +22197,109 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
21465
22197
  .map((entry) => entry.command);
21466
22198
  }
21467
22199
 
22200
+ /**
22201
+ * Canned filter presets for the issue / PR triage TUI views
22202
+ * (#882 phase 6). Each preset compiles to the same shape the
22203
+ * underlying list fetchers (`getIssueList` / `getPullRequestList`)
22204
+ * already accept — there's no new `gh` surface area, just a
22205
+ * curated set of common triage angles surfaced as a single
22206
+ * keystroke (`f` cycles).
22207
+ *
22208
+ * The presets are deliberately *not* a 1:1 mirror across the two
22209
+ * surfaces:
22210
+ *
22211
+ * - Issues have no draft / mergeable concept, so `draft` /
22212
+ * `mergeable` are skipped on the issue list.
22213
+ * - PRs have a `merged` state distinct from `closed`; issues
22214
+ * don't.
22215
+ * - `mine` semantics differ subtly: for issues it tends to
22216
+ * mean "I'm the assignee" (issues are tasks people pick up);
22217
+ * for PRs it means "I'm the author" (PRs are work people
22218
+ * post). The presets bake those in so the user doesn't have
22219
+ * to think about it.
22220
+ */
22221
+ /** Cycle order — must match the keystroke walk on `f`. */
22222
+ const ISSUE_FILTER_PRESETS = [
22223
+ 'open',
22224
+ 'closed',
22225
+ 'mine',
22226
+ 'assigned',
22227
+ ];
22228
+ const PULL_REQUEST_FILTER_PRESETS = [
22229
+ 'open',
22230
+ 'draft',
22231
+ 'mine',
22232
+ 'assigned',
22233
+ 'closed',
22234
+ 'merged',
22235
+ ];
22236
+ const ISSUE_FILTER_LABELS = {
22237
+ open: 'open',
22238
+ closed: 'closed',
22239
+ mine: 'mine (assigned)',
22240
+ assigned: 'assigned to me',
22241
+ };
22242
+ const PULL_REQUEST_FILTER_LABELS = {
22243
+ open: 'open',
22244
+ draft: 'draft',
22245
+ mine: 'mine (authored)',
22246
+ assigned: 'assigned to me',
22247
+ closed: 'closed',
22248
+ merged: 'merged',
22249
+ };
22250
+ /**
22251
+ * Resolve a preset to the filter object the data fetcher accepts.
22252
+ * Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
22253
+ * `getPullRequestList` so unit tests can assert the mapping
22254
+ * independently from the fetch pipeline.
22255
+ */
22256
+ function issueFilterForPreset(preset) {
22257
+ switch (preset) {
22258
+ case 'open':
22259
+ return { state: 'open' };
22260
+ case 'closed':
22261
+ return { state: 'closed' };
22262
+ case 'mine':
22263
+ // Issues are tasks — "mine" is what *I'm working on*, i.e.
22264
+ // assigned to me + still open. Same as `assigned` plus the
22265
+ // open-state filter for ergonomic single-keystroke focus on
22266
+ // the active backlog.
22267
+ return { state: 'open', assignee: '@me' };
22268
+ case 'assigned':
22269
+ return { assignee: '@me' };
22270
+ }
22271
+ }
22272
+ function pullRequestFilterForPreset(preset) {
22273
+ switch (preset) {
22274
+ case 'open':
22275
+ return { state: 'open' };
22276
+ case 'draft':
22277
+ // gh's `--draft` flag implies `--state open`; surface that
22278
+ // explicitly so the canonicalize step doesn't elide it.
22279
+ return { state: 'open', draft: true };
22280
+ case 'mine':
22281
+ // PRs are work — "mine" is what *I authored*. Most useful
22282
+ // when looking at one's own backlog of in-flight PRs.
22283
+ return { state: 'open', author: '@me' };
22284
+ case 'assigned':
22285
+ return { assignee: '@me' };
22286
+ case 'closed':
22287
+ return { state: 'closed' };
22288
+ case 'merged':
22289
+ return { state: 'merged' };
22290
+ }
22291
+ }
22292
+ function cycleIssueFilterPreset(current) {
22293
+ const index = ISSUE_FILTER_PRESETS.indexOf(current);
22294
+ const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
22295
+ return ISSUE_FILTER_PRESETS[next];
22296
+ }
22297
+ function cyclePullRequestFilterPreset(current) {
22298
+ const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
22299
+ const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
22300
+ return PULL_REQUEST_FILTER_PRESETS[next];
22301
+ }
22302
+
21468
22303
  /**
21469
22304
  * Sort modes for the promoted views (P4.2).
21470
22305
  *
@@ -21852,6 +22687,10 @@ function createLogInkState(rows, options = {}) {
21852
22687
  selectedConflictFileIndex: 0,
21853
22688
  selectedReflogIndex: 0,
21854
22689
  selectedSubmoduleIndex: 0,
22690
+ selectedIssueIndex: 0,
22691
+ selectedPullRequestTriageIndex: 0,
22692
+ selectedIssueFilter: 'open',
22693
+ selectedPullRequestFilter: 'open',
21855
22694
  repoStack: [{ label: options.repoLabel || 'root' }],
21856
22695
  branchSort: DEFAULT_BRANCH_SORT_MODE,
21857
22696
  tagSort: DEFAULT_TAG_SORT_MODE,
@@ -22114,6 +22953,36 @@ function applyLogInkAction(state, action) {
22114
22953
  selectedSubmoduleIndex: clampIndex(state.selectedSubmoduleIndex + action.delta, action.count),
22115
22954
  pendingKey: undefined,
22116
22955
  };
22956
+ case 'moveIssue':
22957
+ return {
22958
+ ...state,
22959
+ selectedIssueIndex: clampIndex(state.selectedIssueIndex + action.delta, action.count),
22960
+ pendingKey: undefined,
22961
+ };
22962
+ case 'movePullRequestTriage':
22963
+ return {
22964
+ ...state,
22965
+ selectedPullRequestTriageIndex: clampIndex(state.selectedPullRequestTriageIndex + action.delta, action.count),
22966
+ pendingKey: undefined,
22967
+ };
22968
+ case 'cycleIssueFilter':
22969
+ // Advance the preset, snap the cursor to the top of the
22970
+ // (newly filtered) list — same UX rule as `cycleBranchSort`.
22971
+ // The list refetches on preset change via the effect in
22972
+ // app.ts, so the cursor at 0 lands on whatever was promoted.
22973
+ return {
22974
+ ...state,
22975
+ selectedIssueFilter: cycleIssueFilterPreset(state.selectedIssueFilter),
22976
+ selectedIssueIndex: 0,
22977
+ pendingKey: undefined,
22978
+ };
22979
+ case 'cyclePullRequestTriageFilter':
22980
+ return {
22981
+ ...state,
22982
+ selectedPullRequestFilter: cyclePullRequestFilterPreset(state.selectedPullRequestFilter),
22983
+ selectedPullRequestTriageIndex: 0,
22984
+ pendingKey: undefined,
22985
+ };
22117
22986
  case 'moveWorktreeListEntry':
22118
22987
  return {
22119
22988
  ...state,
@@ -22936,6 +23805,22 @@ function isReflogActionTarget(state) {
22936
23805
  function isSubmodulesActionTarget(state) {
22937
23806
  return state.activeView === 'submodules' && state.focus === 'commits';
22938
23807
  }
23808
+ /**
23809
+ * Issue triage list (#882 phase 3). Same shape as the other promoted
23810
+ * read-only views — j/k move the cursor when the commits pane is
23811
+ * focused on the dedicated view.
23812
+ */
23813
+ function isIssueActionTarget(state) {
23814
+ return state.activeView === 'issues' && state.focus === 'commits';
23815
+ }
23816
+ /**
23817
+ * Pull-request triage list (#882 phase 3). Distinct from the existing
23818
+ * `pull-request` single-PR action panel — this is the multi-PR list
23819
+ * surface and its cursor lives in `selectedPullRequestTriageIndex`.
23820
+ */
23821
+ function isPullRequestTriageActionTarget(state) {
23822
+ return state.activeView === 'pull-request-triage' && state.focus === 'commits';
23823
+ }
22939
23824
  function isWorktreeActionTarget(state) {
22940
23825
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
22941
23826
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
@@ -23085,6 +23970,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
23085
23970
  return [action({ type: 'pushView', value: 'worktrees' })];
23086
23971
  case 'navigatePullRequest':
23087
23972
  return [action({ type: 'pushView', value: 'pull-request' })];
23973
+ case 'navigatePullRequestTriage':
23974
+ return [action({ type: 'pushView', value: 'pull-request-triage' })];
23975
+ case 'navigateIssues':
23976
+ return [action({ type: 'pushView', value: 'issues' })];
23088
23977
  case 'navigateConflicts':
23089
23978
  return [action({ type: 'pushView', value: 'conflicts' })];
23090
23979
  case 'navigateReflog':
@@ -23259,6 +24148,69 @@ function submitInputPrompt(state) {
23259
24148
  action({ type: 'closeInputPrompt' }),
23260
24149
  ];
23261
24150
  }
24151
+ // #882 phase 4 — triage-view mutation prompts. Each kind routes to
24152
+ // its by-number workflow id; the runner reads the cursored item
24153
+ // from state + filtered list and runs the matching `gh` action.
24154
+ if (state.inputPrompt.kind === 'triage-issue-comment') {
24155
+ return [
24156
+ { type: 'runWorkflowAction', id: 'triage-issue-comment', payload: value },
24157
+ action({ type: 'closeInputPrompt' }),
24158
+ ];
24159
+ }
24160
+ if (state.inputPrompt.kind === 'triage-issue-label') {
24161
+ return [
24162
+ { type: 'runWorkflowAction', id: 'triage-issue-label', payload: value },
24163
+ action({ type: 'closeInputPrompt' }),
24164
+ ];
24165
+ }
24166
+ if (state.inputPrompt.kind === 'triage-issue-assign') {
24167
+ return [
24168
+ { type: 'runWorkflowAction', id: 'triage-issue-assign', payload: value },
24169
+ action({ type: 'closeInputPrompt' }),
24170
+ ];
24171
+ }
24172
+ if (state.inputPrompt.kind === 'triage-pr-comment') {
24173
+ return [
24174
+ { type: 'runWorkflowAction', id: 'triage-pr-comment', payload: value },
24175
+ action({ type: 'closeInputPrompt' }),
24176
+ ];
24177
+ }
24178
+ if (state.inputPrompt.kind === 'triage-pr-label') {
24179
+ return [
24180
+ { type: 'runWorkflowAction', id: 'triage-pr-label', payload: value },
24181
+ action({ type: 'closeInputPrompt' }),
24182
+ ];
24183
+ }
24184
+ if (state.inputPrompt.kind === 'triage-pr-assign') {
24185
+ return [
24186
+ { type: 'runWorkflowAction', id: 'triage-pr-assign', payload: value },
24187
+ action({ type: 'closeInputPrompt' }),
24188
+ ];
24189
+ }
24190
+ // #882 phase 5 — destructive prompt submissions route through the
24191
+ // y-confirm path (not directly to runWorkflowAction) so the user
24192
+ // gets a final "are you sure?" before anything ships. The
24193
+ // collected value (strategy / body) rides along as the
24194
+ // confirmation payload.
24195
+ if (state.inputPrompt.kind === 'triage-pr-merge-strategy') {
24196
+ const strategy = value.toLowerCase();
24197
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
24198
+ return [action({
24199
+ type: 'setStatus',
24200
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
24201
+ })];
24202
+ }
24203
+ return [
24204
+ action({ type: 'setPendingConfirmation', value: 'triage-pr-merge', payload: strategy }),
24205
+ action({ type: 'closeInputPrompt' }),
24206
+ ];
24207
+ }
24208
+ if (state.inputPrompt.kind === 'triage-pr-request-changes') {
24209
+ return [
24210
+ action({ type: 'setPendingConfirmation', value: 'triage-pr-request-changes', payload: value }),
24211
+ action({ type: 'closeInputPrompt' }),
24212
+ ];
24213
+ }
23262
24214
  if (state.inputPrompt.kind === 'pr-request-changes') {
23263
24215
  return [
23264
24216
  action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
@@ -23681,6 +24633,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
23681
24633
  action({ type: 'setStatus', value: 'jumped to pull request' }),
23682
24634
  ];
23683
24635
  }
24636
+ // `gP` chord (#882 phase 3): jump to the multi-PR triage list.
24637
+ // Capital P disambiguates from `gp` (current-branch PR panel).
24638
+ // Pleasingly symmetric with `gi` for issues — both lead to the
24639
+ // read-only list views shipped in #882.
24640
+ if (state.pendingKey === 'g' && inputValue === 'P') {
24641
+ return [
24642
+ action({ type: 'pushView', value: 'pull-request-triage' }),
24643
+ action({ type: 'setStatus', value: 'jumped to PR triage' }),
24644
+ ];
24645
+ }
24646
+ // `gi` chord (#882 phase 3): jump to the issue triage list.
24647
+ if (state.pendingKey === 'g' && inputValue === 'i') {
24648
+ return [
24649
+ action({ type: 'pushView', value: 'issues' }),
24650
+ action({ type: 'setStatus', value: 'jumped to issues' }),
24651
+ ];
24652
+ }
23684
24653
  if (state.pendingKey === 'g' && inputValue === 'x') {
23685
24654
  return [
23686
24655
  action({ type: 'pushView', value: 'conflicts' }),
@@ -24119,6 +25088,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24119
25088
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24120
25089
  return [action({ type: 'moveSubmodule', delta: -1, count: context.submoduleCount })];
24121
25090
  }
25091
+ if (isIssueActionTarget(state) && context.issueCount) {
25092
+ return [action({ type: 'moveIssue', delta: -1, count: context.issueCount })];
25093
+ }
25094
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
25095
+ return [action({
25096
+ type: 'movePullRequestTriage',
25097
+ delta: -1,
25098
+ count: context.pullRequestTriageCount,
25099
+ })];
25100
+ }
24122
25101
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
24123
25102
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
24124
25103
  }
@@ -24206,6 +25185,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24206
25185
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24207
25186
  return [action({ type: 'moveSubmodule', delta: 1, count: context.submoduleCount })];
24208
25187
  }
25188
+ if (isIssueActionTarget(state) && context.issueCount) {
25189
+ return [action({ type: 'moveIssue', delta: 1, count: context.issueCount })];
25190
+ }
25191
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
25192
+ return [action({
25193
+ type: 'movePullRequestTriage',
25194
+ delta: 1,
25195
+ count: context.pullRequestTriageCount,
25196
+ })];
25197
+ }
24209
25198
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
24210
25199
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
24211
25200
  }
@@ -24622,6 +25611,120 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24622
25611
  multiline: true,
24623
25612
  })];
24624
25613
  }
25614
+ // #882 phase 4 — issue triage per-row actions. Scoped to the
25615
+ // `'issues'` view + commits focus so the single-letter keys stay
25616
+ // free elsewhere. Each prompts; submit dispatches the matching
25617
+ // `triage-issue-*` workflow which routes through `gh issue` and
25618
+ // invalidates both the in-memory + disk caches on success.
25619
+ if (state.activeView === 'issues' && state.focus === 'commits') {
25620
+ if (inputValue === 'O' && context.issueSelectedUrl) {
25621
+ return [{ type: 'runWorkflowAction', id: 'triage-issue-open' }];
25622
+ }
25623
+ if (inputValue === 'c' && context.issueCount) {
25624
+ return [action({
25625
+ type: 'openInputPrompt',
25626
+ kind: 'triage-issue-comment',
25627
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
25628
+ multiline: true,
25629
+ })];
25630
+ }
25631
+ if (inputValue === 'L' && context.issueCount) {
25632
+ return [action({
25633
+ type: 'openInputPrompt',
25634
+ kind: 'triage-issue-label',
25635
+ label: 'Label name to add',
25636
+ })];
25637
+ }
25638
+ if (inputValue === 'A' && context.issueCount) {
25639
+ return [action({
25640
+ type: 'openInputPrompt',
25641
+ kind: 'triage-issue-assign',
25642
+ label: 'Assignee login (or @me)',
25643
+ initial: '@me',
25644
+ })];
25645
+ }
25646
+ // #882 phase 5 — destructive issue mutations. Both gated through
25647
+ // the y-confirm path. `x` closes (matches `pull-request` view's
25648
+ // close binding); `X` reopens, useful to undo a stray close.
25649
+ if (inputValue === 'x' && context.issueCount) {
25650
+ return [action({ type: 'setPendingConfirmation', value: 'triage-issue-close' })];
25651
+ }
25652
+ if (inputValue === 'X' && context.issueCount) {
25653
+ return [action({ type: 'setPendingConfirmation', value: 'triage-issue-reopen' })];
25654
+ }
25655
+ // #882 phase 6 — cycle the canned filter preset (open → closed
25656
+ // → mine → assigned → open). The effect in app.ts watches
25657
+ // `state.selectedIssueFilter` and refetches with the matching
25658
+ // filter object, so the list updates without an explicit
25659
+ // refresh keystroke.
25660
+ if (inputValue === 'f') {
25661
+ return [action({ type: 'cycleIssueFilter' })];
25662
+ }
25663
+ }
25664
+ // #882 phase 4 — PR triage per-row actions. Same shape as the
25665
+ // issue handlers above; distinct view id so the keys don't
25666
+ // collide with the single-PR action panel (`pull-request`).
25667
+ if (state.activeView === 'pull-request-triage' && state.focus === 'commits') {
25668
+ if (inputValue === 'O' && context.pullRequestTriageSelectedUrl) {
25669
+ return [{ type: 'runWorkflowAction', id: 'triage-pr-open' }];
25670
+ }
25671
+ if (inputValue === 'c' && context.pullRequestTriageCount) {
25672
+ return [action({
25673
+ type: 'openInputPrompt',
25674
+ kind: 'triage-pr-comment',
25675
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
25676
+ multiline: true,
25677
+ })];
25678
+ }
25679
+ if (inputValue === 'L' && context.pullRequestTriageCount) {
25680
+ return [action({
25681
+ type: 'openInputPrompt',
25682
+ kind: 'triage-pr-label',
25683
+ label: 'Label name to add',
25684
+ })];
25685
+ }
25686
+ if (inputValue === 'A' && context.pullRequestTriageCount) {
25687
+ return [action({
25688
+ type: 'openInputPrompt',
25689
+ kind: 'triage-pr-assign',
25690
+ label: 'Assignee login (or @me)',
25691
+ initial: '@me',
25692
+ })];
25693
+ }
25694
+ // #882 phase 5 — destructive PR mutations on the triage view.
25695
+ // Mirror the single-PR action panel's keys (m / x / a / R) but
25696
+ // route to the by-number workflows. `m` and `R` open input
25697
+ // prompts first; submit lands the strategy / body as the
25698
+ // confirmation payload, which the runner picks up after y.
25699
+ if (inputValue === 'm' && context.pullRequestTriageCount) {
25700
+ return [action({
25701
+ type: 'openInputPrompt',
25702
+ kind: 'triage-pr-merge-strategy',
25703
+ label: 'Merge strategy (merge / squash / rebase)',
25704
+ })];
25705
+ }
25706
+ if (inputValue === 'x' && context.pullRequestTriageCount) {
25707
+ return [action({ type: 'setPendingConfirmation', value: 'triage-pr-close' })];
25708
+ }
25709
+ if (inputValue === 'a' && context.pullRequestTriageCount) {
25710
+ return [action({ type: 'setPendingConfirmation', value: 'triage-pr-approve' })];
25711
+ }
25712
+ if (inputValue === 'R' && context.pullRequestTriageCount) {
25713
+ return [action({
25714
+ type: 'openInputPrompt',
25715
+ kind: 'triage-pr-request-changes',
25716
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
25717
+ multiline: true,
25718
+ })];
25719
+ }
25720
+ // #882 phase 6 — cycle the canned filter preset (open → draft
25721
+ // → mine → assigned → closed → merged → open). The effect in
25722
+ // app.ts watches `state.selectedPullRequestFilter` and refetches
25723
+ // with the matching filter object.
25724
+ if (inputValue === 'f') {
25725
+ return [action({ type: 'cyclePullRequestTriageFilter' })];
25726
+ }
25727
+ }
24625
25728
  // Global stash hotkey: `S` opens a stash-message prompt and
24626
25729
  // `createStash` runs once submitted. Available everywhere there's
24627
25730
  // not a more modal handler in front of it.
@@ -24872,6 +25975,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
24872
25975
  if (isSubmodulesActionTarget(state) && context.submoduleCount) {
24873
25976
  return [{ type: 'yankFromActiveView', short }];
24874
25977
  }
25978
+ // #882 phase 4 — triage views: y yanks the cursored issue / PR
25979
+ // URL so the user can paste it into a chat / PR description
25980
+ // without dropping back to the browser. Y is a no-op on these
25981
+ // views — there's no compact alternate identifier worth a
25982
+ // second key.
25983
+ if (isIssueActionTarget(state) && context.issueSelectedUrl) {
25984
+ return [{ type: 'yankFromActiveView' }];
25985
+ }
25986
+ if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageSelectedUrl) {
25987
+ return [{ type: 'yankFromActiveView' }];
25988
+ }
24875
25989
  }
24876
25990
  // Enter on a stash row pushes the diff view scoped to that stash.
24877
25991
  // The runtime loads `git stash show -p <ref>` once the view is
@@ -25381,9 +26495,56 @@ const LOG_INK_DEFAULT_ROWS = 40;
25381
26495
  * gives both views their own air.
25382
26496
  */
25383
26497
  const INSPECTOR_TABBED_BELOW_ROWS = 28;
26498
+ /**
26499
+ * Density-tier breakpoints in columns. Picked so the three legacy
26500
+ * panels (sidebar ~24-32 + inspector ~20-32 + main) still leave the
26501
+ * history panel with at least ~40 usable cells before we start
26502
+ * collapsing chrome:
26503
+ *
26504
+ * wide >= 160 — plenty of room; keep absolute dates
26505
+ * normal >= 120 — relative dates save 8-ish cells without hiding info
26506
+ * tight >= 100 — drop date entirely; subject + refs are the priority
26507
+ * rail < 100 — even with side panels collapsed the row is tight;
26508
+ * stack to two lines and rail the side panels at rest
26509
+ */
26510
+ const LAYOUT_TIGHT_BELOW = 120;
26511
+ const LAYOUT_NORMAL_BELOW = 160;
26512
+ const LAYOUT_RAIL_BELOW = 100;
26513
+ /**
26514
+ * Fixed cell width for a railed side panel. Just wide enough for a
26515
+ * 1-cell icon + a 2-3 digit count after subtracting border (2) and
26516
+ * padding (2). Going narrower clips the count; going wider defeats
26517
+ * the purpose of railing in the first place.
26518
+ */
26519
+ const LAYOUT_RAIL_PANEL_WIDTH = 8;
26520
+ const SIDEBAR_AT_REST_BY_TIER = {
26521
+ rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
26522
+ tight: { min: 22, max: 28, fraction: 0.24 },
26523
+ normal: { min: 22, max: 30, fraction: 0.22 },
26524
+ wide: { min: 28, max: 48, fraction: 0.24 },
26525
+ };
26526
+ function calcSidebarAtRestWidth(columns, density) {
26527
+ const config = SIDEBAR_AT_REST_BY_TIER[density];
26528
+ return Math.max(config.min, Math.min(config.max, Math.floor(columns * config.fraction)));
26529
+ }
25384
26530
  function getLogInkLayout(input) {
25385
26531
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
25386
26532
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
26533
+ const density = columns >= LAYOUT_NORMAL_BELOW
26534
+ ? 'wide'
26535
+ : columns >= LAYOUT_TIGHT_BELOW
26536
+ ? 'normal'
26537
+ : columns >= LAYOUT_RAIL_BELOW
26538
+ ? 'tight'
26539
+ : 'rail';
26540
+ // Rail collapse: only happens at the narrowest tier, and only for
26541
+ // the panel that does NOT currently hold focus AND is not being
26542
+ // commandeered by the help overlay. Focus always wins — pressing
26543
+ // tab to the sidebar pops it back open even on an 80-cell terminal
26544
+ // so the user can actually use it. The help overlay also wins for
26545
+ // the inspector since that's where its descriptions render.
26546
+ const sidebarRailed = density === 'rail' && !input.sidebarFocused;
26547
+ const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
25387
26548
  // Inspector width — at rest 20-32 cells (~22% of width), focused
25388
26549
  // 36-60 cells (~40% of width). Narrow rest state keeps the commit
25389
26550
  // graph dominant; focus expansion gives the inspector room for long
@@ -25394,17 +26555,30 @@ function getLogInkLayout(input) {
25394
26555
  // hotkey descriptions render in full instead of truncating to
25395
26556
  // "Move focus...". Capped at 100 cells so a wide terminal doesn't
25396
26557
  // waste an absurd amount of horizontal space on the cheat sheet.
26558
+ //
26559
+ // Rail collapse wins over the at-rest range but loses to focus and
26560
+ // to the help overlay — both of those represent deliberate user
26561
+ // intent to read the panel.
25397
26562
  const detailWidth = input.helpOverlayActive
25398
26563
  ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
25399
26564
  : input.inspectorFocused
25400
26565
  ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
25401
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
25402
- // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
25403
- // (~36% of width). The transition is instant per render — focus tab to
25404
- // expand, focus away to collapse.
26566
+ : inspectorRailed
26567
+ ? LAYOUT_RAIL_PANEL_WIDTH
26568
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
26569
+ // Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
26570
+ // tight stays compact (22-28), normal shrinks slightly (22-30),
26571
+ // wide grows naturally (28-48) so the side panel doesn't get pinned
26572
+ // at an arbitrary cap on big terminals while the main panel hogs
26573
+ // 80% of the width. Focused: 32-50 cells (~36% of width),
26574
+ // regardless of tier — deliberate user intent to read the sidebar
26575
+ // deserves the extra width. Rail mode (narrow terminal, unfocused)
26576
+ // collapses to a fixed 8-cell strip with tab glyphs only.
25405
26577
  const sidebarWidth = input.sidebarFocused
25406
26578
  ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
25407
- : Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
26579
+ : sidebarRailed
26580
+ ? LAYOUT_RAIL_PANEL_WIDTH
26581
+ : calcSidebarAtRestWidth(columns, density);
25408
26582
  return {
25409
26583
  bodyRows: Math.max(8, rows - 5),
25410
26584
  columns,
@@ -25414,6 +26588,10 @@ function getLogInkLayout(input) {
25414
26588
  sidebarWidth,
25415
26589
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
25416
26590
  inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
26591
+ density,
26592
+ sidebarRailed,
26593
+ inspectorRailed,
26594
+ historyRowMode: density === 'rail' ? 'stacked' : 'single',
25417
26595
  };
25418
26596
  }
25419
26597
 
@@ -26123,6 +27301,382 @@ function stageConflictResolved(git, path) {
26123
27301
  return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
26124
27302
  }
26125
27303
 
27304
+ /**
27305
+ * Per-item issue detail fetcher (#882 inspector hydration). The list
27306
+ * payload from `gh issue list` deliberately omits bodies and
27307
+ * comments to keep the list fetch cheap; this module fills those in
27308
+ * on demand when the user rests the cursor on a specific issue.
27309
+ *
27310
+ * Called from the workstation runtime with a debounced timer so
27311
+ * rapid j/k navigation doesn't spam `gh`. Results land in a
27312
+ * `Map<number, IssueDetail>` cache on `LogInkContext` keyed by
27313
+ * issue number, so cursoring back to a previously-fetched item
27314
+ * shows instantly.
27315
+ */
27316
+ /**
27317
+ * `gh issue view <#> --json` field list. Kept separate from the
27318
+ * list-view field list since the detail view only needs the
27319
+ * fields that the list payload doesn't already carry.
27320
+ */
27321
+ const ISSUE_DETAIL_JSON_FIELDS = ['number', 'body', 'comments'].join(',');
27322
+ function parseIssueComments(value) {
27323
+ if (!Array.isArray(value))
27324
+ return [];
27325
+ return value.map((entry) => {
27326
+ const raw = entry;
27327
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27328
+ ? String(raw.author.login)
27329
+ : undefined;
27330
+ return {
27331
+ author,
27332
+ body: typeof raw.body === 'string' ? raw.body : '',
27333
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
27334
+ };
27335
+ });
27336
+ }
27337
+ function parseIssueDetail(output) {
27338
+ const trimmed = output.trim();
27339
+ if (!trimmed)
27340
+ return undefined;
27341
+ const raw = JSON.parse(trimmed);
27342
+ if (typeof raw.number !== 'number')
27343
+ return undefined;
27344
+ return {
27345
+ number: raw.number,
27346
+ body: typeof raw.body === 'string' ? raw.body : '',
27347
+ comments: parseIssueComments(raw.comments),
27348
+ };
27349
+ }
27350
+ async function getIssueDetail(issueNumber, runner = defaultGhRunner) {
27351
+ try {
27352
+ const output = await runner([
27353
+ 'issue',
27354
+ 'view',
27355
+ String(issueNumber),
27356
+ '--json',
27357
+ ISSUE_DETAIL_JSON_FIELDS,
27358
+ ]);
27359
+ const detail = parseIssueDetail(output);
27360
+ if (!detail) {
27361
+ return { ok: false, message: `Empty response from gh for issue #${issueNumber}` };
27362
+ }
27363
+ return { ok: true, detail };
27364
+ }
27365
+ catch (error) {
27366
+ return {
27367
+ ok: false,
27368
+ message: error instanceof Error ? error.message : String(error),
27369
+ };
27370
+ }
27371
+ }
27372
+
27373
+ /**
27374
+ * Low-risk issue mutations driven from the issue-triage TUI (#882
27375
+ * phase 4). Mirrors `pullRequestActions.ts`'s shape — each function
27376
+ * wraps a single `gh issue <verb>` invocation through the shared
27377
+ * runner indirection so tests can mock the shell-out cleanly.
27378
+ *
27379
+ * "Low risk" here means: reversible by re-invoking with the
27380
+ * opposite flag (`--add-label` ↔ `--remove-label`), or strictly
27381
+ * additive (comment). The destructive verbs (`close`, `reopen`,
27382
+ * `delete`) land in phase 5 alongside the PR-level destructive
27383
+ * mutations, all gated through the y-confirm path.
27384
+ */
27385
+ async function runGhAction$1(runner, args, successMessage) {
27386
+ try {
27387
+ return successMessage(await runner(args));
27388
+ }
27389
+ catch (error) {
27390
+ return {
27391
+ ok: false,
27392
+ message: error.message,
27393
+ };
27394
+ }
27395
+ }
27396
+ function commentIssue(issueNumber, body, runner = defaultGhRunner) {
27397
+ if (!body.trim()) {
27398
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
27399
+ }
27400
+ return runGhAction$1(runner, ['issue', 'comment', String(issueNumber), '--body', body], (output) => ({
27401
+ ok: true,
27402
+ message: output.trim() || `Commented on issue #${issueNumber}`,
27403
+ }));
27404
+ }
27405
+ function addIssueLabel(issueNumber, label, runner = defaultGhRunner) {
27406
+ if (!label.trim()) {
27407
+ return Promise.resolve({ ok: false, message: 'Label name required' });
27408
+ }
27409
+ return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-label', label], () => ({
27410
+ ok: true,
27411
+ message: `Added label '${label}' to issue #${issueNumber}`,
27412
+ }));
27413
+ }
27414
+ function addIssueAssignee(issueNumber, assignee, runner = defaultGhRunner) {
27415
+ if (!assignee.trim()) {
27416
+ return Promise.resolve({ ok: false, message: 'Assignee login required' });
27417
+ }
27418
+ return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-assignee', assignee], () => ({
27419
+ ok: true,
27420
+ message: `Assigned ${assignee} to issue #${issueNumber}`,
27421
+ }));
27422
+ }
27423
+ /**
27424
+ * Destructive issue verbs (#882 phase 5). Both routed through the
27425
+ * y-confirm path in the workstation; the action functions themselves
27426
+ * make no extra guarantee — every gh-side error surfaces via the
27427
+ * standard `runGhAction` error wrapper.
27428
+ */
27429
+ function closeIssue(issueNumber, runner = defaultGhRunner) {
27430
+ return runGhAction$1(runner, ['issue', 'close', String(issueNumber)], (output) => ({
27431
+ ok: true,
27432
+ message: output.trim() || `Closed issue #${issueNumber}`,
27433
+ }));
27434
+ }
27435
+ function reopenIssue(issueNumber, runner = defaultGhRunner) {
27436
+ return runGhAction$1(runner, ['issue', 'reopen', String(issueNumber)], (output) => ({
27437
+ ok: true,
27438
+ message: output.trim() || `Reopened issue #${issueNumber}`,
27439
+ }));
27440
+ }
27441
+
27442
+ /**
27443
+ * Per-item PR detail fetcher (#882 inspector hydration). Mirrors
27444
+ * `issueDetailData.ts`'s shape — pulls body, comments, reviews,
27445
+ * and the status-check rollup on demand when the user rests the
27446
+ * cursor on a PR row.
27447
+ *
27448
+ * Distinct from the existing `pullRequestData.ts` which fetches
27449
+ * the CURRENT BRANCH's PR via `gh pr view` (no number arg). This
27450
+ * fetcher takes an explicit PR number so the triage view can
27451
+ * hydrate any cursored PR, not just the one matching the current
27452
+ * branch.
27453
+ */
27454
+ /**
27455
+ * `gh pr view <#> --json` field list. Subset of what
27456
+ * `pullRequestData.ts`'s `PULL_REQUEST_VIEW_JSON_FIELDS` includes —
27457
+ * the triage list payload already carries the structural metadata
27458
+ * (state, isDraft, branches, labels, etc.), so the detail fetch
27459
+ * only needs the heavy/expensive fields that the list omits.
27460
+ */
27461
+ const PULL_REQUEST_DETAIL_JSON_FIELDS = [
27462
+ 'number',
27463
+ 'body',
27464
+ 'comments',
27465
+ 'reviews',
27466
+ 'statusCheckRollup',
27467
+ ].join(',');
27468
+ function parseComments(value) {
27469
+ if (!Array.isArray(value))
27470
+ return [];
27471
+ return value.map((entry) => {
27472
+ const raw = entry;
27473
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27474
+ ? String(raw.author.login)
27475
+ : undefined;
27476
+ return {
27477
+ author,
27478
+ body: typeof raw.body === 'string' ? raw.body : '',
27479
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
27480
+ };
27481
+ });
27482
+ }
27483
+ function parseReviews(value) {
27484
+ if (!Array.isArray(value))
27485
+ return [];
27486
+ return value
27487
+ .map((entry) => {
27488
+ const raw = entry;
27489
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
27490
+ ? String(raw.author.login)
27491
+ : undefined;
27492
+ return {
27493
+ author,
27494
+ state: typeof raw.state === 'string' ? raw.state : '',
27495
+ body: typeof raw.body === 'string' ? raw.body : '',
27496
+ submittedAt: typeof raw.submittedAt === 'string' ? raw.submittedAt : '',
27497
+ };
27498
+ })
27499
+ // gh occasionally returns review entries without an author when the
27500
+ // reviewer's account is deleted. Those are unactionable noise here;
27501
+ // strip them so the inspector doesn't render anonymous rows.
27502
+ .filter((review) => review.author || review.body);
27503
+ }
27504
+ function parseStatusCheckRollup(value) {
27505
+ if (!Array.isArray(value))
27506
+ return [];
27507
+ return value.map((entry) => {
27508
+ const raw = entry;
27509
+ return {
27510
+ name: String(raw.name || raw.context || 'check'),
27511
+ status: typeof raw.status === 'string' ? raw.status : undefined,
27512
+ conclusion: typeof raw.conclusion === 'string' ? raw.conclusion : undefined,
27513
+ };
27514
+ });
27515
+ }
27516
+ function parsePullRequestDetail(output) {
27517
+ const trimmed = output.trim();
27518
+ if (!trimmed)
27519
+ return undefined;
27520
+ const raw = JSON.parse(trimmed);
27521
+ if (typeof raw.number !== 'number')
27522
+ return undefined;
27523
+ return {
27524
+ number: raw.number,
27525
+ body: typeof raw.body === 'string' ? raw.body : '',
27526
+ comments: parseComments(raw.comments),
27527
+ reviews: parseReviews(raw.reviews),
27528
+ statusCheckRollup: parseStatusCheckRollup(raw.statusCheckRollup),
27529
+ };
27530
+ }
27531
+ async function getPullRequestDetail(pullRequestNumber, runner = defaultGhRunner) {
27532
+ try {
27533
+ const output = await runner([
27534
+ 'pr',
27535
+ 'view',
27536
+ String(pullRequestNumber),
27537
+ '--json',
27538
+ PULL_REQUEST_DETAIL_JSON_FIELDS,
27539
+ ]);
27540
+ const detail = parsePullRequestDetail(output);
27541
+ if (!detail) {
27542
+ return {
27543
+ ok: false,
27544
+ message: `Empty response from gh for pull request #${pullRequestNumber}`,
27545
+ };
27546
+ }
27547
+ return { ok: true, detail };
27548
+ }
27549
+ catch (error) {
27550
+ return {
27551
+ ok: false,
27552
+ message: error instanceof Error ? error.message : String(error),
27553
+ };
27554
+ }
27555
+ }
27556
+
27557
+ /**
27558
+ * `gh pr list --json` field list. Trimmer than `pullRequestData.ts`'s
27559
+ * single-PR field set — the triage list view doesn't need bodies,
27560
+ * statusCheckRollup, or per-review breakdowns (those live in the
27561
+ * inspector that opens when the user picks a row).
27562
+ */
27563
+ const PULL_REQUEST_LIST_JSON_FIELDS = [
27564
+ 'number',
27565
+ 'title',
27566
+ 'url',
27567
+ 'state',
27568
+ 'isDraft',
27569
+ 'headRefName',
27570
+ 'baseRefName',
27571
+ 'author',
27572
+ 'assignees',
27573
+ 'labels',
27574
+ 'reviewDecision',
27575
+ 'mergeable',
27576
+ 'mergeStateStatus',
27577
+ 'createdAt',
27578
+ 'updatedAt',
27579
+ ].join(',');
27580
+ function parsePullRequestListItems(output) {
27581
+ const trimmed = output.trim();
27582
+ if (!trimmed)
27583
+ return [];
27584
+ const raw = JSON.parse(trimmed);
27585
+ return raw.map((entry) => {
27586
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
27587
+ ? String(entry.author.login)
27588
+ : undefined;
27589
+ const assignees = Array.isArray(entry.assignees)
27590
+ ? entry.assignees
27591
+ .map((a) => (a && 'login' in a ? String(a.login) : ''))
27592
+ .filter(Boolean)
27593
+ : undefined;
27594
+ const labels = Array.isArray(entry.labels)
27595
+ ? entry.labels
27596
+ .map((l) => (l && 'name' in l ? String(l.name) : ''))
27597
+ .filter(Boolean)
27598
+ : undefined;
27599
+ return {
27600
+ number: entry.number,
27601
+ title: String(entry.title || ''),
27602
+ url: String(entry.url || ''),
27603
+ state: String(entry.state || ''),
27604
+ isDraft: Boolean(entry.isDraft),
27605
+ headRefName: String(entry.headRefName || ''),
27606
+ baseRefName: String(entry.baseRefName || ''),
27607
+ author,
27608
+ assignees,
27609
+ labels,
27610
+ reviewDecision: typeof entry.reviewDecision === 'string' ? entry.reviewDecision : undefined,
27611
+ mergeable: typeof entry.mergeable === 'string' ? entry.mergeable : undefined,
27612
+ mergeStateStatus: typeof entry.mergeStateStatus === 'string' ? entry.mergeStateStatus : undefined,
27613
+ createdAt: String(entry.createdAt || ''),
27614
+ updatedAt: String(entry.updatedAt || ''),
27615
+ };
27616
+ });
27617
+ }
27618
+ function buildGhArgs(filter) {
27619
+ const args = ['pr', 'list', '--json', PULL_REQUEST_LIST_JSON_FIELDS];
27620
+ if (filter.state)
27621
+ args.push('--state', filter.state);
27622
+ if (filter.assignee)
27623
+ args.push('--assignee', filter.assignee);
27624
+ if (filter.author)
27625
+ args.push('--author', filter.author);
27626
+ if (filter.label)
27627
+ args.push('--label', filter.label);
27628
+ if (filter.search)
27629
+ args.push('--search', filter.search);
27630
+ if (filter.draft)
27631
+ args.push('--draft');
27632
+ if (filter.base)
27633
+ args.push('--base', filter.base);
27634
+ if (filter.head)
27635
+ args.push('--head', filter.head);
27636
+ if (typeof filter.limit === 'number')
27637
+ args.push('--limit', String(filter.limit));
27638
+ return args;
27639
+ }
27640
+ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
27641
+ const repository = await getGitHubRepository(git);
27642
+ if (!repository) {
27643
+ return {
27644
+ available: false,
27645
+ authenticated: false,
27646
+ filter,
27647
+ message: 'No GitHub remote detected.',
27648
+ };
27649
+ }
27650
+ if (!(await isGhAuthenticated(runner))) {
27651
+ return {
27652
+ available: true,
27653
+ authenticated: false,
27654
+ repository,
27655
+ filter,
27656
+ message: 'GitHub CLI is missing or not authenticated.',
27657
+ };
27658
+ }
27659
+ try {
27660
+ const output = await runner(buildGhArgs(filter));
27661
+ return {
27662
+ available: true,
27663
+ authenticated: true,
27664
+ repository,
27665
+ filter,
27666
+ pullRequests: parsePullRequestListItems(output),
27667
+ };
27668
+ }
27669
+ catch (error) {
27670
+ return {
27671
+ available: true,
27672
+ authenticated: true,
27673
+ repository,
27674
+ filter,
27675
+ message: error instanceof Error ? error.message : 'Failed to fetch pull request list.',
27676
+ };
27677
+ }
27678
+ }
27679
+
26126
27680
  function parseCreatedPullRequestUrl(output) {
26127
27681
  return output
26128
27682
  .split('\n')
@@ -26223,6 +27777,77 @@ function commentPullRequest(body, runner = defaultGhRunner) {
26223
27777
  message: output.trim() || 'Comment added',
26224
27778
  }));
26225
27779
  }
27780
+ /**
27781
+ * Triage-view variants (#882 phase 4). Same shape as
27782
+ * `commentPullRequest` above but target a specific PR by number,
27783
+ * which is what the multi-PR triage list needs (the cursor isn't
27784
+ * necessarily on the current branch's PR). Kept as siblings rather
27785
+ * than overloads so the single-PR call sites stay untouched and
27786
+ * the API stays readable at the call site (no need to pass
27787
+ * `undefined` to skip the number arg).
27788
+ */
27789
+ function commentPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
27790
+ if (!body.trim()) {
27791
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
27792
+ }
27793
+ return runGhAction(runner, ['pr', 'comment', String(pullRequestNumber), '--body', body], (output) => ({
27794
+ ok: true,
27795
+ message: output.trim() || `Commented on pull request #${pullRequestNumber}`,
27796
+ }));
27797
+ }
27798
+ function addPullRequestLabel(pullRequestNumber, label, runner = defaultGhRunner) {
27799
+ if (!label.trim()) {
27800
+ return Promise.resolve({ ok: false, message: 'Label name required' });
27801
+ }
27802
+ return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-label', label], () => ({
27803
+ ok: true,
27804
+ message: `Added label '${label}' to pull request #${pullRequestNumber}`,
27805
+ }));
27806
+ }
27807
+ function addPullRequestAssignee(pullRequestNumber, assignee, runner = defaultGhRunner) {
27808
+ if (!assignee.trim()) {
27809
+ return Promise.resolve({ ok: false, message: 'Assignee login required' });
27810
+ }
27811
+ return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-assignee', assignee], () => ({
27812
+ ok: true,
27813
+ message: `Assigned ${assignee} to pull request #${pullRequestNumber}`,
27814
+ }));
27815
+ }
27816
+ /**
27817
+ * Destructive PR verbs targeting a specific number (#882 phase 5).
27818
+ * Siblings of the existing current-branch functions above; both
27819
+ * variants delegate to the same `runGhAction` wrapper so error
27820
+ * shaping stays uniform.
27821
+ */
27822
+ function mergePullRequestByNumber(pullRequestNumber, strategy, runner = defaultGhRunner) {
27823
+ return runGhAction(runner, ['pr', 'merge', String(pullRequestNumber), `--${strategy}`], (output) => ({
27824
+ ok: true,
27825
+ message: output.trim() ||
27826
+ `Merged pull request #${pullRequestNumber} with ${strategy}`,
27827
+ }));
27828
+ }
27829
+ function closePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
27830
+ return runGhAction(runner, ['pr', 'close', String(pullRequestNumber)], (output) => ({
27831
+ ok: true,
27832
+ message: output.trim() || `Closed pull request #${pullRequestNumber}`,
27833
+ }));
27834
+ }
27835
+ function approvePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
27836
+ return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--approve'], (output) => ({
27837
+ ok: true,
27838
+ message: output.trim() || `Approved pull request #${pullRequestNumber}`,
27839
+ }));
27840
+ }
27841
+ function requestChangesPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
27842
+ if (!body.trim()) {
27843
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
27844
+ }
27845
+ return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--request-changes', '--body', body], (output) => ({
27846
+ ok: true,
27847
+ message: output.trim() ||
27848
+ `Requested changes on pull request #${pullRequestNumber}`,
27849
+ }));
27850
+ }
26226
27851
 
26227
27852
  async function runAction(action, successMessage) {
26228
27853
  try {
@@ -27532,10 +29157,70 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
27532
29157
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
27533
29158
  }, 'tab-worktrees', visibleListCount);
27534
29159
  }
27535
- function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
29160
+ /**
29161
+ * Single-letter glyph for a sidebar tab in rail mode. Letters always
29162
+ * carry the meaning so this stays useful under ASCII; the rail is too
29163
+ * narrow to fit the full tab label. Pairs with `sidebarTabCount` for
29164
+ * the trailing count.
29165
+ */
29166
+ function sidebarTabRailGlyph(tab) {
29167
+ switch (tab) {
29168
+ case 'status':
29169
+ return 'S';
29170
+ case 'branches':
29171
+ return 'B';
29172
+ case 'tags':
29173
+ return 'T';
29174
+ case 'stashes':
29175
+ return '$';
29176
+ case 'worktrees':
29177
+ return 'W';
29178
+ default:
29179
+ return '·';
29180
+ }
29181
+ }
29182
+ /**
29183
+ * Rail-mode sidebar — shown on terminals < 100 columns when the
29184
+ * sidebar does not hold focus. Five vertically stacked tab glyphs
29185
+ * with optional counts; the active tab is bracketed. Pressing Tab to
29186
+ * focus the sidebar pops it back to the full accordion (the layout
29187
+ * un-rails it on focus, this renderer is never called in that case).
29188
+ */
29189
+ function renderSidebarRail(h, components, state, context, width, theme, focused, tabs) {
29190
+ const { Box, Text } = components;
29191
+ return h(Box, {
29192
+ borderColor: focusBorderColor(theme, focused),
29193
+ borderStyle: theme.borderStyle,
29194
+ flexDirection: 'column',
29195
+ width,
29196
+ paddingX: 1,
29197
+ }, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
29198
+ const isActive = tab === state.sidebarTab;
29199
+ const glyph = sidebarTabRailGlyph(tab);
29200
+ const count = sidebarTabCount(tab, context);
29201
+ // Count fits in 2 cells (rail content area is ~4 cells); 99+
29202
+ // collapses to `+` so we never overflow.
29203
+ const countText = count === undefined
29204
+ ? ''
29205
+ : count > 99
29206
+ ? '+'
29207
+ : String(count);
29208
+ const body = isActive ? `[${glyph}]` : ` ${glyph} `;
29209
+ const text = countText ? `${body}${countText}` : body;
29210
+ return h(Text, {
29211
+ key: `rail-${tab}`,
29212
+ bold: isActive,
29213
+ dimColor: !isActive,
29214
+ }, text);
29215
+ }));
29216
+ }
29217
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
27536
29218
  const { Box, Text } = components;
27537
29219
  const focused = state.focus === 'sidebar';
27538
29220
  const tabs = getLogInkSidebarTabs();
29221
+ if (railed) {
29222
+ return renderSidebarRail(h, components, state, context, width, theme, focused, tabs);
29223
+ }
27539
29224
  // Accordion layout — every tab's title is visible on its own line, but
27540
29225
  // only the active tab expands its content underneath. Switching tabs
27541
29226
  // (1-5 / [/]) collapses the previous and expands the next.
@@ -27822,6 +29507,34 @@ function formatLogInkSubmodulesEmpty({ filter }) {
27822
29507
  }
27823
29508
  return 'No submodules registered. Add one with `git submodule add <url> <path>` from the shell.';
27824
29509
  }
29510
+ function formatLogInkIssuesEmpty({ filter }) {
29511
+ if (filter.trim()) {
29512
+ return `No issues match filter '${filter}'. Press ctrl+u to clear.`;
29513
+ }
29514
+ return 'No issues match the current GitHub filter (default: open issues).';
29515
+ }
29516
+ function formatLogInkPullRequestTriageEmpty({ filter, }) {
29517
+ if (filter.trim()) {
29518
+ return `No pull requests match filter '${filter}'. Press ctrl+u to clear.`;
29519
+ }
29520
+ return 'No pull requests match the current GitHub filter (default: open PRs).';
29521
+ }
29522
+ /**
29523
+ * Surface-level fallback when the GitHub CLI is missing or not
29524
+ * authenticated. The triage views (#882) all share this empty-state
29525
+ * copy — the underlying problem is the same regardless of which
29526
+ * surface the user is on, and the recovery is identical.
29527
+ */
29528
+ function formatLogInkGitHubUnauthenticated({ resource, }) {
29529
+ return `${resource} require the GitHub CLI. Install \`gh\` and run \`gh auth login\` to enable triage.`;
29530
+ }
29531
+ /**
29532
+ * Surface-level fallback when the repo has no GitHub remote. Same
29533
+ * shared message across the triage surfaces.
29534
+ */
29535
+ function formatLogInkGitHubNoRemote({ resource, }) {
29536
+ return `${resource} require a GitHub remote (origin or fallback). None detected for this repo.`;
29537
+ }
27825
29538
 
27826
29539
  /**
27827
29540
  * Branches surface — promoted view listing local branches with sort,
@@ -28712,6 +30425,179 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
28712
30425
  : []));
28713
30426
  }
28714
30427
 
30428
+ /**
30429
+ * Strip refs that are already represented by a branch tip chip so the
30430
+ * trailing `[ref] [ref]` list doesn't repeat what the chip is already
30431
+ * showing. The chip carries the primary branch name; the trailing
30432
+ * list keeps everything else — including remote-tracking variants
30433
+ * (`origin/X`) and `origin/HEAD` — because those convey "remote is
30434
+ * also at this commit" info the chip alone doesn't.
30435
+ *
30436
+ * Removes:
30437
+ * - exact match of the chipped name (`main`, `feat/foo`)
30438
+ * - `HEAD -> <name>` for the chipped name
30439
+ * - bare `HEAD` when the chip is the HEAD branch (only paranoia;
30440
+ * git typically emits `HEAD -> name` not both, but a detached
30441
+ * fixup commit may have produced both)
30442
+ */
30443
+ function filterChippedRefs(refs, chip) {
30444
+ if (!chip)
30445
+ return refs;
30446
+ const headDecoration = `HEAD -> ${chip.name}`;
30447
+ return refs.filter((ref) => {
30448
+ if (ref === chip.name)
30449
+ return false;
30450
+ if (ref === headDecoration)
30451
+ return false;
30452
+ if (chip.isHead && ref === 'HEAD')
30453
+ return false;
30454
+ return true;
30455
+ });
30456
+ }
30457
+ function getBranchTipChip(refs) {
30458
+ for (const ref of refs) {
30459
+ if (ref.startsWith('HEAD -> ')) {
30460
+ const name = ref.slice('HEAD -> '.length).trim();
30461
+ if (name)
30462
+ return { name, isHead: true };
30463
+ }
30464
+ }
30465
+ for (const ref of refs) {
30466
+ if (ref === 'HEAD' ||
30467
+ ref.startsWith('HEAD -> ') ||
30468
+ ref.startsWith('tag: ') ||
30469
+ ref.includes('/')) {
30470
+ continue;
30471
+ }
30472
+ if (ref.trim())
30473
+ return { name: ref.trim(), isHead: false };
30474
+ }
30475
+ for (const ref of refs) {
30476
+ if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
30477
+ continue;
30478
+ }
30479
+ if (ref.includes('/') && ref.trim()) {
30480
+ return { name: ref.trim(), isHead: false };
30481
+ }
30482
+ }
30483
+ return undefined;
30484
+ }
30485
+
30486
+ const PREFIX_PATTERN = /^([a-z]+)(\(([^)]+)\))?(!)?:\s+/;
30487
+ function parseConventionalCommitPrefix(message) {
30488
+ const match = PREFIX_PATTERN.exec(message);
30489
+ if (!match)
30490
+ return undefined;
30491
+ const [whole, type, , scope, breakingMarker] = match;
30492
+ return {
30493
+ prefix: whole,
30494
+ rest: message.slice(whole.length),
30495
+ type,
30496
+ scope: scope || undefined,
30497
+ breaking: Boolean(breakingMarker),
30498
+ };
30499
+ }
30500
+ /**
30501
+ * Pick the theme color used to paint a conventional-commit prefix.
30502
+ *
30503
+ * Rough mapping intent:
30504
+ * - feat → success (new capability, growth)
30505
+ * - fix → warning (was a problem; eye-catch)
30506
+ * - docs / refactor / perf → info / accent (intent-bearing change)
30507
+ * - test / style / build / ci → muted (mechanical / housekeeping)
30508
+ * - chore → muted
30509
+ * - revert → danger (signals "this undid something")
30510
+ *
30511
+ * Unknown types fall through to `accent` so a project-specific
30512
+ * convention (`wip:`, `release:`, etc.) still reads as the typed
30513
+ * prefix rather than blending into the subject. Returns `undefined`
30514
+ * under `theme.noColor` so the prefix stays plain — the textual
30515
+ * `feat:` carries the meaning by itself.
30516
+ *
30517
+ * Breaking changes (`!:`) override the type color with `danger` so
30518
+ * the row reads as "stop and look at this" regardless of which type
30519
+ * it is.
30520
+ */
30521
+ function getConventionalCommitColor(parsed, theme) {
30522
+ if (theme.noColor)
30523
+ return undefined;
30524
+ if (parsed.breaking)
30525
+ return theme.colors.danger;
30526
+ switch (parsed.type) {
30527
+ case 'feat':
30528
+ return theme.colors.success;
30529
+ case 'fix':
30530
+ return theme.colors.warning;
30531
+ case 'docs':
30532
+ case 'refactor':
30533
+ case 'perf':
30534
+ return theme.colors.info;
30535
+ case 'test':
30536
+ case 'style':
30537
+ case 'build':
30538
+ case 'ci':
30539
+ case 'chore':
30540
+ return theme.colors.muted;
30541
+ case 'revert':
30542
+ return theme.colors.danger;
30543
+ default:
30544
+ return theme.colors.accent;
30545
+ }
30546
+ }
30547
+
30548
+ /**
30549
+ * Date formatting helpers for the Ink TUI surfaces.
30550
+ *
30551
+ * The branch list already ships its own "X ago" formatter
30552
+ * (`formatBranchLastTouched` in iconography.ts) sized for a sidebar
30553
+ * row with room to breathe. The history surface needs a tighter
30554
+ * variant: the date column is fixed-width and competes with the
30555
+ * commit message for cells, so a 2-3 character form is the budget.
30556
+ *
30557
+ * Inputs match what `git log --date=short` produces:
30558
+ * `YYYY-MM-DD`. Caller passes `now` so tests can pin the reference
30559
+ * instant.
30560
+ *
30561
+ * Outputs (rounded toward the nearest unit, no `ago` suffix):
30562
+ * - `today` for same UTC day
30563
+ * - `1d` … `13d` for 1-13 days
30564
+ * - `2w` … `8w` for 2-8 weeks
30565
+ * - `2mo` … `11mo` for 2-11 months
30566
+ * - `2y`+ for older
30567
+ * - `''` for malformed inputs (caller renders nothing)
30568
+ *
30569
+ * Day comparison is in UTC so a commit dated "yesterday" never reads
30570
+ * "today" depending on the operator's timezone.
30571
+ */
30572
+ function formatCompactRelativeDate(iso, now) {
30573
+ if (!iso)
30574
+ return '';
30575
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
30576
+ if (!match)
30577
+ return '';
30578
+ const year = Number.parseInt(match[1], 10);
30579
+ const month = Number.parseInt(match[2], 10);
30580
+ const day = Number.parseInt(match[3], 10);
30581
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
30582
+ return '';
30583
+ const commitUtc = Date.UTC(year, month - 1, day);
30584
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
30585
+ const oneDay = 24 * 60 * 60 * 1000;
30586
+ const days = Math.floor((nowUtc - commitUtc) / oneDay);
30587
+ if (days <= 0)
30588
+ return 'today';
30589
+ if (days < 14)
30590
+ return `${days}d`;
30591
+ const weeks = Math.floor(days / 7);
30592
+ if (weeks < 9)
30593
+ return `${weeks}w`;
30594
+ const months = Math.floor(days / 30);
30595
+ if (months < 12)
30596
+ return `${months}mo`;
30597
+ const years = Math.floor(days / 365);
30598
+ return `${years}y`;
30599
+ }
30600
+
28715
30601
  /**
28716
30602
  * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
28717
30603
  * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
@@ -28961,6 +30847,54 @@ function getLaneColor(laneId, theme) {
28961
30847
  return palette[laneId % palette.length];
28962
30848
  }
28963
30849
 
30850
+ const MONTH_NAMES = [
30851
+ 'January', 'February', 'March', 'April', 'May', 'June',
30852
+ 'July', 'August', 'September', 'October', 'November', 'December',
30853
+ ];
30854
+ function getDateBucket(iso, now) {
30855
+ if (!iso)
30856
+ return { key: 'unknown', label: 'Unknown date' };
30857
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
30858
+ if (!match)
30859
+ return { key: 'unknown', label: 'Unknown date' };
30860
+ const year = Number.parseInt(match[1], 10);
30861
+ const month = Number.parseInt(match[2], 10);
30862
+ const day = Number.parseInt(match[3], 10);
30863
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
30864
+ return { key: 'unknown', label: 'Unknown date' };
30865
+ }
30866
+ const commitUtc = Date.UTC(year, month - 1, day);
30867
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
30868
+ const oneDay = 24 * 60 * 60 * 1000;
30869
+ const days = Math.floor((nowUtc - commitUtc) / oneDay);
30870
+ // Future-dated commits (clock skew, bad commit dates) collapse to
30871
+ // today rather than confusing the user with an "in the future"
30872
+ // bucket.
30873
+ if (days <= 0)
30874
+ return { key: 'today', label: 'Today' };
30875
+ if (days === 1)
30876
+ return { key: 'yesterday', label: 'Yesterday' };
30877
+ if (days < 7)
30878
+ return { key: 'this-week', label: 'This week' };
30879
+ if (days < 14)
30880
+ return { key: 'last-week', label: 'Last week' };
30881
+ // Inside the same calendar month → one "earlier this month" bucket
30882
+ // so the user sees a single section rather than per-day groupings
30883
+ // for a commit-heavy week.
30884
+ if (year === now.getUTCFullYear() && month - 1 === now.getUTCMonth()) {
30885
+ return { key: 'earlier-this-month', label: 'Earlier this month' };
30886
+ }
30887
+ // Older months use the calendar-month label so the bucket reads
30888
+ // naturally even years back (`April 2024`). The key embeds the
30889
+ // year+month so different months stay in distinct buckets without
30890
+ // colliding on month name alone.
30891
+ const monthLabel = MONTH_NAMES[month - 1] ?? `Month ${month}`;
30892
+ return {
30893
+ key: `month-${match[1]}-${match[2]}`,
30894
+ label: `${monthLabel} ${year}`,
30895
+ };
30896
+ }
30897
+
28964
30898
  /**
28965
30899
  * Pick the commit glyph based on parent count + HEAD-ness so the
28966
30900
  * renderer can flag merges and the current head visually. HEAD wins
@@ -28989,9 +30923,8 @@ function commitKey(commit) {
28989
30923
  function graphWidth(items) {
28990
30924
  return Math.max(1, ...items.map((item) => item.graph.length));
28991
30925
  }
28992
- function toCompactItems(state, visibleCount) {
28993
- const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
28994
- return state.filteredCommits.slice(start, start + visibleCount).map((commit, offset) => ({
30926
+ function makeCompactCommitItem(commit, selected) {
30927
+ return {
28995
30928
  type: 'commit',
28996
30929
  commit,
28997
30930
  graph: '*',
@@ -29000,24 +30933,197 @@ function toCompactItems(state, visibleCount) {
29000
30933
  // glance. Lane id stays undefined so the segment renders muted —
29001
30934
  // matching the legacy compact appearance, just with a richer glyph.
29002
30935
  laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
29003
- selected: start + offset === state.selectedIndex,
29004
- }));
30936
+ selected,
30937
+ };
30938
+ }
30939
+ function bucketHeaderItem(label) {
30940
+ return { type: 'bucket-header', graph: '', label };
30941
+ }
30942
+ function toCompactItems(state, visibleCount, bucketingNow) {
30943
+ const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
30944
+ const slice = state.filteredCommits.slice(start, start + visibleCount);
30945
+ if (!bucketingNow) {
30946
+ return slice.map((commit, offset) => makeCompactCommitItem(commit, start + offset === state.selectedIndex));
30947
+ }
30948
+ // With bucketing on: emit a sticky header above the first visible
30949
+ // commit and an additional header each time the bucket changes. The
30950
+ // header occupies one row from the visibleCount budget every time
30951
+ // it fires, so the visible commit count drops slightly in exchange
30952
+ // for always-on temporal orientation.
30953
+ const items = [];
30954
+ let prevBucket = undefined;
30955
+ for (let offset = 0; offset < slice.length && items.length < visibleCount; offset += 1) {
30956
+ const commit = slice[offset];
30957
+ const bucket = getDateBucket(commit.date, bucketingNow);
30958
+ if (bucket.key !== prevBucket) {
30959
+ items.push(bucketHeaderItem(bucket.label));
30960
+ prevBucket = bucket.key;
30961
+ if (items.length >= visibleCount)
30962
+ break;
30963
+ }
30964
+ items.push(makeCompactCommitItem(commit, start + offset === state.selectedIndex));
30965
+ }
30966
+ return items;
29005
30967
  }
29006
30968
  function isSelectedCommit(row, selected) {
29007
30969
  return row.type === 'commit' && selected ? commitKey(row) === commitKey(selected) : false;
29008
30970
  }
29009
- function toFullGraphItems(state, visibleCount) {
30971
+ /**
30972
+ * Build the vertical-only graph string that follows a commit row when
30973
+ * `withSpacers` is enabled. Every commit-cell glyph (`*`) is rewritten
30974
+ * to a lane bar (`|`) so the synthetic row continues every open lane
30975
+ * without re-rendering the commit dot. All other graph chars pass
30976
+ * through unchanged, so a commit graph like `* | |` becomes `| | |`.
30977
+ */
30978
+ function buildSpacerGraph(commitGraph) {
30979
+ return commitGraph.replace(/\*/g, '|');
30980
+ }
30981
+ /**
30982
+ * Walk `state.rows` and inject a synthetic spacer entry after every
30983
+ * commit row when `withSpacers` is true. The spacer is a graph-only
30984
+ * row that renders as `|` per active lane so consecutive commits have
30985
+ * a clear vertical rhythm without losing topology continuity.
30986
+ *
30987
+ * The spacer is suppressed in two cases where it would create visible
30988
+ * "tearing" on the graph column:
30989
+ *
30990
+ * 1. The next row is git's own graph-only topology row (`|\` /
30991
+ * `|/` / `| |`). That row already provides vertical breathing
30992
+ * AND draws the lane transition; sandwiching our spacer between
30993
+ * the commit and the transition produces an extra all-pipes row
30994
+ * that reads as misalignment.
30995
+ *
30996
+ * 2. The current commit's graph contains a backslash or forward
30997
+ * slash (the compressed forms git uses for `*\` / `*` followed
30998
+ * by slash, when it draws the fork on the same row as the
30999
+ * commit). The spacer's commit-glyph → lane-bar rewrite would
31000
+ * leave the diagonal intact, rendering a second corner glyph
31001
+ * immediately below the merge — a duplicate that looks like a
31002
+ * glyph stutter.
31003
+ *
31004
+ * When `withSpacers` is false the list is identity-mapped from
31005
+ * source rows, preserving the legacy zero-padding behavior for any
31006
+ * caller that wants raw git topology (filters, tests, etc.).
31007
+ */
31008
+ function commitGraphIsSimple(graph) {
31009
+ return !/[\\/]/.test(graph);
31010
+ }
31011
+ function expandRowsWithSpacers(rows, withSpacers) {
31012
+ const out = [];
31013
+ for (let i = 0; i < rows.length; i += 1) {
31014
+ const row = rows[i];
31015
+ out.push({ kind: 'source', row });
31016
+ if (!withSpacers || row.type !== 'commit')
31017
+ continue;
31018
+ if (!commitGraphIsSimple(row.graph || '*'))
31019
+ continue;
31020
+ const next = rows[i + 1];
31021
+ if (next && next.type === 'graph')
31022
+ continue;
31023
+ out.push({ kind: 'spacer', sourceCommit: row });
31024
+ }
31025
+ return out;
31026
+ }
31027
+ /**
31028
+ * Walk an already-expanded row list and inject `bucket-header`
31029
+ * entries immediately before each commit whose date bucket differs
31030
+ * from the previous commit's. The very first commit always gets a
31031
+ * header so the user lands inside a labeled section regardless of
31032
+ * where the scroll window starts. Non-commit entries (spacers, git
31033
+ * topology rows) pass through unchanged.
31034
+ */
31035
+ function injectBucketHeaders(rows, now) {
31036
+ const out = [];
31037
+ let prevBucket = undefined;
31038
+ for (const entry of rows) {
31039
+ if (entry.kind === 'source' && entry.row.type === 'commit') {
31040
+ const bucket = getDateBucket(entry.row.date, now);
31041
+ if (bucket.key !== prevBucket) {
31042
+ out.push({ kind: 'bucket-header', label: bucket.label });
31043
+ prevBucket = bucket.key;
31044
+ }
31045
+ }
31046
+ out.push(entry);
31047
+ }
31048
+ return out;
31049
+ }
31050
+ /**
31051
+ * Find the most recent bucket header at or above `start` so a slice
31052
+ * that begins mid-bucket can still surface its section label. Used
31053
+ * for the "sticky header" behavior — when the window scrolls past
31054
+ * the natural header position, prepend the header to the slice so
31055
+ * the user always sees which bucket they're in. Returns the label
31056
+ * to prepend, or `undefined` when no prepend is needed (either
31057
+ * `expanded[start]` is already a header, or there is no earlier
31058
+ * header in the list).
31059
+ */
31060
+ function findStickyBucketLabel(expanded, start) {
31061
+ if (start < expanded.length && expanded[start].kind === 'bucket-header')
31062
+ return undefined;
31063
+ for (let i = start - 1; i >= 0; i -= 1) {
31064
+ const entry = expanded[i];
31065
+ if (entry.kind === 'bucket-header')
31066
+ return entry.label;
31067
+ }
31068
+ return undefined;
31069
+ }
31070
+ function toFullGraphItems(state, visibleCount, options = {
31071
+ withSpacers: false,
31072
+ bucketingNow: undefined,
31073
+ }) {
29010
31074
  const selected = state.filteredCommits[state.selectedIndex];
29011
- const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
29012
- const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
31075
+ const withSpacers = expandRowsWithSpacers(state.rows, options.withSpacers);
31076
+ const expanded = options.bucketingNow
31077
+ ? injectBucketHeaders(withSpacers, options.bucketingNow)
31078
+ : withSpacers;
31079
+ const selectedExpandedIndex = expanded.findIndex((entry) => entry.kind === 'source' && isSelectedCommit(entry.row, selected));
31080
+ const start = clampWindowStart(selectedExpandedIndex >= 0 ? selectedExpandedIndex : 0, expanded.length, visibleCount);
29013
31081
  // Lane tracking is order-dependent — fast-forward the tracker through
29014
31082
  // every row above the visible window so lane ids stay stable as the
29015
31083
  // user scrolls. Without this, scrolling would re-color lanes from a
29016
- // fresh tracker each time.
31084
+ // fresh tracker each time. Spacers contribute their vertical-only
31085
+ // graph to the prefix so the tracker sees a no-op advance and lane
31086
+ // state stays consistent at the window boundary. Bucket headers
31087
+ // skip the tracker entirely since they have no graph string.
29017
31088
  const tracker = createLaneTrackerState();
29018
- const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
29019
- advanceTrackerThrough(allGraphs, tracker, start);
29020
- return state.rows.slice(start, start + visibleCount).map((row) => {
31089
+ const prefixGraphs = [];
31090
+ for (let k = 0; k < start; k += 1) {
31091
+ const entry = expanded[k];
31092
+ if (entry.kind === 'bucket-header')
31093
+ continue;
31094
+ if (entry.kind === 'spacer') {
31095
+ prefixGraphs.push(buildSpacerGraph(entry.sourceCommit.graph || '*'));
31096
+ continue;
31097
+ }
31098
+ prefixGraphs.push(entry.row.type === 'commit' ? entry.row.graph || '*' : entry.row.graph);
31099
+ }
31100
+ advanceTrackerThrough(prefixGraphs, tracker, prefixGraphs.length);
31101
+ // Sticky header — if the slice would start partway into a bucket
31102
+ // (most commonly when scrolling), prepend the bucket label so the
31103
+ // user keeps temporal context. The prepend costs one row from the
31104
+ // visible budget, so the slice itself shrinks by 1.
31105
+ const stickyLabel = options.bucketingNow
31106
+ ? findStickyBucketLabel(expanded, start)
31107
+ : undefined;
31108
+ const sliceCount = stickyLabel ? visibleCount - 1 : visibleCount;
31109
+ const sliced = expanded.slice(start, start + sliceCount);
31110
+ const finalEntries = stickyLabel
31111
+ ? [{ kind: 'bucket-header', label: stickyLabel }, ...sliced]
31112
+ : sliced;
31113
+ return finalEntries.map((entry) => {
31114
+ if (entry.kind === 'bucket-header') {
31115
+ return bucketHeaderItem(entry.label);
31116
+ }
31117
+ if (entry.kind === 'spacer') {
31118
+ const graph = buildSpacerGraph(entry.sourceCommit.graph || '*');
31119
+ return {
31120
+ type: 'graph',
31121
+ graph,
31122
+ laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false }),
31123
+ spacer: true,
31124
+ };
31125
+ }
31126
+ const { row } = entry;
29021
31127
  if (row.type === 'graph') {
29022
31128
  return {
29023
31129
  type: 'graph',
@@ -29036,10 +31142,18 @@ function toFullGraphItems(state, visibleCount) {
29036
31142
  };
29037
31143
  });
29038
31144
  }
29039
- function getVisibleLogInkHistory(state, visibleCount) {
31145
+ function getVisibleLogInkHistory(state, visibleCount, options = {}) {
31146
+ // Bucketing only makes sense for chronologically ordered output —
31147
+ // an active search filter shuffles commits by relevance, so the
31148
+ // adjacent-bucket invariant breaks down and the divider would
31149
+ // read as noise.
31150
+ const bucketingNow = state.filter ? undefined : options.dateBucketingNow;
29040
31151
  const items = state.fullGraph && !state.filter
29041
- ? toFullGraphItems(state, visibleCount)
29042
- : toCompactItems(state, visibleCount);
31152
+ ? toFullGraphItems(state, visibleCount, {
31153
+ withSpacers: Boolean(options.fullGraphSpacing),
31154
+ bucketingNow,
31155
+ })
31156
+ : toCompactItems(state, visibleCount, bucketingNow);
29043
31157
  return {
29044
31158
  graphWidth: graphWidth(items),
29045
31159
  items,
@@ -29065,6 +31179,96 @@ function formatInkRefLabels(refs) {
29065
31179
  * `formatHistoryFetchArgs`) lived in inkRuntime.ts only to support
29066
31180
  * this surface; they migrate together.
29067
31181
  */
31182
+ /**
31183
+ * How the date column should render for a given density tier:
31184
+ * - wide → absolute `YYYY-MM-DD`
31185
+ * - normal → compact relative form (`2d`, `3w`, `2mo`)
31186
+ * - tight → hidden entirely (column dropped)
31187
+ * - rail → caller picks `rowMode='stacked'`; this fn isn't consulted
31188
+ *
31189
+ * Compact mode (the user toggling away from the full graph) forces
31190
+ * the tight behavior regardless of density. Compact is the "scan
31191
+ * mode" — the date is the first thing the user is willing to drop in
31192
+ * exchange for more visible commits per screen.
31193
+ *
31194
+ * When `bucketed` is true the surface is rendering section dividers
31195
+ * (`── Today ──`) above commits, so the per-row date column would be
31196
+ * redundant. We drop it entirely and let the message column expand.
31197
+ */
31198
+ function pickDateText(commit, density, fullGraph, bucketed, now) {
31199
+ if (bucketed)
31200
+ return '';
31201
+ if (!fullGraph)
31202
+ return '';
31203
+ if (density === 'wide')
31204
+ return commit.date;
31205
+ if (density === 'normal')
31206
+ return formatCompactRelativeDate(commit.date, now);
31207
+ return '';
31208
+ }
31209
+ /**
31210
+ * Maximum cells the chip body (between the brackets) is allowed to
31211
+ * occupy. Anything longer is truncated with an ellipsis so a
31212
+ * `[origin/claude/issues-prs-cache]` chip — 32 cells of chrome —
31213
+ * doesn't eat the whole subject column on a narrow terminal. Picked
31214
+ * empirically: 20 cells fits common branch shapes (`feat/foo`,
31215
+ * `claude/graph-fidelity`, `main`) without truncation.
31216
+ */
31217
+ const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
31218
+ /**
31219
+ * Render a pill-style chip for a branch tip — colored background
31220
+ * with the branch name reverse-printed inside, so the chip reads as
31221
+ * a distinct visual category (block) rather than colored text (which
31222
+ * collides with `docs:`/`refactor:`/`perf:` conventional-commit
31223
+ * prefixes that also use `info`). Current branch (HEAD -> X) uses
31224
+ * success-green; other branch tips use info-blue.
31225
+ *
31226
+ * Implementation: `inverse: true` + `color: <accent>` is the
31227
+ * portable way to render "colored background with terminal-default
31228
+ * foreground" — it adapts to dark vs light terminals without
31229
+ * hardcoding a black/white fg. Tags are never chipped; they stay in
31230
+ * the trailing ref list. The chip emits its own trailing space so
31231
+ * callers concatenate it directly into the row without a separator.
31232
+ *
31233
+ * Selection styling is opt-out: when the row is selected, the outer
31234
+ * `inverse: true` + selection background already covers everything,
31235
+ * and a second `inverse` on the chip would flip it back to plain. We
31236
+ * drop the pill styling for selected rows and let the row's own
31237
+ * inverse highlight carry through cleanly.
31238
+ *
31239
+ * Returns the rendered node alongside its cell width AND the chip
31240
+ * descriptor so the caller can pass it to `filterChippedRefs` and
31241
+ * avoid emitting the same branch a second time in the trailing list.
31242
+ */
31243
+ function renderBranchTipChip(h, Text, commit, theme, key, selected) {
31244
+ const chip = getBranchTipChip(commit.refs);
31245
+ if (!chip)
31246
+ return { node: null, width: 0, chip };
31247
+ const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
31248
+ // Inner pill body is `name`; the trailing space sits OUTSIDE the
31249
+ // colored block so the bg doesn't bleed into the message column.
31250
+ // The brackets are gone — the colored block is its own visual
31251
+ // affordance and the brackets would add 2 cells of chrome that
31252
+ // duplicate the affordance.
31253
+ const body = ` ${truncated} `;
31254
+ // Selected row OR noColor mode → drop pill styling. Selected rows
31255
+ // get the row-level inverse highlight; noColor terminals fall
31256
+ // back to bracketed text so the chip still parses visually.
31257
+ if (selected || theme.noColor) {
31258
+ const fallbackLabel = `[${truncated}] `;
31259
+ return {
31260
+ node: h(Text, { key, bold: chip.isHead }, fallbackLabel),
31261
+ width: cellWidth(fallbackLabel),
31262
+ chip,
31263
+ };
31264
+ }
31265
+ const accent = chip.isHead ? theme.colors.success : theme.colors.info;
31266
+ return {
31267
+ node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
31268
+ width: cellWidth(body) + 1,
31269
+ chip,
31270
+ };
31271
+ }
29068
31272
  function formatHistoryFetchArgs(args) {
29069
31273
  const parts = [];
29070
31274
  if (args.author)
@@ -29073,6 +31277,36 @@ function formatHistoryFetchArgs(args) {
29073
31277
  parts.push(`-- ${args.path}`);
29074
31278
  return parts.join(' ') || 'none';
29075
31279
  }
31280
+ /**
31281
+ * Render a commit subject with the conventional-commit prefix
31282
+ * (`feat:`, `fix(scope)!:`, …) painted in a type-specific color so
31283
+ * the eye can bucket commits by type while scanning.
31284
+ *
31285
+ * Truncation lives at the message level above this helper — the
31286
+ * caller has already shortened `text` to the available room. We just
31287
+ * split on the parsed prefix length and emit two spans. If the
31288
+ * shortened text is too narrow to include the full prefix (e.g. a
31289
+ * tight panel that cut into `feat`), we fall back to a single plain
31290
+ * span so the partial prefix doesn't read as a malformed colored
31291
+ * fragment.
31292
+ *
31293
+ * Returns the spans flat so the caller can splat them into the row's
31294
+ * outer Text alongside other segments without an extra wrapper.
31295
+ */
31296
+ function renderTypedSubject(h, Text, text, theme, key) {
31297
+ const parsed = parseConventionalCommitPrefix(text);
31298
+ if (!parsed) {
31299
+ return [h(Text, { key: `${key}-msg` }, text)];
31300
+ }
31301
+ if (text.length < parsed.prefix.length) {
31302
+ return [h(Text, { key: `${key}-msg` }, text)];
31303
+ }
31304
+ const color = getConventionalCommitColor(parsed, theme);
31305
+ return [
31306
+ h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
31307
+ h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
31308
+ ];
31309
+ }
29076
31310
  /**
29077
31311
  * Render `LaneSegment[]` as a flat list of Text spans, one per lane
29078
31312
  * (#791 stage 2). Each segment paints in its lane's palette color so
@@ -29118,8 +31352,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
29118
31352
  * Truncation is per-segment so the variable-length message field gets
29119
31353
  * the leftover budget after fixed segments are accounted for.
29120
31354
  */
29121
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments, isRecent = false) {
29122
- const refs = formatInkRefLabels(commit.refs);
31355
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
29123
31356
  // Total cells available to the row content. Earlier revisions used a
29124
31357
  // hardcoded 140 here, which let row content overflow whenever the
29125
31358
  // panel was narrower than that — Ink would wrap onto a second visual
@@ -29127,7 +31360,19 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
29127
31360
  // continuation rather than its own commit (#830). Subtracting 4
29128
31361
  // accounts for the panel's left + right border + 1-cell padding.
29129
31362
  const totalWidth = Math.max(20, panelWidth - 4);
29130
- const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
31363
+ const dateText = pickDateText(commit, density, fullGraph, bucketed, now);
31364
+ const dateSegmentWidth = dateText ? dateText.length + 1 : 0;
31365
+ // Branch chip prefix — only renders in full-graph mode so compact
31366
+ // (scan) mode stays minimal. Chip occupies cells immediately after
31367
+ // the shortHash and before the message; truncation math reserves
31368
+ // its width before sizing the message column. Trailing refs filter
31369
+ // out whatever the chip already shows so the row doesn't print
31370
+ // `[main] feat: x [HEAD -> main]` with the same info on both ends.
31371
+ const chip = fullGraph
31372
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
31373
+ : { node: null, width: 0, chip: undefined };
31374
+ const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31375
+ const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
29131
31376
  // Refs trail the message and shrink first when the row is narrow:
29132
31377
  // the user can always see the full ref list in the inspector, so
29133
31378
  // the headline subject keeps priority over decoration.
@@ -29165,7 +31410,81 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
29165
31410
  // the primary cue but boldness makes the row read as "this is
29166
31411
  // worth looking at" even without color.
29167
31412
  bold: selected || isRecent,
29168
- }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
31413
+ }, commit.shortHash), ' ',
31414
+ // Date column drops out entirely at `tight` density — no spacer
31415
+ // either, so the message column slides left into the freed cells.
31416
+ dateText
31417
+ ? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
31418
+ : null,
31419
+ // Branch chip prefix (full-graph mode only) lands right before the
31420
+ // message so the eye reads "branch · subject" as a unit.
31421
+ chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
31422
+ }
31423
+ /**
31424
+ * Stacked variant used at `rowMode='stacked'` (rail tier). Each
31425
+ * commit takes two lines so the message never has to share its row
31426
+ * with the date / refs / hash on a sub-90-cell terminal:
31427
+ * line 1: graph · shortHash · subject
31428
+ * line 2: dim padding · date · refs
31429
+ *
31430
+ * Selection styling lives on the line-1 outer span; the secondary
31431
+ * line stays dim regardless of selection so it doesn't pull the eye
31432
+ * away from the subject.
31433
+ */
31434
+ function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
31435
+ const totalWidth = Math.max(20, panelWidth - 4);
31436
+ const accent = theme.noColor ? undefined : theme.colors.accent;
31437
+ const muted = theme.noColor ? undefined : theme.colors.muted;
31438
+ const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
31439
+ // Line 1 — subject row. Mostly mirrors the single-line layout but
31440
+ // skips the date and refs so the message has the whole tail to
31441
+ // itself. Branch chip rides between the hash and the subject the
31442
+ // same way as the single-line variant, but only in full-graph mode.
31443
+ const recentMarkerWidth = isRecent ? 2 : 0;
31444
+ const chip = fullGraph
31445
+ ? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
31446
+ : { node: null, width: 0, chip: undefined };
31447
+ const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
31448
+ const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
31449
+ const graphChildren = laneSegments && !theme.ascii
31450
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
31451
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
31452
+ const lineOne = h(Text, {
31453
+ key: `${commit.hash}-${index}-l1`,
31454
+ backgroundColor: selectedBg,
31455
+ inverse: selected,
31456
+ }, ...graphChildren, ' ', isRecent
31457
+ ? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
31458
+ : null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
31459
+ // Line 2 — metadata row, padded to align with the start of the
31460
+ // shortHash on line 1 so the eye still groups them as one commit.
31461
+ // Selection background does not extend here so we don't get a thick
31462
+ // double-row highlight on a tight terminal. Trailing refs are
31463
+ // filtered against the chip so we don't repeat the branch tip both
31464
+ // as a leading chip and a trailing label.
31465
+ const indent = ' '.repeat(graphWidth + 1);
31466
+ const dateText = formatCompactRelativeDate(commit.date, now);
31467
+ const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
31468
+ const metaRoom = Math.max(8, totalWidth - indent.length - (dateText ? dateText.length + 1 : 0));
31469
+ const refsTrunc = refs ? truncateCells(refs, metaRoom) : '';
31470
+ // If both pieces are empty (date unparseable + no refs), show a
31471
+ // bullet so the row's structure still reads as two-line and the
31472
+ // user doesn't think they hit a render bug.
31473
+ const metaContent = dateText || refsTrunc
31474
+ ? [
31475
+ dateText ? h(Text, { key: `${commit.hash}-${index}-l2-date` }, dateText) : null,
31476
+ dateText && refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-sep` }, ' ') : null,
31477
+ refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-refs` }, refsTrunc) : null,
31478
+ ].filter(Boolean)
31479
+ : [h(Text, { key: `${commit.hash}-${index}-l2-empty` }, '·')];
31480
+ const lineTwo = h(Text, {
31481
+ key: `${commit.hash}-${index}-l2`,
31482
+ dimColor: true,
31483
+ }, indent, ...metaContent);
31484
+ return h(Box, {
31485
+ key: `${commit.hash}-${index}-stack`,
31486
+ flexDirection: 'column',
31487
+ }, lineOne, lineTwo);
29169
31488
  }
29170
31489
  /**
29171
31490
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -29194,7 +31513,7 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
29194
31513
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
29195
31514
  }, truncateCells(label, 140));
29196
31515
  }
29197
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
31516
+ function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = new Date()) {
29198
31517
  const { Box, Text } = components;
29199
31518
  const focused = state.focus === 'commits';
29200
31519
  const worktree = context.worktree;
@@ -29210,8 +31529,26 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
29210
31529
  const showPendingRow = worktreeDirty &&
29211
31530
  !state.filter &&
29212
31531
  state.selectedIndex === 0;
29213
- const listRows = Math.max(3, bodyRows - (showPendingRow ? 5 : 4));
29214
- const visible = getVisibleLogInkHistory(state, listRows);
31532
+ // Stacked rows take two terminal lines each, so the visible item
31533
+ // budget is halved before the pending-row / chrome subtraction.
31534
+ // Full-graph mode injects a spacer row after every commit for
31535
+ // comfortable rhythm — the data-layer items still count 1 per row,
31536
+ // so the listRows budget passes straight through; the spacer rows
31537
+ // just consume some of that budget instead of additional commits.
31538
+ // Date bucketing is the new way the surface communicates "when" —
31539
+ // headers replace the per-row date column whenever the result set
31540
+ // is chronological (no active filter) AND the user has bucketing
31541
+ // enabled in `logTui.dateBucketing`. The filter check is the second
31542
+ // guardrail: even with bucketing enabled, an active search filter
31543
+ // shuffles commits by relevance so the adjacent-bucket invariant
31544
+ // breaks down and the dividers would read as noise.
31545
+ const fullGraphSpacing = state.fullGraph && !state.filter;
31546
+ const dateBucketingNow = !dateBucketingEnabled || state.filter ? undefined : now;
31547
+ const chromeRows = showPendingRow ? 5 : 4;
31548
+ const listRows = rowMode === 'stacked'
31549
+ ? Math.max(2, Math.floor((bodyRows - chromeRows) / 2))
31550
+ : Math.max(3, bodyRows - chromeRows);
31551
+ const visible = getVisibleLogInkHistory(state, listRows, { fullGraphSpacing, dateBucketingNow });
29215
31552
  const loadState = loadingMoreCommits
29216
31553
  ? 'loading older commits'
29217
31554
  : hasMoreCommits
@@ -29246,23 +31583,51 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
29246
31583
  totalCommits: state.commits.length,
29247
31584
  }))
29248
31585
  : visible.items.map((item, index) => {
31586
+ if (item.type === 'bucket-header') {
31587
+ // Section divider — `── Today ────────────`. The label is
31588
+ // bold to anchor the eye, the surrounding rule is dim so
31589
+ // the divider reads as chrome rather than competing with
31590
+ // commit content. Rule fills the panel's interior width
31591
+ // (minus border + padding); the label rides inside it.
31592
+ const contentWidth = Math.max(10, width - 4);
31593
+ const labelCells = cellWidth(item.label) + 2; // pad the label with surrounding spaces
31594
+ const ruleAfter = Math.max(0, contentWidth - 3 - labelCells);
31595
+ return h(Text, {
31596
+ key: `bucket-${index}-${item.label}`,
31597
+ dimColor: true,
31598
+ }, h(Text, undefined, '── '), h(Text, { bold: true }, item.label), h(Text, undefined, ' '), h(Text, undefined, '─'.repeat(ruleAfter)));
31599
+ }
29249
31600
  if (item.type === 'graph') {
29250
- // Graph-only rows are git's lane-closure scaffolding (`|/`,
29251
- // `|\`, etc.) — they're real topology but visually they look
29252
- // like blank rows that the user might wonder if they
29253
- // accidentally skipped a commit on (#831). Render dim-on-dim
29254
- // so they retreat as connectors rather than competing with
29255
- // commit rows for the eye's attention.
31601
+ // Graph-only rows split into two visual categories:
31602
+ //
31603
+ // - git's own lane-closure scaffolding (`|/`, `|\`, etc.)
31604
+ // stays dim-on-dim so it reads as connector chrome that
31605
+ // recedes behind the commits it joins (#831). The eye
31606
+ // should never confuse a fork/close row for a commit
31607
+ // somebody accidentally skipped.
31608
+ //
31609
+ // - synthetic spacers we inject between linear commits
31610
+ // (`spacer: true`) render at FULL lane brightness so the
31611
+ // trunk lane bar visibly connects consecutive commits.
31612
+ // They are explicitly NOT scaffolding — they exist to
31613
+ // make linear-history rhythm read as one continuous lane.
31614
+ const isSpacer = item.spacer === true;
29256
31615
  if (item.laneSegments && !theme.ascii) {
29257
- return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
31616
+ return h(Text, {
31617
+ key: `graph-${index}-${item.graph}`,
31618
+ dimColor: !isSpacer,
31619
+ }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: !isSpacer }));
29258
31620
  }
29259
31621
  return h(Text, {
29260
31622
  key: `graph-${index}-${item.graph}`,
29261
31623
  color: theme.noColor ? undefined : theme.colors.muted,
29262
- dimColor: true,
31624
+ dimColor: !isSpacer,
29263
31625
  }, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
29264
31626
  }
29265
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments, recentCommitsSet.has(item.commit.hash));
31627
+ if (rowMode === 'stacked') {
31628
+ 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));
31629
+ }
31630
+ 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));
29266
31631
  }));
29267
31632
  }
29268
31633
 
@@ -29637,6 +32002,138 @@ function wrapErrorMessage(message, maxWidth) {
29637
32002
  return lines;
29638
32003
  }
29639
32004
 
32005
+ /**
32006
+ * Issues triage surface (#882 phase 3). Read-only list view rendered
32007
+ * in the main panel when `state.activeView === 'issues'`. Mirrors the
32008
+ * branches / tags surface pattern: pure renderer, no hooks, no async.
32009
+ * Data flows in via `context.issueList`; the cursor position lives at
32010
+ * `state.selectedIssueIndex`.
32011
+ *
32012
+ * Per-row actions (assign, label, comment, close) and AI summarize
32013
+ * land in phase 4-6. This phase ships navigation only.
32014
+ */
32015
+ function stateColor$1(theme, state) {
32016
+ if (theme.noColor)
32017
+ return undefined;
32018
+ switch (state.toUpperCase()) {
32019
+ case 'OPEN':
32020
+ return theme.colors.success;
32021
+ case 'CLOSED':
32022
+ return theme.colors.danger;
32023
+ default:
32024
+ return theme.colors.muted;
32025
+ }
32026
+ }
32027
+ function matchesIssueFilter(issue, filter) {
32028
+ if (!filter)
32029
+ return true;
32030
+ return matchesPromotedFilter([
32031
+ `#${issue.number}`,
32032
+ issue.title,
32033
+ issue.author || '',
32034
+ ...(issue.labels || []),
32035
+ ...(issue.assignees || []),
32036
+ ], filter);
32037
+ }
32038
+ function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
32039
+ const { Box, Text } = components;
32040
+ const focused = state.focus === 'commits';
32041
+ const overview = context.issueList;
32042
+ const loading = isLogInkContextKeyLoading(contextStatus, 'issueList');
32043
+ // Resolve the "what should the panel say" headline first, then fan
32044
+ // out to the row body. The chrome (border + title + headerRight) is
32045
+ // identical across loading / unavailable / unauthenticated / empty
32046
+ // / populated states; only the body changes.
32047
+ let headerRight = '';
32048
+ let bodyLines = [];
32049
+ if (loading || !overview) {
32050
+ headerRight = 'loading issues';
32051
+ bodyLines = [
32052
+ h(Text, { key: 'issues-loading', dimColor: true }, formatLogInkLoading({ resource: 'issues' })),
32053
+ ];
32054
+ }
32055
+ else if (!overview.available) {
32056
+ headerRight = 'unavailable';
32057
+ bodyLines = [
32058
+ h(Text, { key: 'issues-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Issues' })),
32059
+ ];
32060
+ }
32061
+ else if (!overview.authenticated) {
32062
+ headerRight = 'gh not authenticated';
32063
+ bodyLines = [
32064
+ h(Text, { key: 'issues-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Issues' })),
32065
+ ];
32066
+ }
32067
+ else if (overview.message && !overview.issues) {
32068
+ headerRight = 'error';
32069
+ bodyLines = [
32070
+ h(Text, { key: 'issues-error', dimColor: true, color: theme.noColor ? undefined : theme.colors.danger }, overview.message),
32071
+ ];
32072
+ }
32073
+ else {
32074
+ const all = overview.issues || [];
32075
+ const visible = state.filter
32076
+ ? all.filter((issue) => matchesIssueFilter(issue, state.filter))
32077
+ : all;
32078
+ const selected = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
32079
+ const listRows = Math.max(4, bodyRows - 4);
32080
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
32081
+ const windowed = visible.slice(startIndex, startIndex + listRows);
32082
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
32083
+ const presetLabel = `▼ ${ISSUE_FILTER_LABELS[state.selectedIssueFilter]}`;
32084
+ const repoLabel = overview.repository
32085
+ ? `${overview.repository.owner}/${overview.repository.name}`
32086
+ : '';
32087
+ headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
32088
+ if (visible.length === 0) {
32089
+ bodyLines = [
32090
+ h(Text, { key: 'issues-empty', dimColor: true }, formatLogInkIssuesEmpty({ filter: state.filter })),
32091
+ ];
32092
+ }
32093
+ else {
32094
+ // Column widths derived from the visible window so columns stay
32095
+ // aligned without one outlier title pushing the rest sideways.
32096
+ // Capped to keep the title column from being squeezed out on
32097
+ // narrow terminals.
32098
+ const numberColWidth = Math.min(6, Math.max(...windowed.map((i) => `#${i.number}`.length), 3));
32099
+ const authorColWidth = Math.min(16, Math.max(...windowed.map((i) => (i.author || '').length), 4));
32100
+ bodyLines = windowed.map((issue, offset) => {
32101
+ const index = startIndex + offset;
32102
+ const isSelected = index === selected;
32103
+ const cursor = isSelected ? '>' : ' ';
32104
+ const numStr = `#${issue.number}`.padEnd(numberColWidth);
32105
+ const stateStr = issue.state.toLowerCase().padEnd(6);
32106
+ const authorStr = (issue.author || '').padEnd(authorColWidth);
32107
+ const commentsStr = typeof issue.comments === 'number' && issue.comments > 0
32108
+ ? ` ${issue.comments}c`
32109
+ : '';
32110
+ // The title column gets whatever is left after the prefix
32111
+ // columns. Truncate so the row stays a single visual line.
32112
+ const head = `${cursor} `;
32113
+ const prefix = `${numStr} ${stateStr} ${authorStr} `;
32114
+ const titleBudget = Math.max(8, width - 4 - head.length - prefix.length - commentsStr.length);
32115
+ const titleStr = truncateCells(issue.title, titleBudget);
32116
+ const labelStr = (issue.labels || []).length
32117
+ ? ` [${(issue.labels || []).join(' ')}]`
32118
+ : '';
32119
+ return h(Text, {
32120
+ key: `issue-${index}`,
32121
+ bold: isSelected,
32122
+ dimColor: !isSelected,
32123
+ }, 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));
32124
+ });
32125
+ }
32126
+ }
32127
+ return h(Box, {
32128
+ borderColor: focusBorderColor(theme, focused),
32129
+ borderStyle: theme.borderStyle,
32130
+ flexDirection: 'column',
32131
+ flexShrink: 0,
32132
+ paddingX: 1,
32133
+ width,
32134
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Issues', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
32135
+ }
32136
+
29640
32137
  /**
29641
32138
  * Normalize gh's two parallel signals (`status` for in-flight check
29642
32139
  * runs, `conclusion` for completed runs and status contexts) into a
@@ -29901,6 +32398,205 @@ function renderPullRequestSurface(h, components, state, context, contextStatus,
29901
32398
  : []));
29902
32399
  }
29903
32400
 
32401
+ /**
32402
+ * Pull-request triage surface (#882 phase 3). Read-only list view
32403
+ * rendered in the main panel when `state.activeView ===
32404
+ * 'pull-request-triage'`. Distinct from the existing single-PR action
32405
+ * panel (`'pull-request'`, chord `gp`) — this is the multi-PR list
32406
+ * surface (chord `gP`).
32407
+ *
32408
+ * Pure renderer; data flows in via `context.pullRequestList`. Per-row
32409
+ * actions (merge, approve, request-changes, close, comment) and AI
32410
+ * summarize land in phase 4-6.
32411
+ */
32412
+ function stateColor(theme, state, isDraft) {
32413
+ if (theme.noColor)
32414
+ return undefined;
32415
+ if (isDraft)
32416
+ return theme.colors.muted;
32417
+ switch (state.toUpperCase()) {
32418
+ case 'OPEN':
32419
+ return theme.colors.success;
32420
+ case 'CLOSED':
32421
+ return theme.colors.danger;
32422
+ case 'MERGED':
32423
+ return theme.colors.accent;
32424
+ default:
32425
+ return theme.colors.muted;
32426
+ }
32427
+ }
32428
+ function reviewGlyph(decision) {
32429
+ switch (decision) {
32430
+ case 'APPROVED':
32431
+ return '✓';
32432
+ case 'CHANGES_REQUESTED':
32433
+ return '✗';
32434
+ case 'REVIEW_REQUIRED':
32435
+ return '?';
32436
+ default:
32437
+ return ' ';
32438
+ }
32439
+ }
32440
+ function mergeableGlyph(mergeStateStatus, mergeable) {
32441
+ if (mergeStateStatus === 'CLEAN')
32442
+ return '●';
32443
+ if (mergeStateStatus === 'BLOCKED')
32444
+ return '●';
32445
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
32446
+ return '●';
32447
+ if (mergeStateStatus === 'BEHIND')
32448
+ return '●';
32449
+ if (mergeStateStatus === 'UNSTABLE')
32450
+ return '●';
32451
+ return '·';
32452
+ }
32453
+ function mergeableColor(theme, mergeStateStatus, mergeable) {
32454
+ if (theme.noColor)
32455
+ return undefined;
32456
+ if (mergeStateStatus === 'CLEAN')
32457
+ return theme.colors.success;
32458
+ if (mergeStateStatus === 'BLOCKED' || mergeStateStatus === 'UNSTABLE')
32459
+ return theme.colors.warning;
32460
+ if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
32461
+ return theme.colors.danger;
32462
+ if (mergeStateStatus === 'BEHIND')
32463
+ return theme.colors.accent;
32464
+ return theme.colors.muted;
32465
+ }
32466
+ function reviewColor(theme, decision) {
32467
+ if (theme.noColor)
32468
+ return undefined;
32469
+ switch (decision) {
32470
+ case 'APPROVED':
32471
+ return theme.colors.success;
32472
+ case 'CHANGES_REQUESTED':
32473
+ return theme.colors.danger;
32474
+ case 'REVIEW_REQUIRED':
32475
+ return theme.colors.warning;
32476
+ default:
32477
+ return theme.colors.muted;
32478
+ }
32479
+ }
32480
+ function matchesPullRequestFilter(pr, filter) {
32481
+ if (!filter)
32482
+ return true;
32483
+ return matchesPromotedFilter([
32484
+ `#${pr.number}`,
32485
+ pr.title,
32486
+ pr.author || '',
32487
+ pr.headRefName,
32488
+ pr.baseRefName,
32489
+ ...(pr.labels || []),
32490
+ ...(pr.assignees || []),
32491
+ ], filter);
32492
+ }
32493
+ function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
32494
+ const { Box, Text } = components;
32495
+ const focused = state.focus === 'commits';
32496
+ const overview = context.pullRequestList;
32497
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequestList');
32498
+ let headerRight = '';
32499
+ let bodyLines = [];
32500
+ if (loading || !overview) {
32501
+ headerRight = 'loading pull requests';
32502
+ bodyLines = [
32503
+ h(Text, { key: 'pr-triage-loading', dimColor: true }, formatLogInkLoading({ resource: 'pull requests' })),
32504
+ ];
32505
+ }
32506
+ else if (!overview.available) {
32507
+ headerRight = 'unavailable';
32508
+ bodyLines = [
32509
+ h(Text, { key: 'pr-triage-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Pull requests' })),
32510
+ ];
32511
+ }
32512
+ else if (!overview.authenticated) {
32513
+ headerRight = 'gh not authenticated';
32514
+ bodyLines = [
32515
+ h(Text, { key: 'pr-triage-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Pull requests' })),
32516
+ ];
32517
+ }
32518
+ else if (overview.message && !overview.pullRequests) {
32519
+ headerRight = 'error';
32520
+ bodyLines = [
32521
+ h(Text, {
32522
+ key: 'pr-triage-error',
32523
+ dimColor: true,
32524
+ color: theme.noColor ? undefined : theme.colors.danger,
32525
+ }, overview.message),
32526
+ ];
32527
+ }
32528
+ else {
32529
+ const all = overview.pullRequests || [];
32530
+ const visible = state.filter
32531
+ ? all.filter((pr) => matchesPullRequestFilter(pr, state.filter))
32532
+ : all;
32533
+ const selected = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
32534
+ const listRows = Math.max(4, bodyRows - 4);
32535
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
32536
+ const windowed = visible.slice(startIndex, startIndex + listRows);
32537
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
32538
+ const presetLabel = `▼ ${PULL_REQUEST_FILTER_LABELS[state.selectedPullRequestFilter]}`;
32539
+ const repoLabel = overview.repository
32540
+ ? `${overview.repository.owner}/${overview.repository.name}`
32541
+ : '';
32542
+ headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
32543
+ if (visible.length === 0) {
32544
+ bodyLines = [
32545
+ h(Text, { key: 'pr-triage-empty', dimColor: true }, formatLogInkPullRequestTriageEmpty({ filter: state.filter })),
32546
+ ];
32547
+ }
32548
+ else {
32549
+ const numberColWidth = Math.min(6, Math.max(...windowed.map((p) => `#${p.number}`.length), 3));
32550
+ const authorColWidth = Math.min(16, Math.max(...windowed.map((p) => (p.author || '').length), 4));
32551
+ const branchColWidth = Math.min(24, Math.max(...windowed.map((p) => p.headRefName.length), 6));
32552
+ bodyLines = windowed.map((pr, offset) => {
32553
+ const index = startIndex + offset;
32554
+ const isSelected = index === selected;
32555
+ const cursor = isSelected ? '>' : ' ';
32556
+ const numStr = `#${pr.number}`.padEnd(numberColWidth);
32557
+ const stateLabel = pr.isDraft ? 'draft' : pr.state.toLowerCase();
32558
+ const stateStr = stateLabel.padEnd(6);
32559
+ const mergeStr = mergeableGlyph(pr.mergeStateStatus, pr.mergeable);
32560
+ const reviewStr = reviewGlyph(pr.reviewDecision);
32561
+ const authorStr = (pr.author || '').padEnd(authorColWidth);
32562
+ const branchStr = truncateCells(pr.headRefName, branchColWidth).padEnd(branchColWidth);
32563
+ const labelStr = (pr.labels || []).length
32564
+ ? ` [${(pr.labels || []).join(' ')}]`
32565
+ : '';
32566
+ const head = `${cursor} `;
32567
+ const prefix = `${numStr} ${stateStr} ${mergeStr} ${reviewStr} ${authorStr} ${branchStr} `;
32568
+ const titleBudget = Math.max(8, width - 4 - head.length - prefix.length);
32569
+ const titleStr = truncateCells(pr.title, titleBudget);
32570
+ return h(Text, {
32571
+ key: `pr-triage-${index}`,
32572
+ bold: isSelected,
32573
+ dimColor: !isSelected,
32574
+ }, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, {
32575
+ color: stateColor(theme, pr.state, pr.isDraft),
32576
+ dimColor: !isSelected,
32577
+ }, stateStr + ' '), h(Text, {
32578
+ color: mergeableColor(theme, pr.mergeStateStatus, pr.mergeable),
32579
+ dimColor: !isSelected,
32580
+ }, mergeStr + ' '), h(Text, {
32581
+ color: reviewColor(theme, pr.reviewDecision),
32582
+ dimColor: !isSelected,
32583
+ }, reviewStr + ' '), h(Text, {
32584
+ color: theme.noColor ? undefined : theme.colors.accent,
32585
+ dimColor: !isSelected,
32586
+ }, authorStr + ' '), h(Text, { dimColor: true }, branchStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr));
32587
+ });
32588
+ }
32589
+ }
32590
+ return h(Box, {
32591
+ borderColor: focusBorderColor(theme, focused),
32592
+ borderStyle: theme.borderStyle,
32593
+ flexDirection: 'column',
32594
+ flexShrink: 0,
32595
+ paddingX: 1,
32596
+ width,
32597
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull requests', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
32598
+ }
32599
+
29904
32600
  /**
29905
32601
  * Reflog surface (#781). Renders `git reflog` chronologically — every
29906
32602
  * HEAD movement (commit, checkout, merge, reset, …) with relative time,
@@ -30480,7 +33176,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
30480
33176
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
30481
33177
  * of #890. No behavior change.
30482
33178
  */
30483
- 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) {
33179
+ 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) {
30484
33180
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
30485
33181
  // detail) when active, because the content — multiple commit groups
30486
33182
  // with file lists, rationale, hunks — needs the full center width
@@ -30524,13 +33220,19 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
30524
33220
  if (state.activeView === 'pull-request') {
30525
33221
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30526
33222
  }
33223
+ if (state.activeView === 'pull-request-triage') {
33224
+ return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
33225
+ }
33226
+ if (state.activeView === 'issues') {
33227
+ return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
33228
+ }
30527
33229
  if (state.activeView === 'conflicts') {
30528
33230
  return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30529
33231
  }
30530
33232
  if (state.activeView === 'changelog') {
30531
33233
  return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
30532
33234
  }
30533
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
33235
+ return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
30534
33236
  }
30535
33237
 
30536
33238
  /**
@@ -30628,6 +33330,230 @@ function formatStashPreview(stash, options = {}) {
30628
33330
  }
30629
33331
  return out;
30630
33332
  }
33333
+ /* ------------------------- detail-section helpers ----------------------- */
33334
+ /**
33335
+ * Render the first `maxLines` non-empty lines of an issue / PR body
33336
+ * as preview lines. Returns an empty array when the body itself is
33337
+ * empty (or whitespace only) so callers can `out.push(...body(...))`
33338
+ * without an extra guard. Trailer appears only when content was
33339
+ * actually truncated.
33340
+ */
33341
+ function bodyExcerptLines(body, maxLines) {
33342
+ if (!body.trim())
33343
+ return [];
33344
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
33345
+ // Drop leading blanks so the excerpt opens on the first real line
33346
+ // rather than rendering an awkward "blank line, then body".
33347
+ while (lines.length > 0 && !lines[0].trim())
33348
+ lines.shift();
33349
+ const shown = lines.slice(0, maxLines);
33350
+ const truncated = lines.length > maxLines;
33351
+ const out = [
33352
+ heading('Body'),
33353
+ ...shown.map((l) => line(l)),
33354
+ ];
33355
+ if (truncated) {
33356
+ out.push(dim(`… ${lines.length - maxLines} more line(s)`));
33357
+ }
33358
+ return out;
33359
+ }
33360
+ function shortenLine(value, maxLength) {
33361
+ const flattened = value.replace(/\s+/g, ' ').trim();
33362
+ if (flattened.length <= maxLength)
33363
+ return flattened;
33364
+ return `${flattened.slice(0, Math.max(0, maxLength - 1))}…`;
33365
+ }
33366
+ function commentsSection(comments, maxShown) {
33367
+ if (comments.length === 0)
33368
+ return [];
33369
+ const recent = comments.slice(-3);
33370
+ const out = [heading(`Comments (${comments.length})`)];
33371
+ for (const comment of recent) {
33372
+ const who = comment.author || 'anonymous';
33373
+ out.push(line(`@${who}: ${shortenLine(comment.body, 80)}`));
33374
+ }
33375
+ if (comments.length > recent.length) {
33376
+ out.push(dim(`… ${comments.length - recent.length} earlier comment(s)`));
33377
+ }
33378
+ return out;
33379
+ }
33380
+ function reviewsSection(reviews) {
33381
+ if (reviews.length === 0)
33382
+ return [];
33383
+ const out = [heading(`Reviews (${reviews.length})`)];
33384
+ for (const review of reviews) {
33385
+ const who = review.author || 'anonymous';
33386
+ const stateLabel = (review.state || 'commented').toLowerCase().replace(/_/g, ' ');
33387
+ const inlineBody = review.body ? ` — ${shortenLine(review.body, 60)}` : '';
33388
+ out.push(line(`@${who} (${stateLabel})${inlineBody}`));
33389
+ }
33390
+ return out;
33391
+ }
33392
+ function statusChecksSection(checks) {
33393
+ if (checks.length === 0)
33394
+ return [];
33395
+ const grouped = {
33396
+ success: 0,
33397
+ failure: 0,
33398
+ pending: 0,
33399
+ other: 0,
33400
+ };
33401
+ for (const check of checks) {
33402
+ const result = check.conclusion?.toLowerCase() ?? check.status?.toLowerCase() ?? '';
33403
+ if (result === 'success')
33404
+ grouped.success++;
33405
+ else if (result === 'failure' || result === 'cancelled' || result === 'timed_out')
33406
+ grouped.failure++;
33407
+ else if (result === 'pending' || result === 'queued' || result === 'in_progress')
33408
+ grouped.pending++;
33409
+ else
33410
+ grouped.other++;
33411
+ }
33412
+ const parts = [];
33413
+ if (grouped.success)
33414
+ parts.push(`${grouped.success} pass`);
33415
+ if (grouped.failure)
33416
+ parts.push(`${grouped.failure} fail`);
33417
+ if (grouped.pending)
33418
+ parts.push(`${grouped.pending} pending`);
33419
+ if (grouped.other)
33420
+ parts.push(`${grouped.other} other`);
33421
+ return [
33422
+ heading(`Checks (${checks.length})`),
33423
+ line(parts.join(' · ')),
33424
+ ];
33425
+ }
33426
+ /* -------------------------------- issue -------------------------------- */
33427
+ /**
33428
+ * Format an issue triage entry into preview lines (#882 phase 3,
33429
+ * body + comments added in the inspector-hydration follow-up).
33430
+ * The list payload from `gh issue list --json` carries metadata
33431
+ * only; the optional `detail` argument is filled by the runtime's
33432
+ * debounced hydration effect when the cursor rests on a row, and
33433
+ * unlocks the body / comments sections.
33434
+ */
33435
+ function formatIssueTriagePreview(issue, detail) {
33436
+ if (!issue) {
33437
+ return [dim('Select an issue to preview.')];
33438
+ }
33439
+ const out = [
33440
+ heading(`#${issue.number} · ${issue.title}`),
33441
+ blank(),
33442
+ line(`State: ${issue.state.toLowerCase()}`),
33443
+ ];
33444
+ if (issue.author)
33445
+ out.push(line(`Author: ${issue.author}`));
33446
+ if (issue.assignees && issue.assignees.length > 0) {
33447
+ out.push(line(`Assigned: ${issue.assignees.join(', ')}`));
33448
+ }
33449
+ if (issue.labels && issue.labels.length > 0) {
33450
+ out.push(line(`Labels: ${issue.labels.join(', ')}`));
33451
+ }
33452
+ if (typeof issue.comments === 'number') {
33453
+ out.push(line(`Comments: ${issue.comments}`));
33454
+ }
33455
+ out.push(blank());
33456
+ if (issue.createdAt)
33457
+ out.push(line(`Created: ${issue.createdAt}`));
33458
+ if (issue.updatedAt)
33459
+ out.push(line(`Updated: ${issue.updatedAt}`));
33460
+ out.push(blank());
33461
+ out.push(dim(issue.url));
33462
+ // Hydrated sections (body + recent comments). Inserted only when
33463
+ // the runtime has finished the per-cursor-rest detail fetch and
33464
+ // populated the cache.
33465
+ if (detail) {
33466
+ const body = bodyExcerptLines(detail.body, 6);
33467
+ if (body.length > 0) {
33468
+ out.push(blank());
33469
+ out.push(...body);
33470
+ }
33471
+ const comments = commentsSection(detail.comments);
33472
+ if (comments.length > 0) {
33473
+ out.push(blank());
33474
+ out.push(...comments);
33475
+ }
33476
+ }
33477
+ else if (typeof issue.comments === 'number' && issue.comments > 0) {
33478
+ // Pre-hydration affordance — tell the user the body / comments
33479
+ // section is coming, so a 250ms wait doesn't look like a bug.
33480
+ out.push(blank());
33481
+ out.push(dim('Loading body + comments…'));
33482
+ }
33483
+ return out;
33484
+ }
33485
+ /* ----------------------------- pull request ---------------------------- */
33486
+ /**
33487
+ * Format a pull-request triage entry into preview lines (#882 phase 3,
33488
+ * body / comments / reviews / checks added in the inspector-hydration
33489
+ * follow-up). Optional `detail` argument is filled by the runtime's
33490
+ * debounced hydration effect when the cursor rests on a row.
33491
+ */
33492
+ function formatPullRequestTriagePreview(pr, detail) {
33493
+ if (!pr) {
33494
+ return [dim('Select a pull request to preview.')];
33495
+ }
33496
+ const out = [
33497
+ heading(`#${pr.number} · ${pr.title}`),
33498
+ blank(),
33499
+ line(`State: ${pr.isDraft ? 'draft' : pr.state.toLowerCase()}`),
33500
+ ];
33501
+ if (pr.author)
33502
+ out.push(line(`Author: ${pr.author}`));
33503
+ out.push(line(`Branches: ${pr.headRefName} → ${pr.baseRefName}`));
33504
+ if (pr.mergeable || pr.mergeStateStatus) {
33505
+ const merge = [pr.mergeable, pr.mergeStateStatus].filter(Boolean).join(' / ');
33506
+ out.push(line(`Mergeable: ${merge.toLowerCase()}`));
33507
+ }
33508
+ if (pr.reviewDecision) {
33509
+ out.push(line(`Review: ${pr.reviewDecision.toLowerCase().replace(/_/g, ' ')}`));
33510
+ }
33511
+ if (pr.assignees && pr.assignees.length > 0) {
33512
+ out.push(line(`Assigned: ${pr.assignees.join(', ')}`));
33513
+ }
33514
+ if (pr.labels && pr.labels.length > 0) {
33515
+ out.push(line(`Labels: ${pr.labels.join(', ')}`));
33516
+ }
33517
+ out.push(blank());
33518
+ if (pr.createdAt)
33519
+ out.push(line(`Created: ${pr.createdAt}`));
33520
+ if (pr.updatedAt)
33521
+ out.push(line(`Updated: ${pr.updatedAt}`));
33522
+ out.push(blank());
33523
+ out.push(dim(pr.url));
33524
+ // Hydrated sections — body, status checks, reviews, comments.
33525
+ // Status checks come BEFORE reviews because failing CI is usually
33526
+ // what a triager wants to see first; reviews come second because
33527
+ // they're the human-judgment layer on top.
33528
+ if (detail) {
33529
+ const body = bodyExcerptLines(detail.body, 6);
33530
+ if (body.length > 0) {
33531
+ out.push(blank());
33532
+ out.push(...body);
33533
+ }
33534
+ const checks = statusChecksSection(detail.statusCheckRollup);
33535
+ if (checks.length > 0) {
33536
+ out.push(blank());
33537
+ out.push(...checks);
33538
+ }
33539
+ const reviews = reviewsSection(detail.reviews);
33540
+ if (reviews.length > 0) {
33541
+ out.push(blank());
33542
+ out.push(...reviews);
33543
+ }
33544
+ const comments = commentsSection(detail.comments);
33545
+ if (comments.length > 0) {
33546
+ out.push(blank());
33547
+ out.push(...comments);
33548
+ }
33549
+ }
33550
+ else {
33551
+ // Pre-hydration affordance — same as the issue preview.
33552
+ out.push(blank());
33553
+ out.push(dim('Loading body + reviews + comments…'));
33554
+ }
33555
+ return out;
33556
+ }
30631
33557
 
30632
33558
  /**
30633
33559
  * Detail / inspector / preview surface family.
@@ -30666,6 +33592,15 @@ function formatStashPreview(stash, options = {}) {
30666
33592
  * minimum from `getLogInkLayout`) so an overflowing label never wraps and
30667
33593
  * collides with the next row.
30668
33594
  */
33595
+ /**
33596
+ * Format the file-count portion of the inspector stats line. Pluralize
33597
+ * "files" only when the count is not 1 so `1 file +12/-22` reads
33598
+ * naturally instead of `1 files`.
33599
+ */
33600
+ function formatCommitStatLine(stats) {
33601
+ const label = stats.filesChanged === 1 ? 'file' : 'files';
33602
+ return `${stats.filesChanged} ${label} +${stats.insertions}/-${stats.deletions}`;
33603
+ }
30669
33604
  function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
30670
33605
  const actions = getInspectorActions(context);
30671
33606
  if (!actions.length)
@@ -30840,7 +33775,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
30840
33775
  cursorActive: focused && state.inspectorTab === 'actions',
30841
33776
  }));
30842
33777
  }
30843
- const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
33778
+ const statLine = formatCommitStatLine(detail.stats);
30844
33779
  // P5.1 — link the commit hash and each ref out to GitHub when we know
30845
33780
  // the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
30846
33781
  // whether to wrap or fall through to plain text.
@@ -30960,7 +33895,7 @@ function renderCommitDiffDetail(h, components, state, detail, loading, width, th
30960
33895
  dimColor: index > 1,
30961
33896
  }, truncateCells(line, width - 4))));
30962
33897
  }
30963
- const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
33898
+ const statLine = formatCommitStatLine(detail.stats);
30964
33899
  const headerLines = [
30965
33900
  detail.message,
30966
33901
  '',
@@ -31189,6 +34124,64 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
31189
34124
  ? null
31190
34125
  : h(Text, { key: 'commit-state', dimColor: true }, truncateCells(stateLine, width - 4)));
31191
34126
  }
34127
+ /**
34128
+ * Issue triage preview pane (#882 phase 3). Mirrors the branch / tag /
34129
+ * stash preview pattern — `renderPreviewPanel` chrome, formatter pulls
34130
+ * the cursored item via `state.selectedIssueIndex`. Shown when
34131
+ * `state.activeView === 'issues'`.
34132
+ */
34133
+ function renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
34134
+ const { Box, Text } = components;
34135
+ if (isLogInkContextKeyLoading(contextStatus, 'issueList')) {
34136
+ return renderPreviewPanel(h, { Box, Text }, 'Issue preview', [{ text: formatLogInkLoading({ resource: 'issues' }), emphasis: 'dim' }], width, theme, focused);
34137
+ }
34138
+ const all = context.issueList?.issues || [];
34139
+ const visible = state.filter
34140
+ ? all.filter((issue) => matchesPromotedFilter([
34141
+ `#${issue.number}`,
34142
+ issue.title,
34143
+ issue.author || '',
34144
+ ...(issue.labels || []),
34145
+ ...(issue.assignees || []),
34146
+ ], state.filter))
34147
+ : all;
34148
+ const index = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
34149
+ const issue = visible[index];
34150
+ const detail = issue
34151
+ ? context.issueDetailByNumber?.get(issue.number)
34152
+ : undefined;
34153
+ return renderPreviewPanel(h, { Box, Text }, 'Issue preview', formatIssueTriagePreview(issue, detail), width, theme, focused);
34154
+ }
34155
+ /**
34156
+ * Pull-request triage preview pane (#882 phase 3). Shown when
34157
+ * `state.activeView === 'pull-request-triage'`. Distinct from the
34158
+ * single-PR action panel's right pane (which renders the full
34159
+ * inspector with status checks, reviews, and action keys).
34160
+ */
34161
+ function renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
34162
+ const { Box, Text } = components;
34163
+ if (isLogInkContextKeyLoading(contextStatus, 'pullRequestList')) {
34164
+ return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', [{ text: formatLogInkLoading({ resource: 'pull requests' }), emphasis: 'dim' }], width, theme, focused);
34165
+ }
34166
+ const all = context.pullRequestList?.pullRequests || [];
34167
+ const visible = state.filter
34168
+ ? all.filter((pr) => matchesPromotedFilter([
34169
+ `#${pr.number}`,
34170
+ pr.title,
34171
+ pr.author || '',
34172
+ pr.headRefName,
34173
+ pr.baseRefName,
34174
+ ...(pr.labels || []),
34175
+ ...(pr.assignees || []),
34176
+ ], state.filter))
34177
+ : all;
34178
+ const index = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
34179
+ const pr = visible[index];
34180
+ const detail = pr
34181
+ ? context.pullRequestDetailByNumber?.get(pr.number)
34182
+ : undefined;
34183
+ return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', formatPullRequestTriagePreview(pr, detail), width, theme, focused);
34184
+ }
31192
34185
 
31193
34186
  /**
31194
34187
  * Detail-panel dispatcher. Routes to the right detail / overlay
@@ -31204,8 +34197,41 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
31204
34197
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
31205
34198
  * of #890. No behavior change.
31206
34199
  */
31207
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
34200
+ /**
34201
+ * Rail-mode inspector — shown on terminals < 100 columns when the
34202
+ * detail panel does not hold focus. The full inspector (commit body,
34203
+ * file list, actions) does not survive truncation to ~4 content cells
34204
+ * so we collapse to a stack with the panel label and the selected
34205
+ * commit's shortHash. Focus pops the panel back to its expanded
34206
+ * widths via the layout, so this renderer is only reached at rest.
34207
+ *
34208
+ * Help / overlay states are still handled by their own renderers
34209
+ * above; this short-circuit only kicks in for the regular "view the
34210
+ * commit" cases.
34211
+ */
34212
+ function renderInspectorRail(h, components, state, detail, width, theme, focused) {
34213
+ const { Box, Text } = components;
34214
+ // Prefer the loaded detail's hash (canonical) but fall back to the
34215
+ // selected list row's shortHash so the rail isn't blank on the
34216
+ // first render before getCommitDetail resolves.
34217
+ const selectedRow = getSelectedInkCommit(state);
34218
+ const hashText = detail?.hash.slice(0, 4)
34219
+ ?? selectedRow?.shortHash.slice(0, 4)
34220
+ ?? '····';
34221
+ return h(Box, {
34222
+ borderColor: focusBorderColor(theme, focused),
34223
+ borderStyle: theme.borderStyle,
34224
+ flexDirection: 'column',
34225
+ width,
34226
+ paddingX: 1,
34227
+ }, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
34228
+ }
34229
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false) {
31208
34230
  const focused = state.focus === 'detail';
34231
+ // Overlays (help / palette / input / confirmation / chord) take
34232
+ // precedence over rail because they always claim the panel's width
34233
+ // via the help-overlay layout branch — and railing those would
34234
+ // defeat their whole purpose (the user is reading them).
31209
34235
  if (state.showHelp) {
31210
34236
  return renderHelpPanel(h, components, state, width, theme, focused);
31211
34237
  }
@@ -31231,6 +34257,15 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
31231
34257
  if (state.pendingKey && !state.splitPlan) {
31232
34258
  return renderChordOverlay(h, components, state, width, theme, focused);
31233
34259
  }
34260
+ // Rail mode applies only after every overlay above has had its say
34261
+ // — those would all be unreadable at 4 cells of content. The layout
34262
+ // also clears `railed` whenever the inspector takes focus, so we
34263
+ // can safely short-circuit the per-view dispatch here without
34264
+ // worrying about hiding the panel from a user who's actively
34265
+ // reading it.
34266
+ if (railed) {
34267
+ return renderInspectorRail(h, components, state, detail, width, theme, focused);
34268
+ }
31234
34269
  // The synthetic "(+) new commit" row routes the inspector through the
31235
34270
  // worktree summary so the user sees what's staged / unstaged at a glance
31236
34271
  // — same surface as the compose view's right panel.
@@ -31271,6 +34306,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
31271
34306
  if (state.activeView === 'submodules') {
31272
34307
  return renderSubmodulePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
31273
34308
  }
34309
+ if (state.activeView === 'issues') {
34310
+ return renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
34311
+ }
34312
+ if (state.activeView === 'pull-request-triage') {
34313
+ return renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
34314
+ }
31274
34315
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
31275
34316
  }
31276
34317
 
@@ -31491,7 +34532,7 @@ function enrichFilterActionWithRectification(action, state, context) {
31491
34532
  }
31492
34533
  }
31493
34534
  function LogInkApp(deps) {
31494
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
34535
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
31495
34536
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
31496
34537
  const h = React.createElement;
31497
34538
  const { exit } = useApp();
@@ -31685,6 +34726,35 @@ function LogInkApp(deps) {
31685
34726
  return all;
31686
34727
  return all.filter((entry) => matchesPromotedFilter([entry.name, entry.path, entry.trackingBranch || '', entry.url || ''], state.filter));
31687
34728
  }, [context.submodules?.entries, state.filter]);
34729
+ // Issues + PR triage filtered lists (#882 phase 3). Same memo
34730
+ // pattern as the other promoted views — collapses per-keystroke
34731
+ // filter work to one pass per (data, filter) change.
34732
+ const filteredIssueList = React.useMemo(() => {
34733
+ const all = context.issueList?.issues || [];
34734
+ if (!state.filter)
34735
+ return all;
34736
+ return all.filter((issue) => matchesPromotedFilter([
34737
+ `#${issue.number}`,
34738
+ issue.title,
34739
+ issue.author || '',
34740
+ ...(issue.labels || []),
34741
+ ...(issue.assignees || []),
34742
+ ], state.filter));
34743
+ }, [context.issueList?.issues, state.filter]);
34744
+ const filteredPullRequestTriageList = React.useMemo(() => {
34745
+ const all = context.pullRequestList?.pullRequests || [];
34746
+ if (!state.filter)
34747
+ return all;
34748
+ return all.filter((pr) => matchesPromotedFilter([
34749
+ `#${pr.number}`,
34750
+ pr.title,
34751
+ pr.author || '',
34752
+ pr.headRefName,
34753
+ pr.baseRefName,
34754
+ ...(pr.labels || []),
34755
+ ...(pr.assignees || []),
34756
+ ], state.filter));
34757
+ }, [context.pullRequestList?.pullRequests, state.filter]);
31688
34758
  const dispatch = React.useCallback((action) => {
31689
34759
  setState((current) => applyLogInkAction(current, action));
31690
34760
  }, []);
@@ -32068,6 +35138,149 @@ function LogInkApp(deps) {
32068
35138
  active = false;
32069
35139
  };
32070
35140
  }, [git, state.activeView, context.pullRequest]);
35141
+ // Lazy-load the issue triage list (#882 phase 3, filter-aware
35142
+ // since phase 6). Fires on entry to the view AND on filter
35143
+ // preset changes (`f` cycles the preset; the dep on
35144
+ // `state.selectedIssueFilter` triggers the refetch). The
35145
+ // existing `context.issueList` guard collapses to a no-op when
35146
+ // the preset hasn't changed and data is already loaded.
35147
+ React.useEffect(() => {
35148
+ if (state.activeView !== 'issues')
35149
+ return;
35150
+ if (context.issueList)
35151
+ return;
35152
+ let active = true;
35153
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
35154
+ const filter = issueFilterForPreset(state.selectedIssueFilter);
35155
+ void safe(getIssueList(git, filter)).then((value) => {
35156
+ if (!active)
35157
+ return;
35158
+ setContext((current) => ({
35159
+ ...current,
35160
+ issueList: value,
35161
+ }));
35162
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
35163
+ });
35164
+ return () => {
35165
+ active = false;
35166
+ };
35167
+ }, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
35168
+ // Filter cycling: when the preset changes, drop the cached list
35169
+ // so the effect above re-fires with the new filter. Done as a
35170
+ // separate effect (rather than folded into the cycle reducer)
35171
+ // because the reducer is pure — fs / network side-effects live
35172
+ // in `useEffect`.
35173
+ React.useEffect(() => {
35174
+ if (state.activeView !== 'issues')
35175
+ return;
35176
+ setContext((current) => (current.issueList ? { ...current, issueList: undefined } : current));
35177
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
35178
+ // We deliberately depend ONLY on the preset — not on
35179
+ // activeView — so re-entering the view doesn't re-fire and
35180
+ // discard the just-loaded data. The activeView guard above
35181
+ // keeps us from clearing data while the user is on a
35182
+ // different surface.
35183
+ }, [state.selectedIssueFilter]);
35184
+ // Lazy-load the PR triage list (#882 phase 3, filter-aware
35185
+ // since phase 6). Same pattern as the issue effect above.
35186
+ React.useEffect(() => {
35187
+ if (state.activeView !== 'pull-request-triage')
35188
+ return;
35189
+ if (context.pullRequestList)
35190
+ return;
35191
+ let active = true;
35192
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
35193
+ const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
35194
+ void safe(getPullRequestList(git, filter)).then((value) => {
35195
+ if (!active)
35196
+ return;
35197
+ setContext((current) => ({
35198
+ ...current,
35199
+ pullRequestList: value,
35200
+ }));
35201
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
35202
+ });
35203
+ return () => {
35204
+ active = false;
35205
+ };
35206
+ }, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
35207
+ React.useEffect(() => {
35208
+ if (state.activeView !== 'pull-request-triage')
35209
+ return;
35210
+ setContext((current) => current.pullRequestList ? { ...current, pullRequestList: undefined } : current);
35211
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
35212
+ }, [state.selectedPullRequestFilter]);
35213
+ // Per-item inspector hydration (#882 follow-up to phase 6). When
35214
+ // the user rests the cursor on an issue / PR row for ~250ms, fetch
35215
+ // the body + comments (+ reviews + status checks for PRs) and
35216
+ // cache the result keyed by number. Cursoring back to a previously-
35217
+ // fetched item shows the cached entry instantly; rapid j/k
35218
+ // navigation never fires a `gh` call because the debounce timer
35219
+ // resets on every cursor move.
35220
+ //
35221
+ // The cache lives on `context.{issueDetailByNumber,
35222
+ // pullRequestDetailByNumber}` so it survives the per-keystroke
35223
+ // re-renders. It's intentionally Maps — `new Map(prev).set(k, v)`
35224
+ // keeps the immutable update story simple, and entries persist
35225
+ // until either the list is invalidated (post-mutation) or the
35226
+ // process exits.
35227
+ const DETAIL_HYDRATION_DELAY_MS = 250;
35228
+ React.useEffect(() => {
35229
+ if (state.activeView !== 'issues')
35230
+ return;
35231
+ const cursored = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
35232
+ if (!cursored)
35233
+ return;
35234
+ if (context.issueDetailByNumber?.has(cursored.number))
35235
+ return;
35236
+ let active = true;
35237
+ const timer = setTimeout(async () => {
35238
+ const result = await getIssueDetail(cursored.number);
35239
+ if (!active || !result.ok)
35240
+ return;
35241
+ setContext((current) => ({
35242
+ ...current,
35243
+ issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
35244
+ }));
35245
+ }, DETAIL_HYDRATION_DELAY_MS);
35246
+ return () => {
35247
+ active = false;
35248
+ clearTimeout(timer);
35249
+ };
35250
+ }, [
35251
+ state.activeView,
35252
+ state.selectedIssueIndex,
35253
+ filteredIssueList,
35254
+ context.issueDetailByNumber,
35255
+ ]);
35256
+ React.useEffect(() => {
35257
+ if (state.activeView !== 'pull-request-triage')
35258
+ return;
35259
+ const cursored = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
35260
+ if (!cursored)
35261
+ return;
35262
+ if (context.pullRequestDetailByNumber?.has(cursored.number))
35263
+ return;
35264
+ let active = true;
35265
+ const timer = setTimeout(async () => {
35266
+ const result = await getPullRequestDetail(cursored.number);
35267
+ if (!active || !result.ok)
35268
+ return;
35269
+ setContext((current) => ({
35270
+ ...current,
35271
+ pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
35272
+ }));
35273
+ }, DETAIL_HYDRATION_DELAY_MS);
35274
+ return () => {
35275
+ active = false;
35276
+ clearTimeout(timer);
35277
+ };
35278
+ }, [
35279
+ state.activeView,
35280
+ state.selectedPullRequestTriageIndex,
35281
+ filteredPullRequestTriageList,
35282
+ context.pullRequestDetailByNumber,
35283
+ ]);
32071
35284
  React.useEffect(() => {
32072
35285
  let active = true;
32073
35286
  async function loadDetail() {
@@ -33014,6 +36227,56 @@ function LogInkApp(deps) {
33014
36227
  const effectiveTarget = expectedTarget || target;
33015
36228
  return applyHunkPatch(git, patchText, { target: effectiveTarget });
33016
36229
  };
36230
+ // #882 phase 4 — post-mutation cache invalidation for the
36231
+ // issue / PR triage views. Each helper does two things:
36232
+ // 1. Clears the in-memory `context.issueList` /
36233
+ // `context.pullRequestList` entry so the view's `useEffect`
36234
+ // retriggers on the next render and the user sees their
36235
+ // change reflected immediately.
36236
+ // 2. Wipes the disk cache so a follow-up `coco issues` /
36237
+ // `coco prs` CLI call doesn't serve stale data from the
36238
+ // 5-minute TTL window. Sledgehammer rather than scalpel —
36239
+ // clearing per (repo, filter) tuple would require more
36240
+ // bookkeeping than the cache is worth.
36241
+ const invalidateIssueListCaches = (issueNumber) => {
36242
+ setContext((current) => {
36243
+ const next = { ...current, issueList: undefined };
36244
+ // Drop only the mutated issue's detail entry so other
36245
+ // hydrated entries survive — they're still accurate. When
36246
+ // no number is given (rare), wipe the whole detail map.
36247
+ if (current.issueDetailByNumber) {
36248
+ if (typeof issueNumber === 'number') {
36249
+ const trimmed = new Map(current.issueDetailByNumber);
36250
+ trimmed.delete(issueNumber);
36251
+ next.issueDetailByNumber = trimmed;
36252
+ }
36253
+ else {
36254
+ next.issueDetailByNumber = undefined;
36255
+ }
36256
+ }
36257
+ return next;
36258
+ });
36259
+ setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
36260
+ clearGitHubListCache();
36261
+ };
36262
+ const invalidatePullRequestListCaches = (pullRequestNumber) => {
36263
+ setContext((current) => {
36264
+ const next = { ...current, pullRequestList: undefined };
36265
+ if (current.pullRequestDetailByNumber) {
36266
+ if (typeof pullRequestNumber === 'number') {
36267
+ const trimmed = new Map(current.pullRequestDetailByNumber);
36268
+ trimmed.delete(pullRequestNumber);
36269
+ next.pullRequestDetailByNumber = trimmed;
36270
+ }
36271
+ else {
36272
+ next.pullRequestDetailByNumber = undefined;
36273
+ }
36274
+ }
36275
+ return next;
36276
+ });
36277
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
36278
+ clearGitHubListCache();
36279
+ };
33017
36280
  const handlers = {
33018
36281
  'create-branch': async () => {
33019
36282
  const name = payload?.trim();
@@ -33531,6 +36794,181 @@ function LogInkApp(deps) {
33531
36794
  return { ok: false, message: 'Comment body required' };
33532
36795
  return commentPullRequest(body);
33533
36796
  },
36797
+ // #882 phase 4 — triage-view low-risk mutations. Each picks
36798
+ // the cursored item from the *filtered* list (matching what
36799
+ // the user sees on screen), runs the corresponding `gh` action,
36800
+ // and on success clears both the in-memory context entry and
36801
+ // the disk cache so the next view entry refetches. Comment
36802
+ // is additive; label / assign are toggleable via re-invocation
36803
+ // with --remove-* (deferred to phase 5 as part of the y-confirm
36804
+ // suite). Open / yank don't mutate so they skip the
36805
+ // invalidation step entirely.
36806
+ 'triage-issue-open': async () => {
36807
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36808
+ if (!issue)
36809
+ return { ok: false, message: 'No issue under cursor' };
36810
+ try {
36811
+ await defaultOpenUrlRunner(issue.url);
36812
+ return { ok: true, message: `Opened ${issue.url}` };
36813
+ }
36814
+ catch (error) {
36815
+ return { ok: false, message: error.message };
36816
+ }
36817
+ },
36818
+ 'triage-issue-comment': async () => {
36819
+ const body = payload?.trim();
36820
+ if (!body)
36821
+ return { ok: false, message: 'Comment body required' };
36822
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36823
+ if (!issue)
36824
+ return { ok: false, message: 'No issue under cursor' };
36825
+ const result = await commentIssue(issue.number, body);
36826
+ if (result.ok)
36827
+ invalidateIssueListCaches(issue.number);
36828
+ return result;
36829
+ },
36830
+ 'triage-issue-label': async () => {
36831
+ const label = payload?.trim();
36832
+ if (!label)
36833
+ return { ok: false, message: 'Label name required' };
36834
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36835
+ if (!issue)
36836
+ return { ok: false, message: 'No issue under cursor' };
36837
+ const result = await addIssueLabel(issue.number, label);
36838
+ if (result.ok)
36839
+ invalidateIssueListCaches(issue.number);
36840
+ return result;
36841
+ },
36842
+ 'triage-issue-assign': async () => {
36843
+ const assignee = payload?.trim();
36844
+ if (!assignee)
36845
+ return { ok: false, message: 'Assignee login required' };
36846
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36847
+ if (!issue)
36848
+ return { ok: false, message: 'No issue under cursor' };
36849
+ const result = await addIssueAssignee(issue.number, assignee);
36850
+ if (result.ok)
36851
+ invalidateIssueListCaches(issue.number);
36852
+ return result;
36853
+ },
36854
+ 'triage-pr-open': async () => {
36855
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36856
+ if (!pr)
36857
+ return { ok: false, message: 'No pull request under cursor' };
36858
+ try {
36859
+ await defaultOpenUrlRunner(pr.url);
36860
+ return { ok: true, message: `Opened ${pr.url}` };
36861
+ }
36862
+ catch (error) {
36863
+ return { ok: false, message: error.message };
36864
+ }
36865
+ },
36866
+ 'triage-pr-comment': async () => {
36867
+ const body = payload?.trim();
36868
+ if (!body)
36869
+ return { ok: false, message: 'Comment body required' };
36870
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36871
+ if (!pr)
36872
+ return { ok: false, message: 'No pull request under cursor' };
36873
+ const result = await commentPullRequestByNumber(pr.number, body);
36874
+ if (result.ok)
36875
+ invalidatePullRequestListCaches(pr.number);
36876
+ return result;
36877
+ },
36878
+ 'triage-pr-label': async () => {
36879
+ const label = payload?.trim();
36880
+ if (!label)
36881
+ return { ok: false, message: 'Label name required' };
36882
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36883
+ if (!pr)
36884
+ return { ok: false, message: 'No pull request under cursor' };
36885
+ const result = await addPullRequestLabel(pr.number, label);
36886
+ if (result.ok)
36887
+ invalidatePullRequestListCaches(pr.number);
36888
+ return result;
36889
+ },
36890
+ 'triage-pr-assign': async () => {
36891
+ const assignee = payload?.trim();
36892
+ if (!assignee)
36893
+ return { ok: false, message: 'Assignee login required' };
36894
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36895
+ if (!pr)
36896
+ return { ok: false, message: 'No pull request under cursor' };
36897
+ const result = await addPullRequestAssignee(pr.number, assignee);
36898
+ if (result.ok)
36899
+ invalidatePullRequestListCaches(pr.number);
36900
+ return result;
36901
+ },
36902
+ // #882 phase 5 — destructive triage mutations. Each is gated
36903
+ // through the y-confirm path so the user sees a prompt before
36904
+ // anything ships. The runner reads the cursored item from the
36905
+ // filtered list at confirm-time; the cursor can't move while
36906
+ // the confirmation overlay is up so there's no stale-target
36907
+ // window. Cache invalidation matches the phase-4 pattern.
36908
+ 'triage-issue-close': async () => {
36909
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36910
+ if (!issue)
36911
+ return { ok: false, message: 'No issue under cursor' };
36912
+ const result = await closeIssue(issue.number);
36913
+ if (result.ok)
36914
+ invalidateIssueListCaches(issue.number);
36915
+ return result;
36916
+ },
36917
+ 'triage-issue-reopen': async () => {
36918
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
36919
+ if (!issue)
36920
+ return { ok: false, message: 'No issue under cursor' };
36921
+ const result = await reopenIssue(issue.number);
36922
+ if (result.ok)
36923
+ invalidateIssueListCaches(issue.number);
36924
+ return result;
36925
+ },
36926
+ 'triage-pr-merge': async () => {
36927
+ const strategy = payload?.trim();
36928
+ if (!strategy || !isPullRequestMergeStrategy(strategy)) {
36929
+ return {
36930
+ ok: false,
36931
+ message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.`,
36932
+ };
36933
+ }
36934
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36935
+ if (!pr)
36936
+ return { ok: false, message: 'No pull request under cursor' };
36937
+ const result = await mergePullRequestByNumber(pr.number, strategy);
36938
+ if (result.ok)
36939
+ invalidatePullRequestListCaches(pr.number);
36940
+ return result;
36941
+ },
36942
+ 'triage-pr-close': async () => {
36943
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36944
+ if (!pr)
36945
+ return { ok: false, message: 'No pull request under cursor' };
36946
+ const result = await closePullRequestByNumber(pr.number);
36947
+ if (result.ok)
36948
+ invalidatePullRequestListCaches(pr.number);
36949
+ return result;
36950
+ },
36951
+ 'triage-pr-approve': async () => {
36952
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36953
+ if (!pr)
36954
+ return { ok: false, message: 'No pull request under cursor' };
36955
+ const result = await approvePullRequestByNumber(pr.number);
36956
+ if (result.ok)
36957
+ invalidatePullRequestListCaches(pr.number);
36958
+ return result;
36959
+ },
36960
+ 'triage-pr-request-changes': async () => {
36961
+ const body = payload?.trim();
36962
+ if (!body)
36963
+ return { ok: false, message: 'Review body required for change-request' };
36964
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
36965
+ if (!pr)
36966
+ return { ok: false, message: 'No pull request under cursor' };
36967
+ const result = await requestChangesPullRequestByNumber(pr.number, body);
36968
+ if (result.ok)
36969
+ invalidatePullRequestListCaches(pr.number);
36970
+ return result;
36971
+ },
33534
36972
  // Status surface group-level batch ops (#791 follow-up). The
33535
36973
  // input handler dispatches these when the user presses Enter on a
33536
36974
  // group header. We re-derive the file list from the live
@@ -33686,6 +37124,29 @@ function LogInkApp(deps) {
33686
37124
  }
33687
37125
  }
33688
37126
  }
37127
+ else if (view === 'issues') {
37128
+ // #882 phase 4 — y yanks the cursored issue's URL so the user
37129
+ // can paste it into Slack / a PR description / etc. without
37130
+ // dropping back to the browser. Short form (`Y`) is a no-op
37131
+ // here — there's no compact identifier worth a second key.
37132
+ const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
37133
+ if (issue) {
37134
+ value = issue.url;
37135
+ label = `issue #${issue.number} URL`;
37136
+ }
37137
+ }
37138
+ else if (view === 'pull-request-triage') {
37139
+ // #882 phase 4 — same URL-yank pattern for the multi-PR list.
37140
+ // Distinct from `pull-request` (single, current-branch); that
37141
+ // view falls through to the generic "Nothing to yank" path
37142
+ // below since the action panel already exposes O for browser
37143
+ // open.
37144
+ const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
37145
+ if (pr) {
37146
+ value = pr.url;
37147
+ label = `pull request #${pr.number} URL`;
37148
+ }
37149
+ }
33689
37150
  else if (view === 'bisect') {
33690
37151
  // #879 item 3 — yank the first-bad commit sha from the
33691
37152
  // completion panel. The headline answer is what the user
@@ -33756,6 +37217,8 @@ function LogInkApp(deps) {
33756
37217
  context.submodules,
33757
37218
  context.tags,
33758
37219
  dispatch,
37220
+ filteredIssueList,
37221
+ filteredPullRequestTriageList,
33759
37222
  selected,
33760
37223
  selectedDetailFile,
33761
37224
  stashDiffLines,
@@ -33768,6 +37231,8 @@ function LogInkApp(deps) {
33768
37231
  state.filteredCommits,
33769
37232
  state.selectedBranchIndex,
33770
37233
  state.selectedIndex,
37234
+ state.selectedIssueIndex,
37235
+ state.selectedPullRequestTriageIndex,
33771
37236
  state.selectedStashIndex,
33772
37237
  state.selectedSubmoduleIndex,
33773
37238
  state.selectedTagIndex,
@@ -33988,6 +37453,10 @@ function LogInkApp(deps) {
33988
37453
  const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
33989
37454
  const submoduleVisibleCount = filteredSubmoduleList.length;
33990
37455
  filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
37456
+ const issueVisibleCount = filteredIssueList.length;
37457
+ const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
37458
+ const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
37459
+ const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
33991
37460
  const worktreeVisibleCount = filteredWorktreeList.length;
33992
37461
  // When the diff view is showing a stash patch, swap the previewLineCount
33993
37462
  // to the stash diff length so the existing pageDetailPreview path
@@ -34019,6 +37488,10 @@ function LogInkApp(deps) {
34019
37488
  reflogCount: reflogVisibleCount,
34020
37489
  reflogSelectedHash,
34021
37490
  submoduleCount: submoduleVisibleCount,
37491
+ issueCount: issueVisibleCount,
37492
+ issueSelectedUrl,
37493
+ pullRequestTriageCount: pullRequestTriageVisibleCount,
37494
+ pullRequestTriageSelectedUrl,
34022
37495
  stashSelectedRef,
34023
37496
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
34024
37497
  stashDiffSelectedPath,
@@ -34186,7 +37659,7 @@ function LogInkApp(deps) {
34186
37659
  if (showOnboarding) {
34187
37660
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
34188
37661
  }
34189
- 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));
37662
+ 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));
34190
37663
  }
34191
37664
 
34192
37665
  /**
@@ -34495,6 +37968,9 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
34495
37968
  appLabel: options.appLabel || 'coco log',
34496
37969
  git,
34497
37970
  idleTipsEnabled: Boolean(options.idleTips),
37971
+ // Resolve undefined → true so the default flips on automatically.
37972
+ // An explicit `false` from config opts out.
37973
+ dateBucketingEnabled: options.dateBucketing !== false,
34498
37974
  ink,
34499
37975
  initialView: options.initialView || 'history',
34500
37976
  logArgv: options.logArgv,
@@ -34659,6 +38135,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
34659
38135
  await startInkInteractiveLog(git, initialRows, {}, {
34660
38136
  appLabel: 'coco',
34661
38137
  idleTips: config.logTui?.idleTips,
38138
+ dateBucketing: config.logTui?.dateBucketing,
34662
38139
  initialView: 'history',
34663
38140
  loadRows,
34664
38141
  logArgv,
@@ -34680,13 +38157,14 @@ async function startCocoUi(argv) {
34680
38157
  await startInkInteractiveLog(git, cachedRows || [], {}, {
34681
38158
  appLabel: 'coco',
34682
38159
  idleTips: config.logTui?.idleTips,
38160
+ dateBucketing: config.logTui?.dateBucketing,
34683
38161
  initialView: argv.view || 'history',
34684
38162
  loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
34685
38163
  logArgv,
34686
38164
  theme: createUiTheme(config, argv),
34687
38165
  });
34688
38166
  }
34689
- const handler$3 = async (argv) => {
38167
+ const handler$4 = async (argv) => {
34690
38168
  await startCocoUi(argv);
34691
38169
  };
34692
38170
 
@@ -34779,7 +38257,7 @@ function formatCommitDetail(detail, format) {
34779
38257
  ].join('\n');
34780
38258
  }
34781
38259
 
34782
- const handler$2 = async (argv) => {
38260
+ const handler$3 = async (argv) => {
34783
38261
  // `--repo <dir>` (alias `--cwd`) — apply the global flag via the
34784
38262
  // shared helper. After this returns, `process.cwd()` and the git
34785
38263
  // instance are both bound to the targeted repo.
@@ -34814,11 +38292,169 @@ const handler$2 = async (argv) => {
34814
38292
  };
34815
38293
 
34816
38294
  var log = {
34817
- command: command$3,
38295
+ command: command$4,
34818
38296
  desc: 'Explore commit history with a branch graph, filters, and commit details.',
38297
+ builder: builder$4,
38298
+ handler: commandExecutor(handler$3),
38299
+ options: options$4,
38300
+ };
38301
+
38302
+ const command$3 = 'prs';
38303
+ const options$3 = {
38304
+ state: {
38305
+ type: 'string',
38306
+ choices: ['open', 'closed', 'merged', 'all'],
38307
+ description: 'Filter by PR state.',
38308
+ default: 'open',
38309
+ },
38310
+ assignee: {
38311
+ type: 'string',
38312
+ description: 'Filter by assignee GitHub login (or `@me`).',
38313
+ },
38314
+ author: {
38315
+ type: 'string',
38316
+ description: 'Filter by author GitHub login.',
38317
+ },
38318
+ label: {
38319
+ type: 'string',
38320
+ description: 'Filter by label name (comma-separated for AND).',
38321
+ },
38322
+ search: {
38323
+ type: 'string',
38324
+ description: 'Free-form GitHub PR search query.',
38325
+ },
38326
+ base: {
38327
+ type: 'string',
38328
+ description: 'Filter to PRs targeting a specific base branch.',
38329
+ },
38330
+ head: {
38331
+ type: 'string',
38332
+ description: 'Filter to PRs originating from a specific head branch.',
38333
+ },
38334
+ draft: {
38335
+ type: 'boolean',
38336
+ description: 'Limit to draft PRs only.',
38337
+ default: false,
38338
+ },
38339
+ mine: {
38340
+ type: 'boolean',
38341
+ description: 'Shorthand for `--assignee @me`.',
38342
+ default: false,
38343
+ },
38344
+ limit: {
38345
+ type: 'number',
38346
+ description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
38347
+ },
38348
+ json: {
38349
+ type: 'boolean',
38350
+ description: 'Print machine-readable JSON instead of a formatted table.',
38351
+ default: false,
38352
+ },
38353
+ refresh: {
38354
+ type: 'boolean',
38355
+ description: 'Force fresh `gh` call (writes through to cache).',
38356
+ default: false,
38357
+ },
38358
+ 'no-cache': {
38359
+ type: 'boolean',
38360
+ description: 'Skip the disk cache entirely (no read, no write).',
38361
+ default: false,
38362
+ },
38363
+ };
38364
+ const builder$3 = (yargs) => {
38365
+ return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
38366
+ };
38367
+
38368
+ const handler$2 = async (argv, logger) => {
38369
+ const git = applyRepoFlag(argv);
38370
+ const repoPath = process.cwd();
38371
+ const filter = {
38372
+ state: argv.state,
38373
+ assignee: argv.mine ? '@me' : argv.assignee,
38374
+ author: argv.author,
38375
+ label: argv.label,
38376
+ search: argv.search,
38377
+ base: argv.base,
38378
+ head: argv.head,
38379
+ draft: argv.draft,
38380
+ limit: argv.limit,
38381
+ };
38382
+ const cacheEnabled = !argv.noCache;
38383
+ let prs;
38384
+ let fromCache = false;
38385
+ let cacheAgeMs;
38386
+ const repository = await getGitHubRepository(git);
38387
+ if (cacheEnabled && !argv.refresh) {
38388
+ const cached = readCachedList('prs', repoPath, filter);
38389
+ if (cached?.fresh) {
38390
+ prs = cached.payload.items;
38391
+ fromCache = true;
38392
+ cacheAgeMs = cached.ageMs;
38393
+ }
38394
+ }
38395
+ if (!prs) {
38396
+ const overview = await getPullRequestList(git, filter);
38397
+ if (!overview.available) {
38398
+ logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
38399
+ commandExit(1);
38400
+ return;
38401
+ }
38402
+ if (!overview.authenticated) {
38403
+ logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
38404
+ logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable PR triage.'));
38405
+ commandExit(1);
38406
+ return;
38407
+ }
38408
+ if (overview.message) {
38409
+ logger.log(chalk.red(overview.message));
38410
+ commandExit(1);
38411
+ return;
38412
+ }
38413
+ prs = overview.pullRequests || [];
38414
+ if (cacheEnabled) {
38415
+ writeCachedList(repoPath, filter, { kind: 'prs', items: prs });
38416
+ }
38417
+ }
38418
+ if (argv.json) {
38419
+ logger.log(JSON.stringify(prs, null, 2));
38420
+ return;
38421
+ }
38422
+ if (repository) {
38423
+ const filterParts = [];
38424
+ if (filter.state && filter.state !== 'open')
38425
+ filterParts.push(`state=${filter.state}`);
38426
+ if (filter.assignee)
38427
+ filterParts.push(`assignee=${filter.assignee}`);
38428
+ if (filter.author)
38429
+ filterParts.push(`author=${filter.author}`);
38430
+ if (filter.label)
38431
+ filterParts.push(`label=${filter.label}`);
38432
+ if (filter.search)
38433
+ filterParts.push(`search=${JSON.stringify(filter.search)}`);
38434
+ if (filter.base)
38435
+ filterParts.push(`base=${filter.base}`);
38436
+ if (filter.head)
38437
+ filterParts.push(`head=${filter.head}`);
38438
+ if (filter.draft)
38439
+ filterParts.push('draft');
38440
+ const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
38441
+ const cacheTag = fromCache && typeof cacheAgeMs === 'number'
38442
+ ? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
38443
+ : '';
38444
+ logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
38445
+ chalk.dim(` · ${prs.length} pull request${prs.length === 1 ? '' : 's'}`) +
38446
+ suffix +
38447
+ cacheTag);
38448
+ logger.log('');
38449
+ }
38450
+ logger.log(formatPullRequestList(prs));
38451
+ };
38452
+
38453
+ var prs = {
38454
+ command: command$3,
38455
+ desc: 'List GitHub pull requests for the current repository (read-only triage)',
34819
38456
  builder: builder$3,
34820
38457
  handler: commandExecutor(handler$2),
34821
- options: options$3,
34822
38458
  };
34823
38459
 
34824
38460
  const RecapLlmResponseSchema = objectType({
@@ -35720,7 +39356,7 @@ var ui = {
35720
39356
  command,
35721
39357
  desc: 'Open the Coco Git workstation TUI.',
35722
39358
  builder,
35723
- handler: commandExecutor(handler$3),
39359
+ handler: commandExecutor(handler$4),
35724
39360
  options,
35725
39361
  };
35726
39362
 
@@ -35751,6 +39387,8 @@ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
35751
39387
  y.command(log.command, log.desc, log.builder, log.handler);
35752
39388
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
35753
39389
  y.command(cache.command, cache.desc, cache.builder, cache.handler);
39390
+ y.command(issues.command, issues.desc, issues.builder, issues.handler);
39391
+ y.command(prs.command, prs.desc, prs.builder, prs.handler);
35754
39392
  async function main() {
35755
39393
  await runPrefetchFromEnv();
35756
39394
  y.help().parse(process.argv.slice(2));
@@ -36209,4 +39847,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
36209
39847
  handleValidationErrors: handleValidationErrors
36210
39848
  });
36211
39849
 
36212
- export { cache, changelog, commit, doctor, init, log, recap, types, ui };
39850
+ export { cache, changelog, commit, doctor, init, issues, log, prs, recap, types, ui };