git-coco 0.41.2 → 0.42.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.
@@ -17,6 +17,8 @@ import prettyMilliseconds from 'pretty-ms';
17
17
  import { ChatAnthropic } from '@langchain/anthropic';
18
18
  import { ChatOllama } from '@langchain/ollama';
19
19
  import { ChatOpenAI } from '@langchain/openai';
20
+ import * as fs$1 from 'node:fs';
21
+ import * as path$1 from 'node:path';
20
22
  import { StructuredOutputParser, BaseOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
21
23
  import { minimatch } from 'minimatch';
22
24
  import { simpleGit, GitError } from 'simple-git';
@@ -38,9 +40,7 @@ import '@langchain/core/utils/async_caller';
38
40
  import { encoding_for_model } from 'tiktoken';
39
41
  import { spawn, exec, execFile } from 'child_process';
40
42
  import { spawnSync } from 'node:child_process';
41
- import * as fs$1 from 'node:fs';
42
43
  import * as os$1 from 'node:os';
43
- import * as path$1 from 'node:path';
44
44
  import * as crypto from 'node:crypto';
45
45
  import * as readline from 'readline';
46
46
  import readline__default from 'readline';
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
53
53
  /**
54
54
  * Current build version from package.json
55
55
  */
56
- const BUILD_VERSION = "0.41.2";
56
+ const BUILD_VERSION = "0.42.0";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -6343,6 +6343,7 @@ function resolveDynamicService(config, task) {
6343
6343
  };
6344
6344
  }
6345
6345
 
6346
+ const benchCalls = [];
6346
6347
  const telemetryByCommand = new Map();
6347
6348
  function estimatePromptTokens(tokenizer, renderedPrompt) {
6348
6349
  if (!tokenizer)
@@ -6354,10 +6355,28 @@ function estimatePromptTokens(tokenizer, renderedPrompt) {
6354
6355
  return undefined;
6355
6356
  }
6356
6357
  }
