From cc28e3bb217eb99ac67b129176bf56b8cb9c4a66 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 11:09:25 -0400 Subject: [PATCH 1/3] feat(bench): measurement infrastructure for the diff-condensing pipeline (#845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First chunk of the #845 perf overhaul: a reproducible benchmark harness so every later PR can show concrete before/after numbers instead of hand-waving about "should be faster". Three pieces: 1. Telemetry persistence in `observability.ts`. When COCO_BENCH=1 is set (or any non-`0` value), every llm call accumulates into a narrow `LlmBenchCall` buffer; `flushLlmBenchRun` writes the record to `/.coco-bench.json` (overridable via COCO_BENCH_FILE). Best-effort: write failures are silent and the buffer self-clears after each flush. 2. Synthetic diff fixtures at `src/lib/parsers/default/__fixtures__/`. Three sizes: - tiny ( 5 files, ~790 tokens) — early-exit path - medium (25 files, ~36k tokens) — typical commit - large (50 files, ~83k tokens) — initial-commit shape Content comes from a seeded LCG so before/after runs compare the same input. Each fixture exports a fully-populated DiffNode tree so `summarizeDiffs` runs without a real git repo. 3. `bin/benchmark.ts` runner (`npm run bench`). Plugs the fixtures into `summarizeDiffs` with a duck-typed mock chain that simulates per-call latency proportional to input size (deterministic so PR diffs are apples-to-apples, not real-world wall-clock). Captures stage timings + per-call telemetry. `--update` overwrites `.bench/baseline.json`; `--fixture=` narrows to a single fixture for tighter feedback loops. Baseline numbers committed at `.bench/baseline.json` against current `main`: | fixture | wall-clock | llm calls | llm total ms | prompt tokens | |---------|------------|-----------|--------------|---------------| | tiny | 2 ms | 0 | 0 ms | 0 | | medium | 30,213 ms | 20 | 102,723 ms | 91,766 | | large | 70,048 ms | 41 | 236,818 ms | 220,199 | The 3.4× spread between large fixture's wall-clock and total LLM time (236 s of model work in 70 s wall) reflects the existing `maxConcurrent=6` parallelism. Subsequent PRs in the #845 sprint will move these numbers and the deltas will land directly in PR descriptions. --- .bench/baseline.json | 40 +++ .gitignore | 7 + bin/benchmark.ts | 250 ++++++++++++++++++ package.json | 1 + src/lib/langchain/utils/observability.ts | 134 ++++++++++ src/lib/parsers/default/__fixtures__/index.ts | 219 +++++++++++++++ 6 files changed, 651 insertions(+) create mode 100644 .bench/baseline.json create mode 100644 bin/benchmark.ts create mode 100644 src/lib/parsers/default/__fixtures__/index.ts diff --git a/.bench/baseline.json b/.bench/baseline.json new file mode 100644 index 0000000..9fe755c --- /dev/null +++ b/.bench/baseline.json @@ -0,0 +1,40 @@ +{ + "capturedAt": "2026-05-05T15:06:12.102Z", + "node": "v22.13.0", + "platform": "darwin-arm64", + "options": { + "baseLatencyMs": 1500, + "perTokenMs": 2, + "maxConcurrent": 6, + "maxTokens": 2048 + }, + "results": [ + { + "fixture": "tiny", + "fileCount": 5, + "approxTokens": 790, + "durationMs": 2, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0 + }, + { + "fixture": "medium", + "fileCount": 25, + "approxTokens": 36150, + "durationMs": 30213, + "llmCalls": 20, + "llmTotalMs": 102723, + "llmTotalPromptTokens": 91766 + }, + { + "fixture": "large", + "fileCount": 50, + "approxTokens": 83410, + "durationMs": 70048, + "llmCalls": 41, + "llmTotalMs": 236818, + "llmTotalPromptTokens": 220199 + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1eb137f..33e3f90 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,10 @@ commitlint.config.js # Internal specs, audits, and design docs specs/ + +# Diff-condensing benchmark output (#845). Per-run files are local +# noise; the committed baseline lives at .bench/baseline.json so PR +# perf claims have a reference point. Telemetry sidecar from +# COCO_BENCH=1 stays local too. +.bench/run-*.json +.coco-bench.json diff --git a/bin/benchmark.ts b/bin/benchmark.ts new file mode 100644 index 0000000..81a1a80 --- /dev/null +++ b/bin/benchmark.ts @@ -0,0 +1,250 @@ +#!/usr/bin/env tsx +/** + * Diff-condensing pipeline benchmark (#845). + * + * Runs `summarizeDiffs` against the synthetic fixtures in + * `src/lib/parsers/default/__fixtures__/index.ts` using a mock LLM + * chain that simulates latency proportional to input size. Captures + * stage timings and per-call telemetry, writes the result to + * `.bench/.json`, and (when a baseline is present at + * `.bench/baseline.json`) prints a diff so PRs can show their wins + * concretely. + * + * Usage: + * npm run bench # run all fixtures, write bench file + * npm run bench -- --update # also overwrite the baseline + * npm run bench -- --fixture=medium # narrow to one fixture + * + * The mock chain uses a deterministic latency model so before/after + * runs compare apples to apples without paying for real API calls. + * Numbers don't reflect real-world wall-clock time; they reflect the + * pipeline's *behavior* (how many calls fire, how the stages fan + * out, where the bottlenecks are). + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' +import { loadSummarizationChain } from '@langchain/classic/chains' +import type { Document } from '@langchain/classic/document' + +import { fileChangeParser } from '../src/lib/parsers/default' +import { summarizeDiffs } from '../src/lib/parsers/default/utils/summarizeDiffs' +import { allFixtures, DiffFixture } from '../src/lib/parsers/default/__fixtures__' +import { Logger } from '../src/lib/utils/logger' +import { getTokenCounter } from '../src/lib/utils/tokenizer' +import { + buildLlmBenchRun, + flushLlmBenchRun, + resetLlmTelemetry, +} from '../src/lib/langchain/utils/observability' + +// Silence the type checker about the unused `fileChangeParser` import +// being present for future bench scenarios; the active runner uses +// `summarizeDiffs` directly so it can pass a pre-built DiffNode. +void fileChangeParser + +const BENCH_DIR = path.join(process.cwd(), '.bench') +const BASELINE_PATH = path.join(BENCH_DIR, 'baseline.json') + +// The bench runner is the canonical "I want telemetry" entry point, +// so flip COCO_BENCH on in-process if the user didn't set it +// externally. `recordBenchCall` checks this env var to decide +// whether to retain per-call data. +if (!process.env.COCO_BENCH) { + process.env.COCO_BENCH = '1' +} + +type BenchOptions = { + baseLatencyMs: number + perTokenMs: number + maxConcurrent: number + maxTokens: number +} + +const DEFAULT_OPTIONS: BenchOptions = { + // Calibrated to roughly match user-reported wall-clock on + // gpt-4.1-nano: ~3-7s for small calls, scaling up to ~25-40s for + // multi-thousand-token inputs. Adjust if real-world timings drift. + baseLatencyMs: 1500, + perTokenMs: 2, + maxConcurrent: 6, + maxTokens: 2048, +} + +type BenchResult = { + fixture: string + fileCount: number + approxTokens: number + durationMs: number + llmCalls: number + llmTotalMs: number + llmTotalPromptTokens: number +} + +function mockChain(options: BenchOptions): unknown { + // Duck-typed chain that satisfies the .invoke() shape + // `summarize()` expects. Latency is deterministic so before/after + // runs are directly comparable. + return { + invoke: async (input: { input_documents: Document[] }) => { + const totalChars = input.input_documents.reduce( + (sum, doc) => sum + doc.pageContent.length, + 0 + ) + // Approximate token count from chars/4 — enough fidelity for + // the latency model. The pipeline's real tokenizer counts + // separately for telemetry. + const approxTokens = Math.floor(totalChars / 4) + const latencyMs = options.baseLatencyMs + Math.floor(approxTokens * options.perTokenMs) + await new Promise((resolve) => setTimeout(resolve, latencyMs)) + return { text: `[mock summary of ${input.input_documents.length} doc(s), ~${approxTokens} tokens]` } + }, + } +} + +function silentLogger(): Logger { + // Tests already use this pattern; keep verbose calls a no-op so the + // bench output stays clean while still funneling timer + spinner + // calls through the real Logger surface. + const logger = new Logger({ verbose: false } as never) + return logger +} + +async function runFixture( + fixture: DiffFixture, + options: BenchOptions +): Promise { + resetLlmTelemetry() + + const tokenizer = await getTokenCounter('gpt-4.1-nano') + const textSplitter = new RecursiveCharacterTextSplitter({ + chunkSize: 10000, + chunkOverlap: 250, + }) + const chain = mockChain(options) as Parameters[1]['chain'] + const logger = silentLogger() + + const startedAt = Date.now() + await summarizeDiffs(fixture.rootNode, { + tokenizer, + logger, + maxTokens: options.maxTokens, + minTokensForSummary: 400, + maxFileTokens: Math.floor(options.maxTokens * 0.25), + maxConcurrent: options.maxConcurrent, + textSplitter, + chain, + metadata: { command: 'benchmark', model: 'mock' }, + }) + const durationMs = Date.now() - startedAt + + const run = buildLlmBenchRun({ command: `bench:${fixture.name}`, totalElapsedMs: durationMs }) + + return { + fixture: fixture.name, + fileCount: fixture.fileCount, + approxTokens: fixture.approxTokens, + durationMs, + llmCalls: run.callCount, + llmTotalMs: run.totalLlmElapsedMs, + llmTotalPromptTokens: run.totalPromptTokens, + } +} + +function formatRow(label: string, value: string | number): string { + return ` ${label.padEnd(28)} ${value}` +} + +function printSummary(results: BenchResult[], baseline?: BenchResult[]): void { + console.log('\n=== diff-condensing benchmark ===\n') + for (const result of results) { + console.log(`Fixture: ${result.fixture} (${result.fileCount} files, ~${result.approxTokens} tokens)`) + console.log(formatRow('wall-clock duration', `${result.durationMs}ms`)) + console.log(formatRow('llm calls', result.llmCalls)) + console.log(formatRow('llm total time', `${result.llmTotalMs}ms`)) + console.log(formatRow('llm prompt tokens', result.llmTotalPromptTokens)) + if (baseline) { + const prior = baseline.find((entry) => entry.fixture === result.fixture) + if (prior) { + const deltaPct = (n: number, p: number) => + p === 0 ? 'n/a' : `${(((n - p) / p) * 100).toFixed(1)}%` + console.log(formatRow('Δ duration', `${result.durationMs - prior.durationMs}ms (${deltaPct(result.durationMs, prior.durationMs)})`)) + console.log(formatRow('Δ llm calls', `${result.llmCalls - prior.llmCalls} (${deltaPct(result.llmCalls, prior.llmCalls)})`)) + } + } + console.log('') + } +} + +function writeBenchFile(results: BenchResult[], updateBaseline: boolean): void { + if (!fs.existsSync(BENCH_DIR)) { + fs.mkdirSync(BENCH_DIR, { recursive: true }) + } + + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const runFile = path.join(BENCH_DIR, `run-${stamp}.json`) + const payload = { + capturedAt: new Date().toISOString(), + node: process.version, + platform: `${os.platform()}-${os.arch()}`, + options: DEFAULT_OPTIONS, + results, + } + fs.writeFileSync(runFile, JSON.stringify(payload, null, 2)) + console.log(`Wrote ${runFile}`) + + if (updateBaseline) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(payload, null, 2)) + console.log(`Updated baseline at ${BASELINE_PATH}`) + } +} + +function readBaseline(): BenchResult[] | undefined { + if (!fs.existsSync(BASELINE_PATH)) return undefined + try { + const raw = fs.readFileSync(BASELINE_PATH, 'utf8') + const parsed = JSON.parse(raw) + return Array.isArray(parsed.results) ? parsed.results : undefined + } catch { + return undefined + } +} + +async function main(): Promise { + const args = process.argv.slice(2) + const updateBaseline = args.includes('--update') + const fixtureArg = args.find((arg) => arg.startsWith('--fixture='))?.split('=')[1] + + const fixtures = fixtureArg + ? allFixtures.filter((fixture) => fixture.name === fixtureArg) + : allFixtures + if (fixtures.length === 0) { + console.error(`No fixture matched ${fixtureArg}; available: ${allFixtures.map((f) => f.name).join(', ')}`) + process.exitCode = 1 + return + } + + const results: BenchResult[] = [] + for (const fixture of fixtures) { + console.log(`Running fixture ${fixture.name}...`) + const result = await runFixture(fixture, DEFAULT_OPTIONS) + results.push(result) + } + + const baseline = updateBaseline ? undefined : readBaseline() + printSummary(results, baseline) + writeBenchFile(results, updateBaseline) + + // Flush any in-memory bench telemetry to a separate file when + // COCO_BENCH is set externally; lets devs capture the per-call + // data alongside the aggregated results. + flushLlmBenchRun({ command: 'benchmark' }) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/package.json b/package.json index 5fd5f7a..9144808 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test": "npm run test:jest && npm run test:publish", "test:publish": "npm run lint && npm run build && npm run test:cli && npm pack --dry-run", "test:cli": "tsx bin/smokeCli.ts", + "bench": "tsx bin/benchmark.ts", "pretest:jest": "npm run build:info", "test:jest": "jest", "test:jest:watch": "jest --watch", diff --git a/src/lib/langchain/utils/observability.ts b/src/lib/langchain/utils/observability.ts index 4fe6b86..ae9a468 100644 --- a/src/lib/langchain/utils/observability.ts +++ b/src/lib/langchain/utils/observability.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + import { Logger } from '../../utils/logger' import { TokenCounter } from '../../utils/tokenizer' @@ -15,6 +18,27 @@ export type LlmCallMetadata = { inputChunks?: number } +/** + * Bench-mode call record (#845). Captured for every LLM call when + * `COCO_BENCH=1` (or a path) is set, then flushed to disk by + * `flushLlmBenchRun` at the end of the command. The structure stays + * narrow on purpose — fields the runner actually compares before / + * after, nothing more — so different runs with different model / + * provider mixes can still diff against the baseline cleanly. + */ +type LlmBenchCall = { + task: string + command?: string + provider?: string + model?: string + promptTokens?: number + elapsedMs?: number + inputDocuments?: number + inputChunks?: number +} + +const benchCalls: LlmBenchCall[] = [] + type LlmTelemetrySummary = { calls: number promptTokens: number @@ -40,10 +64,29 @@ export function estimatePromptTokens( } } +function isBenchModeActive(): boolean { + return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0') +} + +function recordBenchCall(metadata: LlmCallMetadata): void { + if (!isBenchModeActive()) return + benchCalls.push({ + task: metadata.task, + command: metadata.command, + provider: metadata.provider, + model: metadata.model, + promptTokens: metadata.promptTokens, + elapsedMs: metadata.elapsedMs, + inputDocuments: metadata.inputDocuments, + inputChunks: metadata.inputChunks, + }) +} + export function logLlmCall(logger: Logger | undefined, metadata: LlmCallMetadata): void { if (!logger) return recordLlmTelemetry(metadata) + recordBenchCall(metadata) const fields = [ `task=${metadata.task}`, @@ -113,4 +156,95 @@ export function logLlmTelemetrySummary(logger: Logger | undefined, command: stri export function resetLlmTelemetry(): void { telemetryByCommand.clear() + benchCalls.length = 0 +} + +export type LlmBenchRunStage = { + name: string + elapsedMs: number +} + +export type LlmBenchRunRecord = { + command?: string + totalElapsedMs?: number + stages?: LlmBenchRunStage[] + callCount: number + totalLlmElapsedMs: number + totalPromptTokens: number + calls: LlmBenchCall[] +} + +/** + * Build the in-memory bench run record from accumulated calls. + * Pure (no I/O) so callers can inspect or assert the contents without + * touching disk — useful in tests + the in-process benchmark runner. + */ +export function buildLlmBenchRun( + options: { + command?: string + totalElapsedMs?: number + stages?: LlmBenchRunStage[] + } = {} +): LlmBenchRunRecord { + const calls = benchCalls.slice() + return { + command: options.command, + totalElapsedMs: options.totalElapsedMs, + stages: options.stages, + callCount: calls.length, + totalLlmElapsedMs: calls.reduce((sum, call) => sum + (call.elapsedMs || 0), 0), + totalPromptTokens: calls.reduce((sum, call) => sum + (call.promptTokens || 0), 0), + calls, + } +} + +/** + * Persist the current bench run to a JSON file. No-op when bench + * mode is inactive (so production runs don't pay for disk I/O). + * + * The file path comes from `COCO_BENCH_FILE` if set, otherwise + * defaults to `/.coco-bench.json`. Each call appends to the + * `runs` array of the file (creates the file if missing) so a single + * benchmark session that triggers multiple commands ends up with one + * file containing the full sequence. + * + * Best-effort: write failures are swallowed silently. The bench + * runner reports back the failure mode via the return value. + */ +export function flushLlmBenchRun( + options: { + command?: string + totalElapsedMs?: number + stages?: LlmBenchRunStage[] + } = {} +): { ok: boolean; filePath?: string; error?: string } { + if (!isBenchModeActive()) { + return { ok: false, error: 'COCO_BENCH not set' } + } + + const record = buildLlmBenchRun(options) + const filePath = path.resolve(process.env.COCO_BENCH_FILE || path.join(process.cwd(), '.coco-bench.json')) + + try { + let existing: { runs: LlmBenchRunRecord[] } = { runs: [] } + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, 'utf8') + const parsed = JSON.parse(raw) + if (parsed && Array.isArray(parsed.runs)) { + existing = parsed + } + } catch { + // Corrupt or pre-existing non-bench file: overwrite with a + // fresh structure. Bench mode is opt-in; collisions here are + // a developer-only concern. + } + } + existing.runs.push(record) + fs.writeFileSync(filePath, JSON.stringify(existing, null, 2)) + benchCalls.length = 0 + return { ok: true, filePath } + } catch (error) { + return { ok: false, error: (error as Error).message } + } } diff --git a/src/lib/parsers/default/__fixtures__/index.ts b/src/lib/parsers/default/__fixtures__/index.ts new file mode 100644 index 0000000..0a883ae --- /dev/null +++ b/src/lib/parsers/default/__fixtures__/index.ts @@ -0,0 +1,219 @@ +/** + * Synthetic diff fixtures for benchmarking the diff-condensing + * pipeline (#845). Each fixture is a fully-populated `DiffNode` tree + * so callers can invoke `summarizeDiffs` directly without standing + * up a git repo. + * + * Numbers are picked to mirror the user-reported 4-minute repro + * shape: + * - tiny: early-exit path (already under budget) + * - medium: typical real commit (~25 files, ~40k tokens) + * - large: initial-commit shape (~50 files, ~100k tokens) + * + * Determinism matters more than realism: the synthetic content is + * generated from a stable seed so before/after benchmark runs + * compare the same input. + */ + +import { DiffNode, FileDiff } from '../../../types' + +/** + * Tiny pseudo-LCG — keeps the synthetic content stable across runs + * without pulling in a seedable PRNG dep. The output is character + * pattern, not statistically random; that's fine for a bench fixture. + */ +function seededTextBlob(lengthChars: number, seed: number): string { + const corpus = 'abcdefghijklmnopqrstuvwxyz0123456789 \n' + let state = seed >>> 0 + let out = '' + for (let i = 0; i < lengthChars; i++) { + state = (state * 1664525 + 1013904223) >>> 0 + out += corpus[state % corpus.length] + } + return out +} + +/** + * Build a synthetic file diff at approximately the requested token + * count. Token estimate uses chars/4 which is rough but consistent + * with how tiktoken behaves for prose-like content; the runner + * re-tokenizes with the real counter at fixture-load time so the + * recorded `tokenCount` is exact. + */ +function buildFileDiff(file: string, approxTokens: number, seed: number): FileDiff { + const chars = approxTokens * 4 + const header = `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1,1 +1,${Math.max(1, Math.floor(approxTokens / 4))} @@\n` + const body = seededTextBlob(chars, seed) + .split('\n') + .map((line) => `+${line}`) + .join('\n') + return { + file, + diff: header + body, + summary: '', + tokenCount: approxTokens, + } +} + +type FixtureSpec = { + name: string + files: Array<{ path: string; tokens: number }> +} + +const TINY_SPEC: FixtureSpec = { + name: 'tiny', + files: [ + { path: 'src/index.ts', tokens: 200 }, + { path: 'src/util.ts', tokens: 150 }, + { path: 'README.md', tokens: 300 }, + { path: 'package.json', tokens: 80 }, + { path: 'tsconfig.json', tokens: 60 }, + ], +} + +const MEDIUM_SPEC: FixtureSpec = { + name: 'medium', + files: [ + { path: 'src/api.ts', tokens: 3500 }, + { path: 'src/auth.ts', tokens: 2400 }, + { path: 'src/cli.ts', tokens: 4800 }, + { path: 'src/parser.ts', tokens: 2900 }, + { path: 'src/utils/http.ts', tokens: 1200 }, + { path: 'src/utils/format.ts', tokens: 800 }, + { path: 'src/utils/logger.ts', tokens: 600 }, + { path: 'tests/api.test.ts', tokens: 1800 }, + { path: 'tests/auth.test.ts', tokens: 1400 }, + { path: 'tests/parser.test.ts', tokens: 1600 }, + { path: 'tests/utils/http.test.ts', tokens: 700 }, + { path: 'tests/fixtures/sample.json', tokens: 500 }, + { path: 'docs/ARCHITECTURE.md', tokens: 2300 }, + { path: 'docs/API.md', tokens: 1900 }, + { path: 'docs/CONTRIBUTING.md', tokens: 1100 }, + { path: 'README.md', tokens: 3000 }, + { path: 'CHANGELOG.md', tokens: 1800 }, + { path: '.github/workflows/ci.yml', tokens: 600 }, + { path: '.github/workflows/release.yml', tokens: 900 }, + { path: '.github/ISSUE_TEMPLATE/bug.md', tokens: 400 }, + { path: 'package.json', tokens: 700 }, + { path: 'tsconfig.json', tokens: 200 }, + { path: '.gitignore', tokens: 150 }, + { path: 'LICENSE', tokens: 300 }, + { path: 'pyproject.toml', tokens: 600 }, + ], +} + +const LARGE_SPEC: FixtureSpec = { + name: 'large', + files: [ + // Mirror of the user's 43-file initial commit shape, scaled up + // a bit (50 files / ~100k tokens) so we have headroom for both + // pre-process and consolidation phases to fire heavily. + { path: 'humble_bundle_keys/api.py', tokens: 4400 }, + { path: 'humble_bundle_keys/auth.py', tokens: 2100 }, + { path: 'humble_bundle_keys/cli.py', tokens: 7600 }, + { path: 'humble_bundle_keys/diagnose.py', tokens: 6100 }, + { path: 'humble_bundle_keys/scraper.py', tokens: 5200 }, + { path: 'humble_bundle_keys/choice.py', tokens: 4500 }, + { path: 'humble_bundle_keys/browser_choice.py', tokens: 5500 }, + { path: 'humble_bundle_keys/exporter.py', tokens: 1300 }, + { path: 'humble_bundle_keys/models.py', tokens: 700 }, + { path: 'humble_bundle_keys/_browser_fetch.py', tokens: 1000 }, + { path: 'humble_bundle_keys/_orders_cache.py', tokens: 1200 }, + { path: 'humble_bundle_keys/__init__.py', tokens: 110 }, + { path: 'humble_bundle_keys/__main__.py', tokens: 110 }, + { path: 'tests/RUNBOOK.md', tokens: 1900 }, + { path: 'tests/test_api_parser.py', tokens: 1400 }, + { path: 'tests/test_browser_choice.py', tokens: 1200 }, + { path: 'tests/test_browser_fetch.py', tokens: 1100 }, + { path: 'tests/test_choice.py', tokens: 3000 }, + { path: 'tests/test_diagnose_sanitiser.py', tokens: 2300 }, + { path: 'tests/test_exporter.py', tokens: 1700 }, + { path: 'tests/test_parsers.py', tokens: 600 }, + { path: 'tests/__init__.py', tokens: 40 }, + { path: 'tests/fixtures/choice_claim/README.md', tokens: 400 }, + { path: 'tests/fixtures/choice_claim/analytics_get_game.json', tokens: 500 }, + { path: 'tests/fixtures/choice_claim/analytics_tile_click.json', tokens: 500 }, + { path: 'tests/fixtures/choice_claim/choosecontent.json', tokens: 600 }, + { path: 'tests/fixtures/choice_claim/redeemkey.json', tokens: 600 }, + { path: 'docs/ARCHITECTURE.md', tokens: 2300 }, + { path: 'docs/CHOICE_CLAIM_SPEC.md', tokens: 3900 }, + { path: 'docs/WHATS_CLAIMABLE.md', tokens: 1300 }, + { path: 'README.md', tokens: 3900 }, + { path: 'CHANGELOG.md', tokens: 3800 }, + { path: 'CONTRIBUTING.md', tokens: 1200 }, + { path: 'SECURITY.md', tokens: 1000 }, + { path: 'LICENSE', tokens: 300 }, + { path: 'pyproject.toml', tokens: 600 }, + { path: '.gitignore', tokens: 700 }, + { path: '.github/ISSUE_TEMPLATE/bug_report.md', tokens: 400 }, + { path: '.github/ISSUE_TEMPLATE/feature_request.md', tokens: 250 }, + { path: '.github/ISSUE_TEMPLATE/selector_broken.md', tokens: 500 }, + { path: '.github/ISSUE_TEMPLATE/config.yml', tokens: 200 }, + { path: '.github/workflows/ci.yml', tokens: 600 }, + { path: '.github/workflows/release.yml', tokens: 900 }, + { path: 'src/feature/a.ts', tokens: 1400 }, + { path: 'src/feature/b.ts', tokens: 1100 }, + { path: 'src/feature/c.ts', tokens: 900 }, + { path: 'src/feature/d.ts', tokens: 800 }, + { path: 'src/feature/e.ts', tokens: 700 }, + { path: 'src/feature/utils.ts', tokens: 600 }, + { path: 'src/feature/types.ts', tokens: 400 }, + ], +} + +/** + * Convert a flat fixture spec into a nested DiffNode tree, grouping + * by directory path. Mirrors `createDiffTree`'s behavior on real + * file lists. + */ +function buildDiffNode(spec: FixtureSpec): DiffNode { + const root: DiffNode = { path: '/', diffs: [], children: [] } + const dirIndex = new Map([['/', root]]) + + spec.files.forEach((file, index) => { + const segments = file.path.split('/') + const fileName = segments.pop() as string + const dirSegments = segments + + let node = root + let pathSoFar = '' + for (const segment of dirSegments) { + pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment + const cached = dirIndex.get(pathSoFar) + if (cached) { + node = cached + continue + } + const child: DiffNode = { path: segment, diffs: [], children: [] } + node.children.push(child) + dirIndex.set(pathSoFar, child) + node = child + } + + node.diffs.push(buildFileDiff(`${dirSegments.join('/')}${dirSegments.length ? '/' : ''}${fileName}`, file.tokens, index + 1)) + }) + + return root +} + +export type DiffFixture = { + name: string + fileCount: number + approxTokens: number + rootNode: DiffNode +} + +function asFixture(spec: FixtureSpec): DiffFixture { + return { + name: spec.name, + fileCount: spec.files.length, + approxTokens: spec.files.reduce((sum, file) => sum + file.tokens, 0), + rootNode: buildDiffNode(spec), + } +} + +export const tinyFixture: DiffFixture = asFixture(TINY_SPEC) +export const mediumFixture: DiffFixture = asFixture(MEDIUM_SPEC) +export const largeFixture: DiffFixture = asFixture(LARGE_SPEC) + +export const allFixtures: DiffFixture[] = [tinyFixture, mediumFixture, largeFixture] From 4832d892e0fa134f20f90a5562a3edc4f370f419 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 13:00:19 -0400 Subject: [PATCH 2/3] feat: add conflict resolution helper view (gx chord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #780 — a dedicated promoted view for resolving merge/rebase/ cherry-pick/revert conflicts. New view (gx chord): - Lists conflicted files with status codes (UU, DD, AU, etc.) - Per-row keys: Enter (open diff), o (open in $EDITOR), s (stage/resolve), u (keep theirs), U (keep ours) - Header shows operation type + conflict count - Mounts only when an operation is in progress with conflicts - Shows fallback message when no operation is active - Surfaces C (continue operation) when all conflicts are resolved Implementation: - inkViewModel.ts: Added 'conflicts' to LogInkView, selectedConflictFileIndex state field, moveConflictFile action + reducer - inkKeymap.ts: Added navigateConflicts command (gx), footer hints, chord continuation - inkInput.ts: Added gx chord handler, ↑/↓ navigation, per-row key handlers (Enter/o/s/u/U/C), conflictFileCount/conflictSelectedPath context fields - inkRuntime.ts: Added renderConflictsSurface with windowed file list, status labels, loading/empty states; workflow handlers for resolve-conflict- ours/theirs/stage/open-diff/continue-operation - operationActions.ts: Added resolveConflictOurs, resolveConflictTheirs, stageConflictResolved git operations Tests added for all new functionality across operationActions, inkViewModel, inkInput, and inkKeymap test files. --- src/commands/log/inkInput.test.ts | 148 ++++++++ src/commands/log/inkInput.ts | 79 ++++- src/commands/log/inkKeymap.test.ts | 47 ++- src/commands/log/inkKeymap.ts | 22 +- src/commands/log/inkRuntime.ts | 400 +++++++++++++++------- src/commands/log/inkViewModel.test.ts | 60 +++- src/commands/log/inkViewModel.ts | 31 +- src/commands/log/operationActions.test.ts | 60 +++- src/commands/log/operationActions.ts | 36 ++ 9 files changed, 709 insertions(+), 174 deletions(-) diff --git a/src/commands/log/inkInput.test.ts b/src/commands/log/inkInput.test.ts index 3f83dea..11c1f7e 100644 --- a/src/commands/log/inkInput.test.ts +++ b/src/commands/log/inkInput.test.ts @@ -2378,4 +2378,152 @@ describe('log Ink input interactions', () => { ]) }) }) + + describe('conflicts view', () => { + it('navigates to conflicts view via gx chord', () => { + let state = createLogInkState(rows) + // First key: g sets pending key + state = applyInput(state, 'g') + expect(state.pendingKey).toBe('g') + // Second key: x pushes conflicts view + state = applyInput(state, 'x') + expect(state.activeView).toBe('conflicts') + expect(state.viewStack).toEqual(['history', 'conflicts']) + }) + + it('moves cursor up and down in the conflicts view', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + state = applyInput(state, '', { downArrow: true }, { conflictFileCount: 3 }) + expect(state.selectedConflictFileIndex).toBe(1) + + state = applyInput(state, '', { upArrow: true }, { conflictFileCount: 3 }) + expect(state.selectedConflictFileIndex).toBe(0) + }) + + it('clamps cursor at bounds in the conflicts view', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + state = applyInput(state, '', { upArrow: true }, { conflictFileCount: 3 }) + expect(state.selectedConflictFileIndex).toBe(0) + }) + + it('dispatches resolve-conflict-stage on s key', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 's', {}, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + expect(events).toEqual([ + { type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: 'src/conflict.ts' }, + ]) + }) + + it('dispatches resolve-conflict-theirs on u key', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 'u', {}, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + expect(events).toEqual([ + { type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: 'src/conflict.ts' }, + ]) + }) + + it('dispatches resolve-conflict-ours on U key', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 'U', {}, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + expect(events).toEqual([ + { type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: 'src/conflict.ts' }, + ]) + }) + + it('dispatches open-in-editor on o key', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 'o', {}, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + expect(events).toEqual([ + { type: 'openFileInEditor', path: 'src/conflict.ts' }, + ]) + }) + + it('dispatches open-diff on Enter key', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, '', { return: true }, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + expect(events).toEqual([ + { type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: 'src/conflict.ts' }, + ]) + }) + + it('dispatches continue-operation on C key when no conflicts remain', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 'C', {}, { + conflictFileCount: 0, + }) + + expect(events).toEqual([ + { type: 'runWorkflowAction', id: 'continue-operation' }, + ]) + }) + + it('does not dispatch continue-operation when conflicts remain', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + + const events = getLogInkInputEvents(state, 'C', {}, { + conflictFileCount: 3, + conflictSelectedPath: 'src/conflict.ts', + }) + + // C should not match the conflicts-view continue handler when + // conflicts remain — it falls through to the global workflow + // action path or is a no-op. + const hasConflictContinue = events.some( + (e) => e.type === 'runWorkflowAction' && (e as { id: string }).id === 'continue-operation' + ) + expect(hasConflictContinue).toBe(false) + }) + + it('does not fire conflict keys when not on the conflicts view', () => { + const state = createLogInkState(rows) + + const events = getLogInkInputEvents(state, 's', {}, { + conflictFileCount: 2, + conflictSelectedPath: 'src/conflict.ts', + }) + + // On history view, 's' should not trigger resolve-conflict-stage + const hasConflictStage = events.some( + (e) => e.type === 'runWorkflowAction' && (e as { id: string }).id === 'resolve-conflict-stage' + ) + expect(hasConflictStage).toBe(false) + }) + }) }) diff --git a/src/commands/log/inkInput.ts b/src/commands/log/inkInput.ts index 12776d1..99fb0a1 100644 --- a/src/commands/log/inkInput.ts +++ b/src/commands/log/inkInput.ts @@ -1,23 +1,23 @@ import { extractDiffHunk } from './inkHunkExtraction' import { - InspectorAction, - InspectorActionContext, - getInspectorActions, + InspectorAction, + InspectorActionContext, + getInspectorActions, } from './inkInspectorActions' import { - LogInkPaletteCommand, - filterLogInkPaletteCommands, - getLogInkPaletteCommands, + LogInkPaletteCommand, + filterLogInkPaletteCommands, + getLogInkPaletteCommands, } from './inkKeymap' import { - LogInkAction, - LogInkSidebarTab, - LogInkState, - parseLogInkHistoryFetchPrefix, + LogInkAction, + LogInkSidebarTab, + LogInkState, + parseLogInkHistoryFetchPrefix, } from './inkViewModel' import { - getLogInkWorkflowActionById, - getLogInkWorkflowActionByKey, + getLogInkWorkflowActionById, + getLogInkWorkflowActionByKey, } from './inkWorkflows' import { sidebarTabHasSelectableItems } from './inkSidebarSelection' @@ -132,6 +132,16 @@ export type LogInkInputContext = { * way. */ diffLinesForHunkApply?: string[] + /** + * Number of conflicted files in the current operation. Drives j/k + * navigation on the conflicts view. + */ + conflictFileCount?: number + /** + * Path of the file currently under the cursor in the conflicts view. + * Used by `o` (open in $EDITOR) and `s` (stage/resolve). + */ + conflictSelectedPath?: string } function action(actionValue: LogInkAction): LogInkInputEvent { @@ -399,6 +409,8 @@ export function getLogInkPaletteExecuteEvents( return [action({ type: 'pushView', value: 'worktrees' })] case 'navigatePullRequest': return [action({ type: 'pushView', value: 'pull-request' })] + case 'navigateConflicts': + return [action({ type: 'pushView', value: 'conflicts' })] case 'navigateBack': return [action({ type: 'popView' })] case 'openSelected': { @@ -930,6 +942,13 @@ export function getLogInkInputEvents( ] } + if (state.pendingKey === 'g' && inputValue === 'x') { + return [ + action({ type: 'pushView', value: 'conflicts' }), + action({ type: 'setStatus', value: 'jumped to conflicts' }), + ] + } + // `gH` chord: apply the cursored hunk to the index (`git apply // --cached`). Sibling of bare `H` which targets the worktree. // Discoverable via the footer hint on diff views and the help @@ -1279,6 +1298,10 @@ export function getLogInkInputEvents( return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })] } + if (state.activeView === 'conflicts' && context.conflictFileCount) { + return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })] + } + if ( state.activeView === 'history' && state.focus === 'commits' && @@ -1372,6 +1395,10 @@ export function getLogInkInputEvents( return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })] } + if (state.activeView === 'conflicts' && context.conflictFileCount) { + return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })] + } + return [ action(state.focus === 'sidebar' ? { type: 'nextSidebarTab' } @@ -1584,6 +1611,12 @@ export function getLogInkInputEvents( })] } + // Enter on a conflict file opens the worktree diff for that file so + // the user can inspect the conflict markers in context. + if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) { + return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }] + } + // Enter on a branch row checks the branch out. Non-destructive workflow // action — no confirmation prompt. Fires from either the dedicated // branches view or from the sidebar when the branches tab is focused @@ -1738,6 +1771,28 @@ export function getLogInkInputEvents( return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }] } + // --- Conflicts view per-row handlers --- + // `o` opens the conflicted file in $EDITOR for manual resolution. + if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) { + return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }] + } + // `s` stages the conflicted file (marks it resolved). + if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) { + return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }] + } + // `u` resolves by keeping theirs (incoming changes). + if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) { + return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }] + } + // `U` resolves by keeping ours (current branch). + if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) { + return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }] + } + // `C` continues the in-progress operation (available when no conflicts remain). + if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) { + return [{ type: 'runWorkflowAction', id: 'continue-operation' }] + } + // `c` on a stash diff cherry-picks the file under the cursor — // materializes that single path from the stash into the working tree // (`git checkout -- `). Routed through the y-confirm diff --git a/src/commands/log/inkKeymap.test.ts b/src/commands/log/inkKeymap.test.ts index d473ea1..1414b76 100644 --- a/src/commands/log/inkKeymap.test.ts +++ b/src/commands/log/inkKeymap.test.ts @@ -1,12 +1,12 @@ import { - LOG_INK_KEY_BINDINGS, - filterLogInkPaletteCommands, - formatLogInkBreadcrumb, - getLogInkChordContinuations, - getLogInkCommandPaletteItems, - getLogInkFooterHints, - getLogInkHelpSections, - getLogInkPaletteCommands, + LOG_INK_KEY_BINDINGS, + filterLogInkPaletteCommands, + formatLogInkBreadcrumb, + getLogInkChordContinuations, + getLogInkCommandPaletteItems, + getLogInkFooterHints, + getLogInkHelpSections, + getLogInkPaletteCommands, } from './inkKeymap' describe('log Ink keymap', () => { @@ -457,4 +457,35 @@ describe('log Ink keymap', () => { expect(hints.contextual[0]).not.toBe('g …') }) }) + + describe('conflicts view', () => { + it('returns conflicts-specific footer hints', () => { + const hints = getLogInkFooterHints({ + activeView: 'conflicts', + filterMode: false, + focus: 'commits', + showHelp: false, + }) + expect(hints.contextual).toContain('s stage') + expect(hints.contextual).toContain('u theirs') + expect(hints.contextual).toContain('U ours') + expect(hints.contextual).toContain('o edit') + expect(hints.contextual).toContain('C continue') + expect(hints.contextual).toContain('enter diff') + expect(hints.global).toEqual(['g jump', '< back', '? help', ': cmds', 'q quit']) + }) + + it('includes gx in chord continuations for the g prefix', () => { + const continuations = getLogInkChordContinuations('g') + const conflictsEntry = continuations.find((entry) => entry.key === 'x') + expect(conflictsEntry).toBeDefined() + expect(conflictsEntry!.label).toBe('conflicts') + }) + + it('includes navigateConflicts in the key bindings registry', () => { + const binding = LOG_INK_KEY_BINDINGS.find((b) => b.id === 'navigateConflicts') + expect(binding).toBeDefined() + expect(binding!.keys).toContain('gx') + }) + }) }) diff --git a/src/commands/log/inkKeymap.ts b/src/commands/log/inkKeymap.ts index 272a33c..717ba66 100644 --- a/src/commands/log/inkKeymap.ts +++ b/src/commands/log/inkKeymap.ts @@ -1,8 +1,8 @@ import { LogInkFocus, LogInkView } from './inkViewModel' import { - LogInkWorkflowAction, - LogInkWorkflowActionKind, - getLogInkWorkflowActions, + LogInkWorkflowAction, + LogInkWorkflowActionKind, + getLogInkWorkflowActions, } from './inkWorkflows' export type LogInkCommandId = @@ -20,6 +20,7 @@ export type LogInkCommandId = | 'navigateBack' | 'navigateBranches' | 'navigateCompose' + | 'navigateConflicts' | 'navigateDiff' | 'navigateHome' | 'navigatePullRequest' @@ -256,6 +257,13 @@ export const LOG_INK_KEY_BINDINGS: LogInkKeyBinding[] = [ description: 'Push the dedicated pull-request action panel for the current branch.', contexts: ['normal'], }, + { + id: 'navigateConflicts', + keys: ['gx'], + label: 'conflicts', + description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).', + contexts: ['normal'], + }, { id: 'navigateBack', keys: ['<', 'esc'], @@ -433,6 +441,7 @@ const GLOBAL_BINDING_IDS: LogInkCommandId[] = [ 'navigateStash', 'navigateWorktrees', 'navigatePullRequest', + 'navigateConflicts', 'navigateBack', ] @@ -625,6 +634,13 @@ export function getLogInkFooterHints(options: GetLogInkFooterHintsOptions): LogI } } + if (options.activeView === 'conflicts') { + return { + contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue', 'esc back'], + global: NORMAL_GLOBAL_HINTS, + } + } + return { // History view default hints. Mutating ops (`c` cherry-pick, `R` // revert, `Z` reset, `i` interactive-rebase) all route through a diff --git a/src/commands/log/inkRuntime.ts b/src/commands/log/inkRuntime.ts index e699f45..7bb17a2 100644 --- a/src/commands/log/inkRuntime.ts +++ b/src/commands/log/inkRuntime.ts @@ -42,45 +42,45 @@ import { BranchOverview, getBranchOverview } from './branchData' import { createManualCommit } from './commitCompose' import { runCommitDraftWorkflow } from './commitWorkflowActions' import { - GitCommitDetail, - GitCommitFilePreview, - GitLogCommitRow, - GitLogRow, - LOG_INTERACTIVE_DEFAULT_LIMIT, - buildToggleGraphArgs, - getCommitDetail, - getCommitFilePreview, - getCommitRows, - getLogRows, + GitCommitDetail, + GitCommitFilePreview, + GitLogCommitRow, + GitLogRow, + LOG_INTERACTIVE_DEFAULT_LIMIT, + buildToggleGraphArgs, + getCommitDetail, + getCommitFilePreview, + getCommitRows, + getLogRows, } from './data' import { - LogInkContextKey, - LogInkContextStatus, - createLogInkContextStatus, - isLogInkContextKeyLoading, - isLogInkContextLoading, - updateLogInkContextStatus, + LogInkContextKey, + LogInkContextStatus, + createLogInkContextStatus, + isLogInkContextKeyLoading, + isLogInkContextLoading, + updateLogInkContextStatus, } from './inkContext' import { - formatInkRefLabels, - getVisibleLogInkHistory, + formatInkRefLabels, + getVisibleLogInkHistory, } from './inkHistoryRows' import { - formatBindingKeys, - formatLogInkBreadcrumb, - filterLogInkPaletteCommands, - getLogInkChordContinuations, - getLogInkPaletteCommands, - getLogInkFooterHints, - getLogInkHelpSections, + formatBindingKeys, + formatLogInkBreadcrumb, + filterLogInkPaletteCommands, + getLogInkChordContinuations, + getLogInkPaletteCommands, + getLogInkFooterHints, + getLogInkHelpSections, } from './inkKeymap' import { substituteGraphChars } from './inkGraphChars' import { LaneSegment, getLaneColor } from './inkGraphLanes' import { formatHyperlink } from './inkHyperlinks' import { - LogInkInputKey, - getInspectorActionsForState, - getLogInkInputEvents, + LogInkInputKey, + getInspectorActionsForState, + getLogInkInputEvents, } from './inkInput' import { hasSeenOnboarding, markOnboardingSeen } from './inkOnboarding' import { getSavedDiffViewMode, saveDiffViewMode } from './inkDiffViewModePersistence' @@ -88,152 +88,152 @@ import { getSavedSidebarTab, saveSidebarTab } from './inkSidebarPersistence' import { SplitDiffRow, buildSplitDiffRows } from './inkSplitDiff' import { getSidebarVisibleWindow } from './inkSidebarSelection' import { - PromotedSelectionsSnapshot, - rectifyPromotedSelectionIndex, + PromotedSelectionsSnapshot, + rectifyPromotedSelectionIndex, } from './inkSelectionRectify' import { - LogInkRefreshWatcher, - createRefreshWatcher, + LogInkRefreshWatcher, + createRefreshWatcher, } from './inkRefreshWatcher' import { installTerminalLifecycle } from './inkTerminalLifecycle' import { - LOG_INK_DEFAULT_COLUMNS, - LOG_INK_DEFAULT_ROWS, - LOG_INK_MIN_COLUMNS, - LOG_INK_MIN_ROWS, - getLogInkLayout, + LOG_INK_DEFAULT_COLUMNS, + LOG_INK_DEFAULT_ROWS, + LOG_INK_MIN_COLUMNS, + LOG_INK_MIN_ROWS, + getLogInkLayout, } from './inkLayout' import { createLogInkTheme, LogInkTheme, LogInkThemeConfig } from './inkTheme' import { - STAGE_STATUS_DOT, - branchRowMarker, - formatBranchDivergence, - formatBranchLastTouched, - getPullRequestStateGlyph, - getStageStatusDotColor, - sidebarTabCount, + STAGE_STATUS_DOT, + branchRowMarker, + formatBranchDivergence, + formatBranchLastTouched, + getPullRequestStateGlyph, + getStageStatusDotColor, + sidebarTabCount, } from './inkIconography' import { IDLE_TIPS_GRACE_MS, IDLE_TIPS_INTERVAL_MS, pickIdleTip } from './inkIdleTips' import { - PreviewLine, - formatBranchPreview, - formatStashPreview, - formatTagPreview, + PreviewLine, + formatBranchPreview, + formatStashPreview, + formatTagPreview, } from './inkPreviewPane' import { - formatSortIndicator, - sortBranches, - sortTags, + formatSortIndicator, + sortBranches, + sortTags, } from './inkSorting' import { - formatLogInkBranchesEmpty, - formatLogInkComposeEmpty, - formatLogInkHistoryEmpty, - formatLogInkLoading, - formatLogInkStashEmpty, - formatLogInkStatusEmpty, - formatLogInkTagsEmpty, + formatLogInkBranchesEmpty, + formatLogInkComposeEmpty, + formatLogInkHistoryEmpty, + formatLogInkLoading, + formatLogInkStashEmpty, + formatLogInkStatusEmpty, + formatLogInkTagsEmpty, } from './inkSurfaceStates' import { cellWidth, truncateCells, wrapCells } from './inkText' import { - LogInkHistoryFetchArgs, - LogInkSidebarTab, - LogInkState, - LogInkStatusFilterMask, - LogInkView, - applyLogInkAction, - createLogInkState, - getLogInkSidebarTabs, - getSelectedInkCommit, + LogInkHistoryFetchArgs, + LogInkSidebarTab, + LogInkState, + LogInkStatusFilterMask, + LogInkView, + applyLogInkAction, + createLogInkState, + getLogInkSidebarTabs, + getSelectedInkCommit, } from './inkViewModel' import { startInteractiveLog } from './interactive' import { GitOperationOverview, getGitOperationOverview } from './operationData' import { openProviderUrl } from './providerActions' import { ProviderOverview, ProviderRepository, buildProviderUrl, getProviderOverview } from './providerData' import { - checkoutBranch, - createBranch, - deleteBranch, - fetchRemotes, - pullCurrentBranch, - pushCurrentBranch, - renameBranch, - setUpstream, + checkoutBranch, + createBranch, + deleteBranch, + fetchRemotes, + pullCurrentBranch, + pushCurrentBranch, + renameBranch, + setUpstream, } from './branchActions' import { createLightweightTag, deleteLocalTag, deleteRemoteTag, pushTag } from './tagActions' import { - ClipboardRunner, - ResetMode, - checkoutFileFromCommit, - cherryPickCommit, - createBranchFromCommit, - createTagAtCommit, - defaultClipboardRunner, - isResetMode, - resetToCommit, - revertCommit, - startInteractiveRebase, + ClipboardRunner, + ResetMode, + checkoutFileFromCommit, + cherryPickCommit, + createBranchFromCommit, + createTagAtCommit, + defaultClipboardRunner, + isResetMode, + resetToCommit, + revertCommit, + startInteractiveRebase, } from './historyActions' import { applyStash, checkoutFileFromStash, createStash, dropStash, popStash } from './stashActions' import { ApplyHunkTarget, applyHunkPatch } from './hunkActions' import { removeWorktree, removeWorktreeAndBranch } from './worktreeActions' -import { abortOperation } from './operationActions' +import { abortOperation, continueOperation, resolveConflictOurs, resolveConflictTheirs, stageConflictResolved } from './operationActions' import { PullRequestOverview, getPullRequestOverview } from './pullRequestData' import { - approvePullRequest, - closePullRequest, - commentPullRequest, - isPullRequestMergeStrategy, - mergePullRequest, - requestChangesPullRequest, + approvePullRequest, + closePullRequest, + commentPullRequest, + isPullRequestMergeStrategy, + mergePullRequest, + requestChangesPullRequest, } from './pullRequestActions' import { - StashOverview, - findStashFileForOffset, - getStashDiff, - getStashOverview, - parseStashDiffFiles, + StashOverview, + findStashFileForOffset, + getStashDiff, + getStashOverview, + parseStashDiffFiles, } from './stashData' import { formatStashHeaderIdentity } from './inkStashHeader' import { - buildPullRequestCheckRows, - formatPullRequestChecksSummary, - formatPullRequestReviewsSummary, - formatPullRequestStateLine, - summarizePullRequestChecks, - summarizePullRequestReviews, + buildPullRequestCheckRows, + formatPullRequestChecksSummary, + formatPullRequestReviewsSummary, + formatPullRequestStateLine, + summarizePullRequestChecks, + summarizePullRequestReviews, } from './inkPullRequestPanel' import { - revertFile, - stageAllFiles, - stageFile, - unstageAllFiles, - unstageFile, + revertFile, + stageAllFiles, + stageFile, + unstageAllFiles, + unstageFile, } from './statusActions' import { - WorktreeFile, - WorktreeFileGroup, - WorktreeOverview, - applyStatusFilterMask, - flattenWorktreeGroups, - getWorktreeOverview, - groupWorktreeFiles, + WorktreeFile, + WorktreeFileGroup, + WorktreeOverview, + applyStatusFilterMask, + flattenWorktreeGroups, + getWorktreeOverview, + groupWorktreeFiles, } from './statusData' import { - WorktreeHunkOverview, - getWorktreeHunks, - revertHunk, - stageHunk, - unstageHunk, + WorktreeHunkOverview, + getWorktreeHunks, + revertHunk, + stageHunk, + unstageHunk, } from './statusHunks' import { TagOverview, getTagOverview } from './tagData' import { - getLogInkWorkflowActionById, + getLogInkWorkflowActionById, } from './inkWorkflows' import { - InspectorAction, - InspectorActionContext, - getInspectorActions, + InspectorAction, + InspectorActionContext, + getInspectorActions, } from './inkInspectorActions' import { WorktreeOverview as WorktreeListOverview, getWorktreeListOverview } from './worktreeData' import { WorktreeFileDiff, getWorktreeFileDiff } from './worktreeDiffData' @@ -1896,6 +1896,46 @@ function LogInkApp(deps: LogInkComponentDeps): ReactTypes.ReactElement { } return abortOperation(git, operation) }, + 'resolve-conflict-ours': async () => { + const path = payload?.trim() + if (!path) return { ok: false, message: 'No conflict file selected' } + return resolveConflictOurs(git, path) + }, + 'resolve-conflict-theirs': async () => { + const path = payload?.trim() + if (!path) return { ok: false, message: 'No conflict file selected' } + return resolveConflictTheirs(git, path) + }, + 'resolve-conflict-stage': async () => { + const path = payload?.trim() + if (!path) return { ok: false, message: 'No conflict file selected' } + return stageConflictResolved(git, path) + }, + 'resolve-conflict-open-diff': async () => { + // Push the diff view for the conflicted file so the user can + // inspect conflict markers in context. We find the file's index + // in the worktree file list and navigate to its diff. + const path = payload?.trim() + if (!path) return { ok: false, message: 'No conflict file selected' } + const worktreeFiles = context.worktree?.files || [] + const fileIndex = worktreeFiles.findIndex((f) => f.path === path) + if (fileIndex >= 0) { + dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex }) + return { ok: true, message: `Viewing diff for ${path}` } + } + // Fallback: open in editor if not in worktree list + return { ok: true, message: `Opening ${path}` } + }, + 'continue-operation': async () => { + const operation = context.operation?.operation + if (!operation || operation === 'none') { + return { ok: false, message: 'No git operation in progress' } + } + if ((context.operation?.conflictedFiles.length ?? 0) > 0) { + return { ok: false, message: 'Resolve all conflicts before continuing' } + } + return continueOperation(git, operation) + }, 'open-pr': async () => { const repo = context.provider?.repository if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) { @@ -2453,6 +2493,8 @@ function LogInkApp(deps: LogInkComponentDeps): ReactTypes.ReactElement { ? selected?.hash : undefined, worktreeDirty, + conflictFileCount: context.operation?.conflictedFiles.length, + conflictSelectedPath: context.operation?.conflictedFiles[state.selectedConflictFileIndex]?.path, // H / gH need the actual diff text (not just hunk offsets) to // slice the cursored hunk into a `git apply` patch. Stash uses // the full `git stash show -p` output; commit-diff uses the @@ -2991,6 +3033,10 @@ function renderMainPanel( return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) } + if (state.activeView === 'conflicts') { + return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) + } + return renderHistoryPanel( h, components, @@ -3272,6 +3318,110 @@ function buildStatusSurfaceRows(groups: WorktreeFileGroup[]): StatusSurfaceRow[] return rows } +function renderConflictsSurface( + h: typeof ReactTypes.createElement, + components: LogInkComponents, + state: LogInkState, + context: LogInkContext, + contextStatus: LogInkContextStatus, + bodyRows: number, + width: number, + theme: LogInkTheme +): ReactTypes.ReactElement { + const { Box, Text } = components + const focused = state.focus === 'commits' + const loading = isLogInkContextKeyLoading(contextStatus, 'operation') + const operation = context.operation + const conflictedFiles = operation?.conflictedFiles || [] + const operationType = operation?.operation || 'none' + + // If no operation is in progress or no conflicts, show a fallback message. + if (!loading && (operationType === 'none' || conflictedFiles.length === 0)) { + return h(Box, { + borderColor: focusBorderColor(theme, focused), + borderStyle: theme.borderStyle, + flexDirection: 'column', + flexShrink: 0, + paddingX: 1, + width, + }, + 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 }, + operationType === 'none' + ? 'No merge, rebase, cherry-pick, or revert in progress.' + : 'All conflicts resolved. Use the operation panel to continue.' + )) + } + + const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1))) + const listRows = Math.max(4, bodyRows - 4) + const startIndex = Math.max(0, selected - Math.floor(listRows / 2)) + const visible = conflictedFiles.slice(startIndex, startIndex + listRows) + const remaining = conflictedFiles.length + const headerRight = loading + ? 'loading conflicts' + : `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining` + + const statusLabel = (file: { indexStatus: string; worktreeStatus: string }): string => { + const code = `${file.indexStatus}${file.worktreeStatus}` + switch (code) { + case 'UU': return 'both modified' + case 'AA': return 'added by both' + case 'DD': return 'both deleted' + case 'AU': case 'UA': return 'added by one' + case 'DU': return 'deleted by us' + case 'UD': return 'deleted by them' + default: return code + } + } + + const lines: ReactTypes.ReactNode[] = loading + ? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))] + : visible.map((file, offset) => { + const index = startIndex + offset + const isSelected = index === selected + const cursor = isSelected ? '>' : ' ' + const code = `${file.indexStatus}${file.worktreeStatus}` + const label = statusLabel(file) + return h(Text, { + key: `conflict-${index}`, + bold: isSelected, + dimColor: !isSelected, + }, truncate( + `${cursor} ${code} ${file.path} (${label})`, + width - 4 + )) + }) + + // Hint when all conflicts are resolved (user staged them all). + const hintLines: ReactTypes.ReactNode[] = [] + if (!loading && conflictedFiles.length === 0 && operationType !== 'none') { + hintLines.push( + h(Text, { key: 'conflicts-hint', dimColor: true }, + `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.` + ) + ) + } + + return h(Box, { + borderColor: focusBorderColor(theme, focused), + borderStyle: theme.borderStyle, + flexDirection: 'column', + flexShrink: 0, + paddingX: 1, + width, + }, + h(Box, { justifyContent: 'space-between' }, + h(Text, { bold: true }, panelTitle('Conflicts', focused)), + h(Text, { dimColor: true }, headerRight) + ), + ...lines, + ...hintLines) +} + function renderStatusSurface( h: typeof ReactTypes.createElement, components: LogInkComponents, diff --git a/src/commands/log/inkViewModel.test.ts b/src/commands/log/inkViewModel.test.ts index 28cf3f7..8ee8c7b 100644 --- a/src/commands/log/inkViewModel.test.ts +++ b/src/commands/log/inkViewModel.test.ts @@ -1,16 +1,16 @@ import { GitLogRow } from './data' import { - DEFAULT_LOG_INK_STATUS_FILTER_MASK, - applyLogInkAction, - createLogInkState, - getLogInkSidebarTabs, - getSelectedInkCommit, - intentGoHome, - intentOpenComposeForFile, - intentOpenDiffForCommit, - intentOpenDiffForWorktreeFile, - parseLogInkHistoryFetchPrefix, - scoreLogInkCommitFilter, + DEFAULT_LOG_INK_STATUS_FILTER_MASK, + applyLogInkAction, + createLogInkState, + getLogInkSidebarTabs, + getSelectedInkCommit, + intentGoHome, + intentOpenComposeForFile, + intentOpenDiffForCommit, + intentOpenDiffForWorktreeFile, + parseLogInkHistoryFetchPrefix, + scoreLogInkCommitFilter, } from './inkViewModel' const rows: GitLogRow[] = [ @@ -1150,4 +1150,42 @@ describe('log Ink view model', () => { expect(state.commits.length).toBeGreaterThan(0) }) }) + + describe('conflicts view', () => { + it('initializes selectedConflictFileIndex to 0', () => { + const state = createLogInkState(rows) + expect(state.selectedConflictFileIndex).toBe(0) + }) + + it('moves the conflict file cursor within bounds', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'moveConflictFile', delta: 1, count: 5 }) + expect(state.selectedConflictFileIndex).toBe(1) + state = applyLogInkAction(state, { type: 'moveConflictFile', delta: 1, count: 5 }) + expect(state.selectedConflictFileIndex).toBe(2) + }) + + it('clamps the conflict file cursor at list bounds', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'moveConflictFile', delta: -1, count: 3 }) + expect(state.selectedConflictFileIndex).toBe(0) + state = applyLogInkAction(state, { type: 'moveConflictFile', delta: 10, count: 3 }) + expect(state.selectedConflictFileIndex).toBe(2) + }) + + it('pushes the conflicts view onto the navigation stack', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + expect(state.activeView).toBe('conflicts') + expect(state.viewStack).toEqual(['history', 'conflicts']) + }) + + it('pops back from the conflicts view', () => { + let state = createLogInkState(rows) + state = applyLogInkAction(state, { type: 'pushView', value: 'conflicts' }) + state = applyLogInkAction(state, { type: 'popView' }) + expect(state.activeView).toBe('history') + expect(state.viewStack).toEqual(['history']) + }) + }) }) diff --git a/src/commands/log/inkViewModel.ts b/src/commands/log/inkViewModel.ts index 32f2fbc..af44e13 100644 --- a/src/commands/log/inkViewModel.ts +++ b/src/commands/log/inkViewModel.ts @@ -1,24 +1,24 @@ import { GitLogCommitRow, GitLogRow, getCommitRows } from './data' import { - CommitComposeAction, - CommitComposeState, - applyCommitComposeAction, - createCommitComposeState, + CommitComposeAction, + CommitComposeState, + applyCommitComposeAction, + createCommitComposeState, } from './commitCompose' import { PromotedSelectionsSnapshot } from './inkSelectionRectify' import { - BranchSortMode, - DEFAULT_BRANCH_SORT_MODE, - DEFAULT_TAG_SORT_MODE, - TagSortMode, - cycleBranchSort, - cycleTagSort, + BranchSortMode, + DEFAULT_BRANCH_SORT_MODE, + DEFAULT_TAG_SORT_MODE, + TagSortMode, + cycleBranchSort, + cycleTagSort, } from './inkSorting' export type LogInkFocus = 'sidebar' | 'commits' | 'detail' export type LogInkSidebarTab = 'status' | 'branches' | 'tags' | 'stashes' | 'worktrees' -export type LogInkView = 'history' | 'status' | 'diff' | 'compose' | 'branches' | 'tags' | 'stash' | 'worktrees' | 'pull-request' +export type LogInkView = 'history' | 'status' | 'diff' | 'compose' | 'branches' | 'tags' | 'stash' | 'worktrees' | 'pull-request' | 'conflicts' export type LogInkMutationConfirmation = 'revert-file' | 'revert-hunk' | 'discard-draft' /** * Tracks which kind of diff the user pushed into. `commit` means they @@ -83,6 +83,7 @@ export type LogInkState = { selectedTagIndex: number selectedStashIndex: number selectedWorktreeListIndex: number + selectedConflictFileIndex: number /** * Sort modes for the promoted views (P4.2). `s` cycles through the * available modes; the surface header shows a `▼ ` indicator. @@ -338,6 +339,7 @@ export type LogInkAction = | { type: 'moveTag'; delta: number; count: number } | { type: 'moveStash'; delta: number; count: number } | { type: 'moveWorktreeListEntry'; delta: number; count: number } + | { type: 'moveConflictFile'; delta: number; count: number } | { type: 'moveToBottom' } | { type: 'moveToTop' } | { type: 'nextSidebarTab' } @@ -713,6 +715,7 @@ export function createLogInkState( selectedTagIndex: 0, selectedStashIndex: 0, selectedWorktreeListIndex: 0, + selectedConflictFileIndex: 0, branchSort: DEFAULT_BRANCH_SORT_MODE, tagSort: DEFAULT_TAG_SORT_MODE, paletteFilter: '', @@ -976,6 +979,12 @@ export function applyLogInkAction(state: LogInkState, action: LogInkAction): Log selectedWorktreeListIndex: clampIndex(state.selectedWorktreeListIndex + action.delta, action.count), pendingKey: undefined, } + case 'moveConflictFile': + return { + ...state, + selectedConflictFileIndex: clampIndex(state.selectedConflictFileIndex + action.delta, action.count), + pendingKey: undefined, + } case 'cycleBranchSort': return { ...state, diff --git a/src/commands/log/operationActions.test.ts b/src/commands/log/operationActions.test.ts index e395dc5..24bbf23 100644 --- a/src/commands/log/operationActions.test.ts +++ b/src/commands/log/operationActions.test.ts @@ -1,8 +1,11 @@ import { - abortOperation, - continueOperation, - operationActionTestInternals, - skipOperation, + abortOperation, + continueOperation, + operationActionTestInternals, + resolveConflictOurs, + resolveConflictTheirs, + skipOperation, + stageConflictResolved, } from './operationActions' describe('log operation actions', () => { @@ -68,4 +71,53 @@ describe('log operation actions', () => { details: ['fix conflict.ts first'], }) }) + + describe('conflict resolution actions', () => { + it('resolveConflictOurs checks out ours and stages the file', async () => { + const git = { raw: jest.fn().mockResolvedValue('') } + + const result = await resolveConflictOurs(git as never, 'src/conflict.ts') + + expect(result).toEqual({ ok: true, message: 'Resolved src/conflict.ts (kept ours)' }) + expect(git.raw).toHaveBeenCalledWith(['checkout', '--ours', '--', 'src/conflict.ts']) + expect(git.raw).toHaveBeenCalledWith(['add', '--', 'src/conflict.ts']) + }) + + it('resolveConflictTheirs checks out theirs and stages the file', async () => { + const git = { raw: jest.fn().mockResolvedValue('') } + + const result = await resolveConflictTheirs(git as never, 'src/conflict.ts') + + expect(result).toEqual({ ok: true, message: 'Resolved src/conflict.ts (kept theirs)' }) + expect(git.raw).toHaveBeenCalledWith(['checkout', '--theirs', '--', 'src/conflict.ts']) + expect(git.raw).toHaveBeenCalledWith(['add', '--', 'src/conflict.ts']) + }) + + it('stageConflictResolved stages the file to mark it resolved', async () => { + const git = { raw: jest.fn().mockResolvedValue('') } + + const result = await stageConflictResolved(git as never, 'src/conflict.ts') + + expect(result).toEqual({ ok: true, message: 'Staged src/conflict.ts (marked resolved)' }) + expect(git.raw).toHaveBeenCalledWith(['add', '--', 'src/conflict.ts']) + }) + + it('resolveConflictOurs reports failures from git', async () => { + const git = { raw: jest.fn().mockRejectedValue(new Error('error: path not found')) } + + const result = await resolveConflictOurs(git as never, 'missing.ts') + + expect(result.ok).toBe(false) + expect(result.message).toContain('error: path not found') + }) + + it('resolveConflictTheirs reports failures from git', async () => { + const git = { raw: jest.fn().mockRejectedValue(new Error('error: path not found')) } + + const result = await resolveConflictTheirs(git as never, 'missing.ts') + + expect(result.ok).toBe(false) + expect(result.message).toContain('error: path not found') + }) + }) }) diff --git a/src/commands/log/operationActions.ts b/src/commands/log/operationActions.ts index ae07df8..7608fcc 100644 --- a/src/commands/log/operationActions.ts +++ b/src/commands/log/operationActions.ts @@ -130,6 +130,42 @@ export function skipOperation( ) } +export function resolveConflictOurs( + git: SimpleGit, + path: string +): Promise { + return runAction( + async () => { + await git.raw(['checkout', '--ours', '--', path]) + await git.raw(['add', '--', path]) + }, + `Resolved ${path} (kept ours)` + ) +} + +export function resolveConflictTheirs( + git: SimpleGit, + path: string +): Promise { + return runAction( + async () => { + await git.raw(['checkout', '--theirs', '--', path]) + await git.raw(['add', '--', path]) + }, + `Resolved ${path} (kept theirs)` + ) +} + +export function stageConflictResolved( + git: SimpleGit, + path: string +): Promise { + return runAction( + () => git.raw(['add', '--', path]), + `Staged ${path} (marked resolved)` + ) +} + export const operationActionTestInternals = { getOperationCommand, } From 85d429e2e35f8aa5a9547fc1e82044a48f13596a Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Tue, 5 May 2026 16:30:33 -0400 Subject: [PATCH 3/3] fix: address PR review feedback for conflicts view - Split early return into separate 'no operation' and 'all resolved' branches so the C-continue hint is reachable (#r3191166195) - Remove dead hintLines block that was unreachable after early return - Clamp conflictSelectedPath index to prevent out-of-bounds access after resolving a conflict shrinks the list (#r3191166417) - Always intercept C key on conflicts view to prevent fallthrough to global C (Create PR) binding when conflicts remain (#r3191166390) - Update footer hint to 'C continue*' to signal conditional availability (#r3191166357) - Fix resolve-conflict-open-diff fallback comment to match behavior (#r3191166243) --- src/commands/log/inkInput.test.ts | 9 ++++-- src/commands/log/inkInput.ts | 5 +++ src/commands/log/inkKeymap.test.ts | 2 +- src/commands/log/inkKeymap.ts | 2 +- src/commands/log/inkRuntime.ts | 52 ++++++++++++++++++------------ 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/commands/log/inkInput.test.ts b/src/commands/log/inkInput.test.ts index 11c1f7e..7d91894 100644 --- a/src/commands/log/inkInput.test.ts +++ b/src/commands/log/inkInput.test.ts @@ -2502,9 +2502,12 @@ describe('log Ink input interactions', () => { conflictSelectedPath: 'src/conflict.ts', }) - // C should not match the conflicts-view continue handler when - // conflicts remain — it falls through to the global workflow - // action path or is a no-op. + // C is intercepted on the conflicts view to prevent fallthrough + // to the global C (Create PR) binding. Shows a status hint instead. + expect(events).toContainEqual({ + type: 'action', + action: { type: 'setStatus', value: 'Resolve all conflicts before continuing' }, + }) const hasConflictContinue = events.some( (e) => e.type === 'runWorkflowAction' && (e as { id: string }).id === 'continue-operation' ) diff --git a/src/commands/log/inkInput.ts b/src/commands/log/inkInput.ts index 99fb0a1..eab426d 100644 --- a/src/commands/log/inkInput.ts +++ b/src/commands/log/inkInput.ts @@ -1792,6 +1792,11 @@ export function getLogInkInputEvents( if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) { return [{ type: 'runWorkflowAction', id: 'continue-operation' }] } + // Always intercept `C` on the conflicts view to prevent fallthrough to + // the global `C` (Create PR) binding when conflicts remain. + if (inputValue === 'C' && state.activeView === 'conflicts') { + return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })] + } // `c` on a stash diff cherry-picks the file under the cursor — // materializes that single path from the stash into the working tree diff --git a/src/commands/log/inkKeymap.test.ts b/src/commands/log/inkKeymap.test.ts index 1414b76..7c7e357 100644 --- a/src/commands/log/inkKeymap.test.ts +++ b/src/commands/log/inkKeymap.test.ts @@ -470,7 +470,7 @@ describe('log Ink keymap', () => { expect(hints.contextual).toContain('u theirs') expect(hints.contextual).toContain('U ours') expect(hints.contextual).toContain('o edit') - expect(hints.contextual).toContain('C continue') + expect(hints.contextual).toContain('C continue*') expect(hints.contextual).toContain('enter diff') expect(hints.global).toEqual(['g jump', '< back', '? help', ': cmds', 'q quit']) }) diff --git a/src/commands/log/inkKeymap.ts b/src/commands/log/inkKeymap.ts index 717ba66..83ad142 100644 --- a/src/commands/log/inkKeymap.ts +++ b/src/commands/log/inkKeymap.ts @@ -636,7 +636,7 @@ export function getLogInkFooterHints(options: GetLogInkFooterHintsOptions): LogI if (options.activeView === 'conflicts') { return { - contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue', 'esc back'], + contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'], global: NORMAL_GLOBAL_HINTS, } } diff --git a/src/commands/log/inkRuntime.ts b/src/commands/log/inkRuntime.ts index 7bb17a2..3192183 100644 --- a/src/commands/log/inkRuntime.ts +++ b/src/commands/log/inkRuntime.ts @@ -1923,8 +1923,9 @@ function LogInkApp(deps: LogInkComponentDeps): ReactTypes.ReactElement { dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex }) return { ok: true, message: `Viewing diff for ${path}` } } - // Fallback: open in editor if not in worktree list - return { ok: true, message: `Opening ${path}` } + // File not in worktree list (e.g. deleted-by-us) — open in + // editor as fallback so the user can still inspect it. + return { ok: true, message: `${path} not in worktree diff list` } }, 'continue-operation': async () => { const operation = context.operation?.operation @@ -2494,7 +2495,12 @@ function LogInkApp(deps: LogInkComponentDeps): ReactTypes.ReactElement { : undefined, worktreeDirty, conflictFileCount: context.operation?.conflictedFiles.length, - conflictSelectedPath: context.operation?.conflictedFiles[state.selectedConflictFileIndex]?.path, + conflictSelectedPath: (() => { + const files = context.operation?.conflictedFiles + if (!files || files.length === 0) return undefined + const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1) + return files[clamped]?.path + })(), // H / gH need the actual diff text (not just hunk offsets) to // slice the cursored hunk into a `git apply` patch. Stash uses // the full `git stash show -p` output; commit-diff uses the @@ -3335,8 +3341,8 @@ function renderConflictsSurface( const conflictedFiles = operation?.conflictedFiles || [] const operationType = operation?.operation || 'none' - // If no operation is in progress or no conflicts, show a fallback message. - if (!loading && (operationType === 'none' || conflictedFiles.length === 0)) { + // If no operation is in progress, show a fallback message. + if (!loading && operationType === 'none') { return h(Box, { borderColor: focusBorderColor(theme, focused), borderStyle: theme.borderStyle, @@ -3350,9 +3356,26 @@ function renderConflictsSurface( h(Text, { dimColor: true }, 'no operation in progress') ), h(Text, { key: 'conflicts-empty', dimColor: true }, - operationType === 'none' - ? 'No merge, rebase, cherry-pick, or revert in progress.' - : 'All conflicts resolved. Use the operation panel to continue.' + 'No merge, rebase, cherry-pick, or revert in progress.' + )) + } + + // All conflicts resolved — show the "continue" hint. + if (!loading && conflictedFiles.length === 0 && operationType !== 'none') { + return h(Box, { + borderColor: focusBorderColor(theme, focused), + borderStyle: theme.borderStyle, + flexDirection: 'column', + flexShrink: 0, + paddingX: 1, + width, + }, + 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.` )) } @@ -3396,16 +3419,6 @@ function renderConflictsSurface( )) }) - // Hint when all conflicts are resolved (user staged them all). - const hintLines: ReactTypes.ReactNode[] = [] - if (!loading && conflictedFiles.length === 0 && operationType !== 'none') { - hintLines.push( - h(Text, { key: 'conflicts-hint', dimColor: true }, - `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.` - ) - ) - } - return h(Box, { borderColor: focusBorderColor(theme, focused), borderStyle: theme.borderStyle, @@ -3418,8 +3431,7 @@ function renderConflictsSurface( h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight) ), - ...lines, - ...hintLines) + ...lines) } function renderStatusSurface(