6358
+ function isBenchModeActive() {
6359
+ return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0');
6360
+ }
6361
+ function recordBenchCall(metadata) {
6362
+ if (!isBenchModeActive())
6363
+ return;
6364
+ benchCalls.push({
6365
+ task: metadata.task,
6366
+ command: metadata.command,
6367
+ provider: metadata.provider,
6368
+ model: metadata.model,
6369
+ promptTokens: metadata.promptTokens,
6370
+ elapsedMs: metadata.elapsedMs,
6371
+ inputDocuments: metadata.inputDocuments,
6372
+ inputChunks: metadata.inputChunks,
6373
+ });
6374
+ }
6357
6375
  function logLlmCall(logger, metadata) {
6358
6376
  if (!logger)
6359
6377
  return;
6360
6378
  recordLlmTelemetry(metadata);
6379
+ recordBenchCall(metadata);
6361
6380
  const fields = [
6362
6381
  `task=${metadata.task}`,
6363
6382
  metadata.command ? `command=${metadata.command}` : undefined,
@@ -15369,6 +15388,13 @@ const LOG_INK_KEY_BINDINGS = [
15369
15388
  description: 'Push the dedicated pull-request action panel for the current branch.',
15370
15389
  contexts: ['normal'],
15371
15390
  },
15391
+ {
15392
+ id: 'navigateConflicts',
15393
+ keys: ['gx'],
15394
+ label: 'conflicts',
15395
+ description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
15396
+ contexts: ['normal'],
15397
+ },
15372
15398
  {
15373
15399
  id: 'navigateBack',
15374
15400
  keys: ['<', 'esc'],
@@ -15498,6 +15524,7 @@ const GLOBAL_BINDING_IDS = [
15498
15524
  'navigateStash',
15499
15525
  'navigateWorktrees',
15500
15526
  'navigatePullRequest',
15527
+ 'navigateConflicts',
15501
15528
  'navigateBack',
15502
15529
  ];
15503
15530
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15670,6 +15697,12 @@ function getLogInkFooterHints(options) {
15670
15697
  global: NORMAL_GLOBAL_HINTS,
15671
15698
  };
15672
15699
  }
15700
+ if (options.activeView === 'conflicts') {
15701
+ return {
15702
+ contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'],
15703
+ global: NORMAL_GLOBAL_HINTS,
15704
+ };
15705
+ }
15673
15706
  return {
15674
15707
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15675
15708
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16416,6 +16449,7 @@ function createLogInkState(rows, options = {}) {
16416
16449
  selectedTagIndex: 0,
16417
16450
  selectedStashIndex: 0,
16418
16451
  selectedWorktreeListIndex: 0,
16452
+ selectedConflictFileIndex: 0,
16419
16453
  branchSort: DEFAULT_BRANCH_SORT_MODE,
16420
16454
  tagSort: DEFAULT_TAG_SORT_MODE,
16421
16455
  paletteFilter: '',
@@ -16669,6 +16703,12 @@ function applyLogInkAction(state, action) {
16669
16703
  selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
16670
16704
  pendingKey: undefined,
16671
16705
  };
16706
+ case 'moveConflictFile':
16707
+ return {
16708
+ ...state,
16709
+ selectedConflictFileIndex: clampIndex$1(state.selectedConflictFileIndex + action.delta, action.count),
16710
+ pendingKey: undefined,
16711
+ };
16672
16712
  case 'cycleBranchSort':
16673
16713
  return {
16674
16714
  ...state,
@@ -17337,6 +17377,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
17337
17377
  return [action({ type: 'pushView', value: 'worktrees' })];
17338
17378
  case 'navigatePullRequest':
17339
17379
  return [action({ type: 'pushView', value: 'pull-request' })];
17380
+ case 'navigateConflicts':
17381
+ return [action({ type: 'pushView', value: 'conflicts' })];
17340
17382
  case 'navigateBack':
17341
17383
  return [action({ type: 'popView' })];
17342
17384
  case 'openSelected': {
@@ -17808,6 +17850,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17808
17850
  action({ type: 'setStatus', value: 'jumped to pull request' }),
17809
17851
  ];
17810
17852
  }
17853
+ if (state.pendingKey === 'g' && inputValue === 'x') {
17854
+ return [
17855
+ action({ type: 'pushView', value: 'conflicts' }),
17856
+ action({ type: 'setStatus', value: 'jumped to conflicts' }),
17857
+ ];
17858
+ }
17811
17859
  // `gH` chord: apply the cursored hunk to the index (`git apply
17812
17860
  // --cached`). Sibling of bare `H` which targets the worktree.
17813
17861
  // Discoverable via the footer hint on diff views and the help
@@ -18117,6 +18165,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18117
18165
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18118
18166
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18119
18167
  }
18168
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18169
+ return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })];
18170
+ }
18120
18171
  if (state.activeView === 'history' &&
18121
18172
  state.focus === 'commits' &&
18122
18173
  state.selectedIndex === 0 &&
@@ -18195,6 +18246,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18195
18246
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18196
18247
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
18197
18248
  }
18249
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18250
+ return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })];
18251
+ }
18198
18252
  return [
18199
18253
  action(state.focus === 'sidebar'
18200
18254
  ? { type: 'nextSidebarTab' }
@@ -18380,6 +18434,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18380
18434
  fileIndex: state.selectedWorktreeFileIndex,
18381
18435
  })];
18382
18436
  }
18437
+ // Enter on a conflict file opens the worktree diff for that file so
18438
+ // the user can inspect the conflict markers in context.
18439
+ if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18440
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }];
18441
+ }
18383
18442
  // Enter on a branch row checks the branch out. Non-destructive workflow
18384
18443
  // action — no confirmation prompt. Fires from either the dedicated
18385
18444
  // branches view or from the sidebar when the branches tab is focused
@@ -18521,6 +18580,32 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18521
18580
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
18522
18581
  return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
18523
18582
  }
18583
+ // --- Conflicts view per-row handlers ---
18584
+ // `o` opens the conflicted file in $EDITOR for manual resolution.
18585
+ if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18586
+ return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }];
18587
+ }
18588
+ // `s` stages the conflicted file (marks it resolved).
18589
+ if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18590
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }];
18591
+ }
18592
+ // `u` resolves by keeping theirs (incoming changes).
18593
+ if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18594
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }];
18595
+ }
18596
+ // `U` resolves by keeping ours (current branch).
18597
+ if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18598
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }];
18599
+ }
18600
+ // `C` continues the in-progress operation (available when no conflicts remain).
18601
+ if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) {
18602
+ return [{ type: 'runWorkflowAction', id: 'continue-operation' }];
18603
+ }
18604
+ // Always intercept `C` on the conflicts view to prevent fallthrough to
18605
+ // the global `C` (Create PR) binding when conflicts remain.
18606
+ if (inputValue === 'C' && state.activeView === 'conflicts') {
18607
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
18608
+ }
18524
18609
  // `c` on a stash diff cherry-picks the file under the cursor —
18525
18610
  // materializes that single path from the stash into the working tree
18526
18611
  // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
@@ -21817,6 +21902,21 @@ function skipOperation(git, operation) {
21817
21902
  }
21818
21903
  return runAction(() => git.raw(command.args), command.successMessage);
21819
21904
  }
21905
+ function resolveConflictOurs(git, path) {
21906
+ return runAction(async () => {
21907
+ await git.raw(['checkout', '--ours', '--', path]);
21908
+ await git.raw(['add', '--', path]);
21909
+ }, `Resolved ${path} (kept ours)`);
21910
+ }
21911
+ function resolveConflictTheirs(git, path) {
21912
+ return runAction(async () => {
21913
+ await git.raw(['checkout', '--theirs', '--', path]);
21914
+ await git.raw(['add', '--', path]);
21915
+ }, `Resolved ${path} (kept theirs)`);
21916
+ }
21917
+ function stageConflictResolved(git, path) {
21918
+ return runAction(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
21919
+ }
21820
21920
 
21821
21921
  function openProviderUrl(repository, target, openUrl = defaultOpenUrlRunner) {
21822
21922
  const url = repository ? buildProviderUrl(repository, target) : undefined;
@@ -25199,6 +25299,51 @@ function LogInkApp(deps) {
25199
25299
  }
25200
25300
  return abortOperation(git, operation);
25201
25301
  },
25302
+ 'resolve-conflict-ours': async () => {
25303
+ const path = payload?.trim();
25304
+ if (!path)
25305
+ return { ok: false, message: 'No conflict file selected' };
25306
+ return resolveConflictOurs(git, path);
25307
+ },
25308
+ 'resolve-conflict-theirs': async () => {
25309
+ const path = payload?.trim();
25310
+ if (!path)
25311
+ return { ok: false, message: 'No conflict file selected' };
25312
+ return resolveConflictTheirs(git, path);
25313
+ },
25314
+ 'resolve-conflict-stage': async () => {
25315
+ const path = payload?.trim();
25316
+ if (!path)
25317
+ return { ok: false, message: 'No conflict file selected' };
25318
+ return stageConflictResolved(git, path);
25319
+ },
25320
+ 'resolve-conflict-open-diff': async () => {
25321
+ // Push the diff view for the conflicted file so the user can
25322
+ // inspect conflict markers in context. We find the file's index
25323
+ // in the worktree file list and navigate to its diff.
25324
+ const path = payload?.trim();
25325
+ if (!path)
25326
+ return { ok: false, message: 'No conflict file selected' };
25327
+ const worktreeFiles = context.worktree?.files || [];
25328
+ const fileIndex = worktreeFiles.findIndex((f) => f.path === path);
25329
+ if (fileIndex >= 0) {
25330
+ dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex });
25331
+ return { ok: true, message: `Viewing diff for ${path}` };
25332
+ }
25333
+ // File not in worktree list (e.g. deleted-by-us) — open in
25334
+ // editor as fallback so the user can still inspect it.
25335
+ return { ok: true, message: `${path} not in worktree diff list` };
25336
+ },
25337
+ 'continue-operation': async () => {
25338
+ const operation = context.operation?.operation;
25339
+ if (!operation || operation === 'none') {
25340
+ return { ok: false, message: 'No git operation in progress' };
25341
+ }
25342
+ if ((context.operation?.conflictedFiles.length ?? 0) > 0) {
25343
+ return { ok: false, message: 'Resolve all conflicts before continuing' };
25344
+ }
25345
+ return continueOperation(git, operation);
25346
+ },
25202
25347
  'open-pr': async () => {
25203
25348
  const repo = context.provider?.repository;
25204
25349
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
@@ -25722,6 +25867,14 @@ function LogInkApp(deps) {
25722
25867
  ? selected?.hash
25723
25868
  : undefined,
25724
25869
  worktreeDirty,
25870
+ conflictFileCount: context.operation?.conflictedFiles.length,
25871
+ conflictSelectedPath: (() => {
25872
+ const files = context.operation?.conflictedFiles;
25873
+ if (!files || files.length === 0)
25874
+ return undefined;
25875
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
25876
+ return files[clamped]?.path;
25877
+ })(),
25725
25878
  // H / gH need the actual diff text (not just hunk offsets) to
25726
25879
  // slice the cursored hunk into a `git apply` patch. Stash uses
25727
25880
  // the full `git stash show -p` output; commit-diff uses the
@@ -26086,6 +26239,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
26086
26239
  if (state.activeView === 'pull-request') {
26087
26240
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26088
26241
  }
26242
+ if (state.activeView === 'conflicts') {
26243
+ return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26244
+ }
26089
26245
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
26090
26246
  }
26091
26247
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -26269,6 +26425,79 @@ function buildStatusSurfaceRows(groups) {
26269
26425
  }
26270
26426
  return rows;
26271
26427
  }
26428
+ function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26429
+ const { Box, Text } = components;
26430
+ const focused = state.focus === 'commits';
26431
+ const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
26432
+ const operation = context.operation;
26433
+ const conflictedFiles = operation?.conflictedFiles || [];
26434
+ const operationType = operation?.operation || 'none';
26435
+ // If no operation is in progress, show a fallback message.
26436
+ if (!loading && operationType === 'none') {
26437
+ return h(Box, {
26438
+ borderColor: focusBorderColor(theme, focused),
26439
+ borderStyle: theme.borderStyle,
26440
+ flexDirection: 'column',
26441
+ flexShrink: 0,
26442
+ paddingX: 1,
26443
+ width,
26444
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, 'no operation in progress')), h(Text, { key: 'conflicts-empty', dimColor: true }, 'No merge, rebase, cherry-pick, or revert in progress.'));
26445
+ }
26446
+ // All conflicts resolved — show the "continue" hint.
26447
+ if (!loading && conflictedFiles.length === 0 && operationType !== 'none') {
26448
+ return h(Box, {
26449
+ borderColor: focusBorderColor(theme, focused),
26450
+ borderStyle: theme.borderStyle,
26451
+ flexDirection: 'column',
26452
+ flexShrink: 0,
26453
+ paddingX: 1,
26454
+ width,
26455
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, `${operationType} — all conflicts resolved`)), h(Text, { key: 'conflicts-hint', dimColor: true }, `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.`));
26456
+ }
26457
+ const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1)));
26458
+ const listRows = Math.max(4, bodyRows - 4);
26459
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
26460
+ const visible = conflictedFiles.slice(startIndex, startIndex + listRows);
26461
+ const remaining = conflictedFiles.length;
26462
+ const headerRight = loading
26463
+ ? 'loading conflicts'
26464
+ : `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining`;
26465
+ const statusLabel = (file) => {
26466
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
26467
+ switch (code) {
26468
+ case 'UU': return 'both modified';
26469
+ case 'AA': return 'added by both';
26470
+ case 'DD': return 'both deleted';
26471
+ case 'AU':
26472
+ case 'UA': return 'added by one';
26473
+ case 'DU': return 'deleted by us';
26474
+ case 'UD': return 'deleted by them';
26475
+ default: return code;
26476
+ }
26477
+ };
26478
+ const lines = loading
26479
+ ? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))]
26480
+ : visible.map((file, offset) => {
26481
+ const index = startIndex + offset;
26482
+ const isSelected = index === selected;
26483
+ const cursor = isSelected ? '>' : ' ';
26484
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
26485
+ const label = statusLabel(file);
26486
+ return h(Text, {
26487
+ key: `conflict-${index}`,
26488
+ bold: isSelected,
26489
+ dimColor: !isSelected,
26490
+ }, truncate$1(`${cursor} ${code} ${file.path} (${label})`, width - 4));
26491
+ });
26492
+ return h(Box, {
26493
+ borderColor: focusBorderColor(theme, focused),
26494
+ borderStyle: theme.borderStyle,
26495
+ flexDirection: 'column',
26496
+ flexShrink: 0,
26497
+ paddingX: 1,
26498
+ width,
26499
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
26500
+ }
26272
26501
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26273
26502
  const { Box, Text } = components;
26274
26503
  const focused = state.focus === 'commits';
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ var prettyMilliseconds = require('pretty-ms');
16
16
  var anthropic = require('@langchain/anthropic');
17
17
  var ollama = require('@langchain/ollama');
18
18
  var openai = require('@langchain/openai');
19
+ var fs$1 = require('node:fs');
20
+ var path$1 = require('node:path');
19
21
  var output_parsers = require('@langchain/core/output_parsers');
20
22
  var minimatch = require('minimatch');
21
23
  var simpleGit = require('simple-git');
@@ -37,9 +39,7 @@ require('@langchain/core/utils/async_caller');
37
39
  var tiktoken = require('tiktoken');
38
40
  var child_process = require('child_process');
39
41
  var node_child_process = require('node:child_process');
40
- var fs$1 = require('node:fs');
41
42
  var os$1 = require('node:os');
42
- var path$1 = require('node:path');
43
43
  var crypto = require('node:crypto');
44
44
  var readline = require('readline');
45
45
  var util$1 = require('util');
@@ -68,8 +68,8 @@ var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
68
68
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
69
69
  var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
70
70
  var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
71
- var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
72
71
  var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
72
+ var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
73
73
  var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
74
74
  var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
75
75
 
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.41.2";
81
+ const BUILD_VERSION = "0.42.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -6368,6 +6368,7 @@ function resolveDynamicService(config, task) {
6368
6368
  };
6369
6369
  }
6370
6370
 
6371
+ const benchCalls = [];
6371
6372
  const telemetryByCommand = new Map();
6372
6373
  function estimatePromptTokens(tokenizer, renderedPrompt) {
6373
6374
  if (!tokenizer)
@@ -6379,10 +6380,28 @@ function estimatePromptTokens(tokenizer, renderedPrompt) {
6379
6380
  return undefined;
6380
6381
  }
6381
6382
  }
6383
+ function isBenchModeActive() {
6384
+ return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0');
6385
+ }
6386
+ function recordBenchCall(metadata) {
6387
+ if (!isBenchModeActive())
6388
+ return;
6389
+ benchCalls.push({
6390
+ task: metadata.task,
6391
+ command: metadata.command,
6392
+ provider: metadata.provider,
6393
+ model: metadata.model,
6394
+ promptTokens: metadata.promptTokens,
6395
+ elapsedMs: metadata.elapsedMs,
6396
+ inputDocuments: metadata.inputDocuments,
6397
+ inputChunks: metadata.inputChunks,
6398
+ });
6399
+ }
6382
6400
  function logLlmCall(logger, metadata) {
6383
6401
  if (!logger)
6384
6402
  return;
6385
6403
  recordLlmTelemetry(metadata);
6404
+ recordBenchCall(metadata);
6386
6405
  const fields = [
6387
6406
  `task=${metadata.task}`,
6388
6407
  metadata.command ? `command=${metadata.command}` : undefined,
@@ -15394,6 +15413,13 @@ const LOG_INK_KEY_BINDINGS = [
15394
15413
  description: 'Push the dedicated pull-request action panel for the current branch.',
15395
15414
  contexts: ['normal'],
15396
15415
  },
15416
+ {
15417
+ id: 'navigateConflicts',
15418
+ keys: ['gx'],
15419
+ label: 'conflicts',
15420
+ description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
15421
+ contexts: ['normal'],
15422
+ },
15397
15423
  {
15398
15424
  id: 'navigateBack',
15399
15425
  keys: ['<', 'esc'],
@@ -15523,6 +15549,7 @@ const GLOBAL_BINDING_IDS = [
15523
15549
  'navigateStash',
15524
15550
  'navigateWorktrees',
15525
15551
  'navigatePullRequest',
15552
+ 'navigateConflicts',
15526
15553
  'navigateBack',
15527
15554
  ];
15528
15555
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15695,6 +15722,12 @@ function getLogInkFooterHints(options) {
15695
15722
  global: NORMAL_GLOBAL_HINTS,
15696
15723
  };
15697
15724
  }
15725
+ if (options.activeView === 'conflicts') {
15726
+ return {
15727
+ contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'],
15728
+ global: NORMAL_GLOBAL_HINTS,
15729
+ };
15730
+ }
15698
15731
  return {
15699
15732
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15700
15733
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16441,6 +16474,7 @@ function createLogInkState(rows, options = {}) {
16441
16474
  selectedTagIndex: 0,
16442
16475
  selectedStashIndex: 0,
16443
16476
  selectedWorktreeListIndex: 0,
16477
+ selectedConflictFileIndex: 0,
16444
16478
  branchSort: DEFAULT_BRANCH_SORT_MODE,
16445
16479
  tagSort: DEFAULT_TAG_SORT_MODE,
16446
16480
  paletteFilter: '',
@@ -16694,6 +16728,12 @@ function applyLogInkAction(state, action) {
16694
16728
  selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
16695
16729
  pendingKey: undefined,
16696
16730
  };
16731
+ case 'moveConflictFile':
16732
+ return {
16733
+ ...state,
16734
+ selectedConflictFileIndex: clampIndex$1(state.selectedConflictFileIndex + action.delta, action.count),
16735
+ pendingKey: undefined,
16736
+ };
16697
16737
  case 'cycleBranchSort':
16698
16738
  return {
16699
16739
  ...state,
@@ -17362,6 +17402,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
17362
17402
  return [action({ type: 'pushView', value: 'worktrees' })];
17363
17403
  case 'navigatePullRequest':
17364
17404
  return [action({ type: 'pushView', value: 'pull-request' })];
17405
+ case 'navigateConflicts':
17406
+ return [action({ type: 'pushView', value: 'conflicts' })];
17365
17407
  case 'navigateBack':
17366
17408
  return [action({ type: 'popView' })];
17367
17409
  case 'openSelected': {
@@ -17833,6 +17875,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17833
17875
  action({ type: 'setStatus', value: 'jumped to pull request' }),
17834
17876
  ];
17835
17877
  }
17878
+ if (state.pendingKey === 'g' && inputValue === 'x') {
17879
+ return [
17880
+ action({ type: 'pushView', value: 'conflicts' }),
17881
+ action({ type: 'setStatus', value: 'jumped to conflicts' }),
17882
+ ];
17883
+ }
17836
17884
  // `gH` chord: apply the cursored hunk to the index (`git apply
17837
17885
  // --cached`). Sibling of bare `H` which targets the worktree.
17838
17886
  // Discoverable via the footer hint on diff views and the help
@@ -18142,6 +18190,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18142
18190
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18143
18191
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18144
18192
  }
18193
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18194
+ return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })];
18195
+ }
18145
18196
  if (state.activeView === 'history' &&
18146
18197
  state.focus === 'commits' &&
18147
18198
  state.selectedIndex === 0 &&
@@ -18220,6 +18271,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18220
18271
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18221
18272
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
18222
18273
  }
18274
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18275
+ return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })];
18276
+ }
18223
18277
  return [
18224
18278
  action(state.focus === 'sidebar'
18225
18279
  ? { type: 'nextSidebarTab' }
@@ -18405,6 +18459,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18405
18459
  fileIndex: state.selectedWorktreeFileIndex,
18406
18460
  })];
18407
18461
  }
18462
+ // Enter on a conflict file opens the worktree diff for that file so
18463
+ // the user can inspect the conflict markers in context.
18464
+ if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18465
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }];
18466
+ }
18408
18467
  // Enter on a branch row checks the branch out. Non-destructive workflow
18409
18468
  // action — no confirmation prompt. Fires from either the dedicated
18410
18469
  // branches view or from the sidebar when the branches tab is focused
@@ -18546,6 +18605,32 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18546
18605
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
18547
18606
  return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
18548
18607
  }
18608
+ // --- Conflicts view per-row handlers ---
18609
+ // `o` opens the conflicted file in $EDITOR for manual resolution.
18610
+ if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18611
+ return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }];
18612
+ }
18613
+ // `s` stages the conflicted file (marks it resolved).
18614
+ if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18615
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }];
18616
+ }
18617
+ // `u` resolves by keeping theirs (incoming changes).
18618
+ if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18619
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }];
18620
+ }
18621
+ // `U` resolves by keeping ours (current branch).
18622
+ if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
18623
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }];
18624
+ }
18625
+ // `C` continues the in-progress operation (available when no conflicts remain).
18626
+ if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) {
18627
+ return [{ type: 'runWorkflowAction', id: 'continue-operation' }];
18628
+ }
18629
+ // Always intercept `C` on the conflicts view to prevent fallthrough to
18630
+ // the global `C` (Create PR) binding when conflicts remain.
18631
+ if (inputValue === 'C' && state.activeView === 'conflicts') {
18632
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
18633
+ }
18549
18634
  // `c` on a stash diff cherry-picks the file under the cursor —
18550
18635
  // materializes that single path from the stash into the working tree
18551
18636
  // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
@@ -21842,6 +21927,21 @@ function skipOperation(git, operation) {
21842
21927
  }
21843
21928
  return runAction(() => git.raw(command.args), command.successMessage);
21844
21929
  }
21930
+ function resolveConflictOurs(git, path) {
21931
+ return runAction(async () => {
21932
+ await git.raw(['checkout', '--ours', '--', path]);
21933
+ await git.raw(['add', '--', path]);
21934
+ }, `Resolved ${path} (kept ours)`);
21935
+ }
21936
+ function resolveConflictTheirs(git, path) {
21937
+ return runAction(async () => {
21938
+ await git.raw(['checkout', '--theirs', '--', path]);
21939
+ await git.raw(['add', '--', path]);
21940
+ }, `Resolved ${path} (kept theirs)`);
21941
+ }
21942
+ function stageConflictResolved(git, path) {
21943
+ return runAction(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
21944
+ }
21845
21945
 
21846
21946
  function openProviderUrl(repository, target, openUrl = defaultOpenUrlRunner) {
21847
21947
  const url = repository ? buildProviderUrl(repository, target) : undefined;
@@ -25224,6 +25324,51 @@ function LogInkApp(deps) {
25224
25324
  }
25225
25325
  return abortOperation(git, operation);
25226
25326
  },
25327
+ 'resolve-conflict-ours': async () => {
25328
+ const path = payload?.trim();
25329
+ if (!path)
25330
+ return { ok: false, message: 'No conflict file selected' };
25331
+ return resolveConflictOurs(git, path);
25332
+ },
25333
+ 'resolve-conflict-theirs': async () => {
25334
+ const path = payload?.trim();
25335
+ if (!path)
25336
+ return { ok: false, message: 'No conflict file selected' };
25337
+ return resolveConflictTheirs(git, path);
25338
+ },
25339
+ 'resolve-conflict-stage': async () => {
25340
+ const path = payload?.trim();
25341
+ if (!path)
25342
+ return { ok: false, message: 'No conflict file selected' };
25343
+ return stageConflictResolved(git, path);
25344
+ },
25345
+ 'resolve-conflict-open-diff': async () => {
25346
+ // Push the diff view for the conflicted file so the user can
25347
+ // inspect conflict markers in context. We find the file's index
25348
+ // in the worktree file list and navigate to its diff.
25349
+ const path = payload?.trim();
25350
+ if (!path)
25351
+ return { ok: false, message: 'No conflict file selected' };
25352
+ const worktreeFiles = context.worktree?.files || [];
25353
+ const fileIndex = worktreeFiles.findIndex((f) => f.path === path);
25354
+ if (fileIndex >= 0) {
25355
+ dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex });
25356
+ return { ok: true, message: `Viewing diff for ${path}` };
25357
+ }
25358
+ // File not in worktree list (e.g. deleted-by-us) — open in
25359
+ // editor as fallback so the user can still inspect it.
25360
+ return { ok: true, message: `${path} not in worktree diff list` };
25361
+ },
25362
+ 'continue-operation': async () => {
25363
+ const operation = context.operation?.operation;
25364
+ if (!operation || operation === 'none') {
25365
+ return { ok: false, message: 'No git operation in progress' };
25366
+ }
25367
+ if ((context.operation?.conflictedFiles.length ?? 0) > 0) {
25368
+ return { ok: false, message: 'Resolve all conflicts before continuing' };
25369
+ }
25370
+ return continueOperation(git, operation);
25371
+ },
25227
25372
  'open-pr': async () => {
25228
25373
  const repo = context.provider?.repository;
25229
25374
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
@@ -25747,6 +25892,14 @@ function LogInkApp(deps) {
25747
25892
  ? selected?.hash
25748
25893
  : undefined,
25749
25894
  worktreeDirty,
25895
+ conflictFileCount: context.operation?.conflictedFiles.length,
25896
+ conflictSelectedPath: (() => {
25897
+ const files = context.operation?.conflictedFiles;
25898
+ if (!files || files.length === 0)
25899
+ return undefined;
25900
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
25901
+ return files[clamped]?.path;
25902
+ })(),
25750
25903
  // H / gH need the actual diff text (not just hunk offsets) to
25751
25904
  // slice the cursored hunk into a `git apply` patch. Stash uses
25752
25905
  // the full `git stash show -p` output; commit-diff uses the
@@ -26111,6 +26264,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
26111
26264
  if (state.activeView === 'pull-request') {
26112
26265
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26113
26266
  }
26267
+ if (state.activeView === 'conflicts') {
26268
+ return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26269
+ }
26114
26270
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
26115
26271
  }
26116
26272
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -26294,6 +26450,79 @@ function buildStatusSurfaceRows(groups) {
26294
26450
  }
26295
26451
  return rows;
26296
26452
  }
26453
+ function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26454
+ const { Box, Text } = components;
26455
+ const focused = state.focus === 'commits';
26456
+ const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
26457
+ const operation = context.operation;
26458
+ const conflictedFiles = operation?.conflictedFiles || [];
26459
+ const operationType = operation?.operation || 'none';
26460
+ // If no operation is in progress, show a fallback message.
26461
+ if (!loading && operationType === 'none') {
26462
+ return h(Box, {
26463
+ borderColor: focusBorderColor(theme, focused),
26464
+ borderStyle: theme.borderStyle,
26465
+ flexDirection: 'column',
26466
+ flexShrink: 0,
26467
+ paddingX: 1,
26468
+ width,
26469
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, 'no operation in progress')), h(Text, { key: 'conflicts-empty', dimColor: true }, 'No merge, rebase, cherry-pick, or revert in progress.'));
26470
+ }
26471
+ // All conflicts resolved — show the "continue" hint.
26472
+ if (!loading && conflictedFiles.length === 0 && operationType !== 'none') {
26473
+ return h(Box, {
26474
+ borderColor: focusBorderColor(theme, focused),
26475
+ borderStyle: theme.borderStyle,
26476
+ flexDirection: 'column',
26477
+ flexShrink: 0,
26478
+ paddingX: 1,
26479
+ width,
26480
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, `${operationType} — all conflicts resolved`)), h(Text, { key: 'conflicts-hint', dimColor: true }, `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.`));
26481
+ }
26482
+ const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1)));
26483
+ const listRows = Math.max(4, bodyRows - 4);
26484
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
26485
+ const visible = conflictedFiles.slice(startIndex, startIndex + listRows);
26486
+ const remaining = conflictedFiles.length;
26487
+ const headerRight = loading
26488
+ ? 'loading conflicts'
26489
+ : `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining`;
26490
+ const statusLabel = (file) => {
26491
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
26492
+ switch (code) {
26493
+ case 'UU': return 'both modified';
26494
+ case 'AA': return 'added by both';
26495
+ case 'DD': return 'both deleted';
26496
+ case 'AU':
26497
+ case 'UA': return 'added by one';
26498
+ case 'DU': return 'deleted by us';
26499
+ case 'UD': return 'deleted by them';
26500
+ default: return code;
26501
+ }
26502
+ };
26503
+ const lines = loading
26504
+ ? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))]
26505
+ : visible.map((file, offset) => {
26506
+ const index = startIndex + offset;
26507
+ const isSelected = index === selected;
26508
+ const cursor = isSelected ? '>' : ' ';
26509
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
26510
+ const label = statusLabel(file);
26511
+ return h(Text, {
26512
+ key: `conflict-${index}`,
26513
+ bold: isSelected,
26514
+ dimColor: !isSelected,
26515
+ }, truncate$1(`${cursor} ${code} ${file.path} (${label})`, width - 4));
26516
+ });
26517
+ return h(Box, {
26518
+ borderColor: focusBorderColor(theme, focused),
26519
+ borderStyle: theme.borderStyle,
26520
+ flexDirection: 'column',
26521
+ flexShrink: 0,
26522
+ paddingX: 1,
26523
+ width,
26524
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
26525
+ }
26297
26526
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26298
26527
  const { Box, Text } = components;
26299
26528
  const focused = state.focus === 'commits';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.41.2",
3
+ "version": "0.42.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",
@@ -39,6 +39,7 @@
39
39
  "test": "npm run test:jest && npm run test:publish",
40
40
  "test:publish": "npm run lint && npm run build && npm run test:cli && npm pack --dry-run",
41
41
  "test:cli": "tsx bin/smokeCli.ts",
42
+ "bench": "tsx bin/benchmark.ts",
42
43
  "pretest:jest": "npm run build:info",
43
44
  "test:jest": "jest",
44
45
  "test:jest:watch": "jest --watch",