git-coco 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +9 -23
- package/dist/index.esm.mjs +811 -103
- package/dist/index.js +811 -103
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.62.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -309,10 +309,19 @@ class LangChainExecutionError extends LangChainError {
|
|
|
309
309
|
}
|
|
310
310
|
/**
|
|
311
311
|
* Authentication-related errors (missing API keys, invalid credentials, etc.)
|
|
312
|
+
*
|
|
313
|
+
* Carries `provider` + `endpoint` context so the formatter (in
|
|
314
|
+
* `commandExecutor`) can render provider-specific recovery hints
|
|
315
|
+
* ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
|
|
316
|
+
* generic "verify your API key" copy. Mirrors the shape of
|
|
317
|
+
* `LangChainNetworkError` so call sites can hand the same fields to
|
|
318
|
+
* either constructor depending on which condition fired.
|
|
312
319
|
*/
|
|
313
320
|
class LangChainAuthenticationError extends LangChainError {
|
|
314
|
-
constructor(message, context) {
|
|
315
|
-
super(message, context);
|
|
321
|
+
constructor(message, provider, endpoint, context) {
|
|
322
|
+
super(message, { ...context, provider, endpoint });
|
|
323
|
+
this.provider = provider;
|
|
324
|
+
this.endpoint = endpoint;
|
|
316
325
|
}
|
|
317
326
|
}
|
|
318
327
|
/**
|
|
@@ -450,21 +459,27 @@ function getDefaultServiceApiKey(config) {
|
|
|
450
459
|
const requiresAuth = provider === 'openai' || provider === 'anthropic';
|
|
451
460
|
if (service.authentication.type === 'APIKey') {
|
|
452
461
|
const apiKey = service.authentication.credentials?.apiKey;
|
|
462
|
+
// `endpoint` is optional on some service variants (Ollama / OpenAI-
|
|
463
|
+
// compatible) and absent on others (managed OpenAI / Anthropic).
|
|
464
|
+
// Read defensively so we still attach it when present.
|
|
465
|
+
const endpoint = service.endpoint;
|
|
453
466
|
if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
|
|
454
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`,
|
|
467
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
455
468
|
}
|
|
456
469
|
return apiKey || '';
|
|
457
470
|
}
|
|
458
471
|
if (service.authentication.type === 'OAuth') {
|
|
459
472
|
const token = service.authentication.credentials?.token;
|
|
473
|
+
const endpoint = service.endpoint;
|
|
460
474
|
if (requiresAuth && (!token || token.trim() === '')) {
|
|
461
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`,
|
|
475
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
462
476
|
}
|
|
463
477
|
return token || '';
|
|
464
478
|
}
|
|
465
479
|
if (service.authentication.type === 'None') {
|
|
466
480
|
if (requiresAuth) {
|
|
467
|
-
|
|
481
|
+
const endpoint = service.endpoint;
|
|
482
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
468
483
|
}
|
|
469
484
|
return '';
|
|
470
485
|
}
|
|
@@ -2597,18 +2612,48 @@ function formatNetworkError(error, logger) {
|
|
|
2597
2612
|
logger.log(' • Verify the service endpoint is correct', { color: 'white' });
|
|
2598
2613
|
logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
|
|
2599
2614
|
}
|
|
2615
|
+
logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
|
|
2600
2616
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2601
2617
|
}
|
|
2602
2618
|
/**
|
|
2603
|
-
* Formats an authentication error with
|
|
2619
|
+
* Formats an authentication error with provider-aware troubleshooting.
|
|
2620
|
+
*
|
|
2621
|
+
* Pre-MEDIUM-8 the formatter was generic — "verify your API key,
|
|
2622
|
+
* check it hasn't expired" — because the error class didn't carry
|
|
2623
|
+
* any provider context. Now that `LangChainAuthenticationError`
|
|
2624
|
+
* carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
|
|
2625
|
+
* we can name the env var the user actually needs to set and route
|
|
2626
|
+
* Ollama / OpenAI-compatible / managed-provider users through the
|
|
2627
|
+
* right next step.
|
|
2604
2628
|
*/
|
|
2605
2629
|
function formatAuthenticationError(error, logger) {
|
|
2630
|
+
const provider = error.provider || 'LLM service';
|
|
2631
|
+
const endpoint = error.endpoint;
|
|
2606
2632
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2607
|
-
logger.log(
|
|
2633
|
+
logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
|
|
2634
|
+
if (endpoint) {
|
|
2635
|
+
logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
|
|
2636
|
+
}
|
|
2608
2637
|
logger.log('\nTroubleshooting:', { color: 'cyan' });
|
|
2609
|
-
logger.log(' • Verify your API key is correct', { color: 'white' });
|
|
2610
|
-
|
|
2611
|
-
|
|
2638
|
+
logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
|
|
2639
|
+
// Provider-specific env var hint when we know the provider.
|
|
2640
|
+
if (provider === 'openai' || provider === 'OpenAI') {
|
|
2641
|
+
logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2642
|
+
}
|
|
2643
|
+
else if (provider === 'anthropic' || provider === 'Anthropic') {
|
|
2644
|
+
logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2645
|
+
}
|
|
2646
|
+
else if (provider === 'ollama' || provider === 'Ollama') {
|
|
2647
|
+
logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
|
|
2648
|
+
}
|
|
2649
|
+
else if (provider === 'openai-compatible') {
|
|
2650
|
+
logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
|
|
2651
|
+
}
|
|
2652
|
+
else {
|
|
2653
|
+
logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
|
|
2654
|
+
}
|
|
2655
|
+
logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
|
|
2656
|
+
logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
|
|
2612
2657
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2613
2658
|
}
|
|
2614
2659
|
/**
|
|
@@ -3578,8 +3623,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3578
3623
|
const result = clearDiffSummaryCache(repoPath);
|
|
3579
3624
|
if (!result.ok) {
|
|
3580
3625
|
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
3581
|
-
|
|
3582
|
-
return;
|
|
3626
|
+
commandExit(1, 'cache clear failed');
|
|
3583
3627
|
}
|
|
3584
3628
|
if (result.removed) {
|
|
3585
3629
|
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
@@ -3619,8 +3663,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3619
3663
|
if (interactive) {
|
|
3620
3664
|
const picked = await promptLanguageSelection(logger);
|
|
3621
3665
|
if (!picked) {
|
|
3622
|
-
|
|
3623
|
-
return;
|
|
3666
|
+
commandExit(1, 'cache prefetch cancelled');
|
|
3624
3667
|
}
|
|
3625
3668
|
resolved = picked;
|
|
3626
3669
|
}
|
|
@@ -3637,7 +3680,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3637
3680
|
`${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
|
|
3638
3681
|
`${chalk.red(`${result.failed.length} failed`)}`);
|
|
3639
3682
|
if (result.failed.length > 0) {
|
|
3640
|
-
|
|
3683
|
+
commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
|
|
3641
3684
|
}
|
|
3642
3685
|
return;
|
|
3643
3686
|
}
|
|
@@ -3670,12 +3713,12 @@ const handler$b = async (argv, logger) => {
|
|
|
3670
3713
|
}
|
|
3671
3714
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3672
3715
|
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3673
|
-
|
|
3716
|
+
commandExit(1, `unknown cache subcommand: ${subcommand}`);
|
|
3674
3717
|
};
|
|
3675
3718
|
|
|
3676
3719
|
var cache = {
|
|
3677
3720
|
command: command$b,
|
|
3678
|
-
desc: 'Manage
|
|
3721
|
+
desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
|
|
3679
3722
|
builder: builder$b,
|
|
3680
3723
|
handler: commandExecutor(handler$b),
|
|
3681
3724
|
};
|
|
@@ -8944,6 +8987,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
|
|
|
8944
8987
|
return result;
|
|
8945
8988
|
}
|
|
8946
8989
|
|
|
8990
|
+
/**
|
|
8991
|
+
* Centralised glyph + label vocabulary for diagnostic / status copy.
|
|
8992
|
+
*
|
|
8993
|
+
* Before this module each surface (commandExecutor, doctor, footer,
|
|
8994
|
+
* cache, issues, prs, commit-hook flow) picked its own marks for
|
|
8995
|
+
* pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
|
|
8996
|
+
* couldn't lean on a consistent visual signal to scan output, and the
|
|
8997
|
+
* audit flagged it as one of the bigger inconsistencies in the
|
|
8998
|
+
* codebase.
|
|
8999
|
+
*
|
|
9000
|
+
* The vocabulary mirrors what Linux package managers + git-aware
|
|
9001
|
+
* tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
|
|
9002
|
+
* green check / red fail / yellow warn / blue info. ASCII fallbacks
|
|
9003
|
+
* are first-class so dumb terminals (TERM=dumb / vt100) still render
|
|
9004
|
+
* a meaningful prefix.
|
|
9005
|
+
*
|
|
9006
|
+
* Conventions:
|
|
9007
|
+
* - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
|
|
9008
|
+
* output, command exit, doctor severity, footer message kinds.
|
|
9009
|
+
* Colour-coded variants live alongside as `*_COLORED` helpers
|
|
9010
|
+
* so callers can use either depending on context.
|
|
9011
|
+
* - Action glyphs (BULLET, ARROW) — for indented hint lines and
|
|
9012
|
+
* "next step" callouts.
|
|
9013
|
+
* - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
|
|
9014
|
+
* vocabularies (PR reviews, status checks) because their
|
|
9015
|
+
* semantic shape doesn't map cleanly onto pass/fail/warn/info.
|
|
9016
|
+
*
|
|
9017
|
+
* Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
|
|
9018
|
+
* `theme.ascii` mode in a single call site.
|
|
9019
|
+
*/
|
|
9020
|
+
/**
|
|
9021
|
+
* Status-severity glyph set. Same vocabulary as the workstation
|
|
9022
|
+
* footer's `kind` field (info / warning / error / success / loading)
|
|
9023
|
+
* plus `pass` for the doctor / "no problem" case.
|
|
9024
|
+
*/
|
|
9025
|
+
const GLYPHS = {
|
|
9026
|
+
pass: '✓',
|
|
9027
|
+
fail: '✖',
|
|
9028
|
+
warn: '⚠',
|
|
9029
|
+
info: 'ℹ',
|
|
9030
|
+
bullet: '•'};
|
|
9031
|
+
/**
|
|
9032
|
+
* Theme-tinted helpers for terminal output. These return chalk-wrapped
|
|
9033
|
+
* strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
|
|
9034
|
+
* pattern. Each maps to the canonical colour the codebase uses for
|
|
9035
|
+
* that severity:
|
|
9036
|
+
*
|
|
9037
|
+
* - PASS → green
|
|
9038
|
+
* - FAIL → red
|
|
9039
|
+
* - WARN → yellow
|
|
9040
|
+
* - INFO → blue
|
|
9041
|
+
*
|
|
9042
|
+
* Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
|
|
9043
|
+
* delegates here so the colours stay in sync if the theme palette
|
|
9044
|
+
* shifts in the future.
|
|
9045
|
+
*/
|
|
9046
|
+
const PASS = () => chalk.green(GLYPHS.pass);
|
|
9047
|
+
const FAIL = () => chalk.red(GLYPHS.fail);
|
|
9048
|
+
const WARN = () => chalk.yellow(GLYPHS.warn);
|
|
9049
|
+
const INFO = () => chalk.blue(GLYPHS.info);
|
|
9050
|
+
|
|
9051
|
+
/**
|
|
9052
|
+
* Maps each provider to the env var users should set + the kebab-case
|
|
9053
|
+
* provider label used in the recovery copy. `coco init` and `coco
|
|
9054
|
+
* doctor` both reference these names; keeping the lookup in one place
|
|
9055
|
+
* makes the messages stay aligned when a new provider lands.
|
|
9056
|
+
*/
|
|
9057
|
+
const PROVIDER_ENV_VARS = {
|
|
9058
|
+
openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
|
|
9059
|
+
anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
|
|
9060
|
+
ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
|
|
9061
|
+
'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
|
|
9062
|
+
};
|
|
9063
|
+
/**
|
|
9064
|
+
* Print a structured "missing API key" message + exit non-zero.
|
|
9065
|
+
*
|
|
9066
|
+
* Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
|
|
9067
|
+
* inline in commit / changelog / recap / review handlers. Centralised
|
|
9068
|
+
* because:
|
|
9069
|
+
*
|
|
9070
|
+
* 1. The message names the env var the user actually needs to set
|
|
9071
|
+
* (different per provider) — that was the single biggest gap in
|
|
9072
|
+
* the prior message.
|
|
9073
|
+
* 2. It surfaces the configured provider + model so the user can tell
|
|
9074
|
+
* which of their providers tripped the check (useful when running
|
|
9075
|
+
* with dynamic model routing).
|
|
9076
|
+
* 3. It points at `coco init` and `coco doctor` as the recovery
|
|
9077
|
+
* paths, mirroring the discoverability cue every other modern CLI
|
|
9078
|
+
* uses for first-run config errors.
|
|
9079
|
+
*
|
|
9080
|
+
* Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
|
|
9081
|
+
* to handle the return value.
|
|
9082
|
+
*/
|
|
9083
|
+
function handleMissingApiKey(logger, config, options) {
|
|
9084
|
+
const provider = config.service?.provider || 'unknown';
|
|
9085
|
+
const model = config.service?.model || 'unknown';
|
|
9086
|
+
const providerInfo = PROVIDER_ENV_VARS[provider] || {
|
|
9087
|
+
envVar: 'PROVIDER_API_KEY',
|
|
9088
|
+
label: provider,
|
|
9089
|
+
};
|
|
9090
|
+
const lines = [
|
|
9091
|
+
`${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
|
|
9092
|
+
'',
|
|
9093
|
+
`${chalk.bold('Next step')} — set up an API key one of these ways:`,
|
|
9094
|
+
` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
|
|
9095
|
+
` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
|
|
9096
|
+
` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
|
|
9097
|
+
'',
|
|
9098
|
+
`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
|
|
9099
|
+
];
|
|
9100
|
+
for (const line of lines) {
|
|
9101
|
+
logger.log(line);
|
|
9102
|
+
}
|
|
9103
|
+
// Tag the exit message with the failing command so process supervisors
|
|
9104
|
+
// / CI logs can grep for it without parsing the full body.
|
|
9105
|
+
commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
|
|
9106
|
+
}
|
|
9107
|
+
|
|
8947
9108
|
const logSuccess = () => {
|
|
8948
9109
|
console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
|
|
8949
9110
|
};
|
|
@@ -14436,8 +14597,7 @@ const handler$a = async (argv, logger) => {
|
|
|
14436
14597
|
commandExit(1);
|
|
14437
14598
|
}
|
|
14438
14599
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
14439
|
-
logger
|
|
14440
|
-
commandExit(1);
|
|
14600
|
+
handleMissingApiKey(logger, config, { command: 'changelog' });
|
|
14441
14601
|
}
|
|
14442
14602
|
const llm = getLlm(provider, model, { ...config, service: changelogService });
|
|
14443
14603
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
@@ -16333,8 +16493,7 @@ const handler$9 = async (argv, logger) => {
|
|
|
16333
16493
|
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
16334
16494
|
const model = commitService.model;
|
|
16335
16495
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
16336
|
-
logger
|
|
16337
|
-
commandExit(1);
|
|
16496
|
+
handleMissingApiKey(logger, config, { command: 'commit' });
|
|
16338
16497
|
}
|
|
16339
16498
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
16340
16499
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
@@ -17064,9 +17223,9 @@ function checkProjectConfigFile(diagnostics) {
|
|
|
17064
17223
|
}
|
|
17065
17224
|
|
|
17066
17225
|
const SEVERITY_ICON = {
|
|
17067
|
-
error:
|
|
17068
|
-
warn:
|
|
17069
|
-
info:
|
|
17226
|
+
error: FAIL(),
|
|
17227
|
+
warn: WARN(),
|
|
17228
|
+
info: INFO(),
|
|
17070
17229
|
};
|
|
17071
17230
|
const SEVERITY_LABEL = {
|
|
17072
17231
|
error: chalk.red('error'),
|
|
@@ -17085,10 +17244,10 @@ function formatSourceInfo(sources) {
|
|
|
17085
17244
|
for (const source of sources) {
|
|
17086
17245
|
const label = SOURCE_LABELS[source.source] || source.source;
|
|
17087
17246
|
if (source.path) {
|
|
17088
|
-
lines.push(` ${
|
|
17247
|
+
lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
|
|
17089
17248
|
}
|
|
17090
17249
|
else {
|
|
17091
|
-
lines.push(` ${
|
|
17250
|
+
lines.push(` ${PASS()} ${label}`);
|
|
17092
17251
|
}
|
|
17093
17252
|
}
|
|
17094
17253
|
return lines;
|
|
@@ -17125,7 +17284,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17125
17284
|
// Run diagnostics
|
|
17126
17285
|
const diagnostics = runDiagnostics(config);
|
|
17127
17286
|
if (diagnostics.length === 0) {
|
|
17128
|
-
logger.log(chalk.green(
|
|
17287
|
+
logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
|
|
17129
17288
|
return;
|
|
17130
17289
|
}
|
|
17131
17290
|
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
@@ -17178,7 +17337,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17178
17337
|
const raw = JSON.parse(fs__namespace.readFileSync(configPath, 'utf-8'));
|
|
17179
17338
|
for (const diagnostic of fixable) {
|
|
17180
17339
|
diagnostic.autoFix(raw);
|
|
17181
|
-
logger.log(chalk.green(`
|
|
17340
|
+
logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
|
|
17182
17341
|
}
|
|
17183
17342
|
// Ensure $schema is present
|
|
17184
17343
|
if (!raw.$schema) {
|
|
@@ -17197,6 +17356,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
17197
17356
|
logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
|
|
17198
17357
|
}
|
|
17199
17358
|
}
|
|
17359
|
+
// Exit non-zero when error-severity diagnostics were surfaced so CI
|
|
17360
|
+
// pipelines can gate on `coco doctor` without parsing its stdout.
|
|
17361
|
+
// Warnings + infos still exit clean — they're informational, not
|
|
17362
|
+
// blockers. Auto-fixed errors keep the non-zero exit so the CI run
|
|
17363
|
+
// surfaces "we patched something for you, please commit it" rather
|
|
17364
|
+
// than masquerading as a passing check.
|
|
17365
|
+
if (errors.length > 0) {
|
|
17366
|
+
commandExit(1, `${errors.length} doctor error(s)`);
|
|
17367
|
+
}
|
|
17200
17368
|
};
|
|
17201
17369
|
|
|
17202
17370
|
var doctor = {
|
|
@@ -17204,6 +17372,7 @@ var doctor = {
|
|
|
17204
17372
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
17205
17373
|
builder: builder$8,
|
|
17206
17374
|
handler: commandExecutor(handler$8),
|
|
17375
|
+
options: options$8,
|
|
17207
17376
|
};
|
|
17208
17377
|
|
|
17209
17378
|
const command$7 = 'init';
|
|
@@ -17583,11 +17752,7 @@ const handler$7 = async (argv, logger) => {
|
|
|
17583
17752
|
// writes the project config to X, not the launcher's cwd. The
|
|
17584
17753
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
17585
17754
|
// its target path (it reads process.cwd).
|
|
17586
|
-
|
|
17587
|
-
// `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
|
|
17588
|
-
// union including Promise — pass just the `repo` field as a plain
|
|
17589
|
-
// object so the helper's narrow signature stays clean.
|
|
17590
|
-
applyRepoCwd({ repo: argv.repo });
|
|
17755
|
+
applyRepoCwd(argv);
|
|
17591
17756
|
const options = loadConfig(argv);
|
|
17592
17757
|
logger.log(LOGO);
|
|
17593
17758
|
let scope = options?.scope;
|
|
@@ -17722,6 +17887,44 @@ const handler$7 = async (argv, logger) => {
|
|
|
17722
17887
|
await installCommitlintPackages(scope, logger);
|
|
17723
17888
|
}
|
|
17724
17889
|
logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
|
|
17890
|
+
// Post-write verification — run the same check `coco doctor` runs
|
|
17891
|
+
// so the user finds out about typos / structural issues now,
|
|
17892
|
+
// before their first `coco commit`. Re-load from disk so we
|
|
17893
|
+
// verify the persisted config (not the in-memory shape we just
|
|
17894
|
+
// built), which catches transcription bugs in the appenders.
|
|
17895
|
+
try {
|
|
17896
|
+
const persistedConfig = loadConfig({});
|
|
17897
|
+
const diagnostics = runDiagnostics(persistedConfig);
|
|
17898
|
+
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
17899
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warn');
|
|
17900
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
17901
|
+
logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
|
|
17902
|
+
}
|
|
17903
|
+
else {
|
|
17904
|
+
if (errors.length > 0) {
|
|
17905
|
+
logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
|
|
17906
|
+
for (const diagnostic of errors) {
|
|
17907
|
+
logger.log(` ${chalk.red(diagnostic.message)}`);
|
|
17908
|
+
}
|
|
17909
|
+
}
|
|
17910
|
+
if (warnings.length > 0) {
|
|
17911
|
+
logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
|
|
17912
|
+
for (const diagnostic of warnings) {
|
|
17913
|
+
logger.log(` ${chalk.yellow(diagnostic.message)}`);
|
|
17914
|
+
}
|
|
17915
|
+
}
|
|
17916
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
|
|
17917
|
+
}
|
|
17918
|
+
}
|
|
17919
|
+
catch (verifyError) {
|
|
17920
|
+
// Verification is a polish step, not a blocker. If it crashes
|
|
17921
|
+
// (e.g. config file written to a path the loader can't reach
|
|
17922
|
+
// from the current cwd), fall through to a hint instead of
|
|
17923
|
+
// failing the whole init flow — the config is on disk and
|
|
17924
|
+
// the user can run `coco doctor` themselves.
|
|
17925
|
+
logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
|
|
17926
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
|
|
17927
|
+
}
|
|
17725
17928
|
}
|
|
17726
17929
|
else {
|
|
17727
17930
|
logger.log('\ninit cancelled.', { color: 'yellow' });
|
|
@@ -17763,7 +17966,7 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17763
17966
|
|
|
17764
17967
|
var init = {
|
|
17765
17968
|
command: command$7,
|
|
17766
|
-
desc: '
|
|
17969
|
+
desc: 'Install & configure coco globally or for the current project',
|
|
17767
17970
|
builder: builder$7,
|
|
17768
17971
|
handler: commandExecutor(handler$7),
|
|
17769
17972
|
options: options$7,
|
|
@@ -17847,19 +18050,76 @@ async function getGitHubRepository(git) {
|
|
|
17847
18050
|
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17848
18051
|
}
|
|
17849
18052
|
/**
|
|
17850
|
-
* Probe `gh auth status` and return
|
|
17851
|
-
*
|
|
17852
|
-
* circuit before issuing real API calls —
|
|
17853
|
-
*
|
|
17854
|
-
* of
|
|
18053
|
+
* Probe `gh auth status` and return a structured status describing
|
|
18054
|
+
* exactly which of the failure modes is in play. Used by every data
|
|
18055
|
+
* fetcher to short-circuit before issuing real API calls — and now
|
|
18056
|
+
* lets the caller surface a tailored recovery hint per failure mode
|
|
18057
|
+
* instead of one catch-all message.
|
|
18058
|
+
*
|
|
18059
|
+
* Distinguishing the modes:
|
|
18060
|
+
* - ENOENT (`gh: command not found`) → `not-installed`
|
|
18061
|
+
* - `gh auth status` exits non-zero with stderr matching the
|
|
18062
|
+
* "not logged into" / "authentication required" pattern →
|
|
18063
|
+
* `not-authenticated`
|
|
18064
|
+
* - Anything else (permission denied on the binary, timeout, etc.)
|
|
18065
|
+
* → `unknown` with the underlying error message attached for
|
|
18066
|
+
* diagnostic display.
|
|
17855
18067
|
*/
|
|
17856
|
-
async function
|
|
18068
|
+
async function getGhStatus(runner) {
|
|
17857
18069
|
try {
|
|
17858
18070
|
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17859
|
-
return
|
|
18071
|
+
return { kind: 'ok' };
|
|
17860
18072
|
}
|
|
17861
|
-
catch {
|
|
17862
|
-
|
|
18073
|
+
catch (error) {
|
|
18074
|
+
const err = error;
|
|
18075
|
+
// ENOENT = the binary itself is missing. exec/spawn surfaces this
|
|
18076
|
+
// as either `code === 'ENOENT'` (Node's spawn error code) or a
|
|
18077
|
+
// message containing "ENOENT". Either form is unambiguous.
|
|
18078
|
+
if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
|
|
18079
|
+
return { kind: 'not-installed' };
|
|
18080
|
+
}
|
|
18081
|
+
// gh exits non-zero from `auth status` when the user isn't logged
|
|
18082
|
+
// in. The message body contains "not logged into" or "logged in
|
|
18083
|
+
// failed" depending on the gh version. Both patterns are stable
|
|
18084
|
+
// enough to gate on without scope-locking to a specific gh
|
|
18085
|
+
// release.
|
|
18086
|
+
const stderr = err.stderr || err.message || '';
|
|
18087
|
+
if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
|
|
18088
|
+
return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
|
|
18089
|
+
}
|
|
18090
|
+
// Anything else — permission denied, timeout, etc. Surface the
|
|
18091
|
+
// raw message so the user can read it; treat as unavailable.
|
|
18092
|
+
return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
|
|
18093
|
+
}
|
|
18094
|
+
}
|
|
18095
|
+
/**
|
|
18096
|
+
* Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
|
|
18097
|
+
* existing callers (data loaders, sidebar fetchers) don't all have to
|
|
18098
|
+
* migrate at once. New call sites should use `getGhStatus` directly
|
|
18099
|
+
* to access the discriminated failure modes.
|
|
18100
|
+
*/
|
|
18101
|
+
async function isGhAuthenticated(runner) {
|
|
18102
|
+
const status = await getGhStatus(runner);
|
|
18103
|
+
return status.kind === 'ok';
|
|
18104
|
+
}
|
|
18105
|
+
/**
|
|
18106
|
+
* Render a user-facing recovery hint for a non-`ok` gh status. Used by
|
|
18107
|
+
* `commands/issues` / `commands/prs` / pull-request workflow surfaces
|
|
18108
|
+
* so every "gh is unavailable" message tells the user the exact next
|
|
18109
|
+
* step. Keeps the wording in sync across surfaces — if a user runs
|
|
18110
|
+
* `coco prs` and `coco issues` back to back, the same broken state
|
|
18111
|
+
* surfaces the same fix.
|
|
18112
|
+
*/
|
|
18113
|
+
function describeGhStatus(status) {
|
|
18114
|
+
switch (status.kind) {
|
|
18115
|
+
case 'ok':
|
|
18116
|
+
return 'GitHub CLI is installed and authenticated.';
|
|
18117
|
+
case 'not-installed':
|
|
18118
|
+
return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
|
|
18119
|
+
case 'not-authenticated':
|
|
18120
|
+
return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
|
|
18121
|
+
case 'unknown':
|
|
18122
|
+
return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
|
|
17863
18123
|
}
|
|
17864
18124
|
}
|
|
17865
18125
|
|
|
@@ -18051,13 +18311,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
18051
18311
|
message: 'No GitHub remote detected.',
|
|
18052
18312
|
};
|
|
18053
18313
|
}
|
|
18054
|
-
|
|
18314
|
+
const ghStatus = await getGhStatus(runner);
|
|
18315
|
+
if (ghStatus.kind !== 'ok') {
|
|
18055
18316
|
return {
|
|
18056
18317
|
available: true,
|
|
18057
18318
|
authenticated: false,
|
|
18058
18319
|
repository,
|
|
18059
18320
|
filter,
|
|
18060
|
-
message:
|
|
18321
|
+
message: describeGhStatus(ghStatus),
|
|
18061
18322
|
};
|
|
18062
18323
|
}
|
|
18063
18324
|
try {
|
|
@@ -18169,6 +18430,7 @@ var issues = {
|
|
|
18169
18430
|
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
18170
18431
|
builder: builder$6,
|
|
18171
18432
|
handler: commandExecutor(handler$6),
|
|
18433
|
+
options: options$6,
|
|
18172
18434
|
};
|
|
18173
18435
|
|
|
18174
18436
|
const command$5 = 'log';
|
|
@@ -22188,6 +22450,18 @@ function getLogInkWorkflowActions() {
|
|
|
22188
22450
|
kind: 'destructive',
|
|
22189
22451
|
requiresConfirmation: true,
|
|
22190
22452
|
},
|
|
22453
|
+
{
|
|
22454
|
+
// No key binding — this is raised by the runtime as a second
|
|
22455
|
+
// confirmation when a safe `delete-branch` (`git branch -d`) is
|
|
22456
|
+
// rejected for an unmerged branch. Reachable from the `:` palette
|
|
22457
|
+
// too, as an explicit force-delete that still gates on y-confirm.
|
|
22458
|
+
id: 'force-delete-branch',
|
|
22459
|
+
key: '',
|
|
22460
|
+
label: 'Force-delete branch',
|
|
22461
|
+
description: 'Force-delete the selected branch even if it is not fully merged (git branch -D).',
|
|
22462
|
+
kind: 'destructive',
|
|
22463
|
+
requiresConfirmation: true,
|
|
22464
|
+
},
|
|
22191
22465
|
{
|
|
22192
22466
|
id: 'delete-tag',
|
|
22193
22467
|
key: 'T',
|
|
@@ -22985,6 +23259,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22985
23259
|
description: 'Create a lightweight tag at the cursored commit.',
|
|
22986
23260
|
contexts: ['history'],
|
|
22987
23261
|
},
|
|
23262
|
+
{
|
|
23263
|
+
id: 'viewKeys',
|
|
23264
|
+
keys: ['g?'],
|
|
23265
|
+
label: 'keys',
|
|
23266
|
+
description: 'Show the single-key actions available in the current view (which-key strip).',
|
|
23267
|
+
contexts: ['normal'],
|
|
23268
|
+
},
|
|
22988
23269
|
{
|
|
22989
23270
|
id: 'themePicker',
|
|
22990
23271
|
keys: ['gC'],
|
|
@@ -23712,6 +23993,48 @@ function getLogInkHelpSections(options) {
|
|
|
23712
23993
|
},
|
|
23713
23994
|
];
|
|
23714
23995
|
}
|
|
23996
|
+
/**
|
|
23997
|
+
* True when a key string is a single, bare printable key (e.g. `c`, `R`,
|
|
23998
|
+
* `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
|
|
23999
|
+
* `page down`). Used by the which-key view-keys strip, which surfaces only
|
|
24000
|
+
* the single-key overloads — the chord set already has its own overlay.
|
|
24001
|
+
*/
|
|
24002
|
+
function isBareSingleKey(key) {
|
|
24003
|
+
return key.length === 1 && key !== ' ';
|
|
24004
|
+
}
|
|
24005
|
+
/**
|
|
24006
|
+
* Single-key bindings available in the current view (#1137). Powers the
|
|
24007
|
+
* `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
|
|
24008
|
+
*
|
|
24009
|
+
* Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
|
|
24010
|
+
* filtered the same way the help overlay's "This view" section is — by
|
|
24011
|
+
* `contexts` against the active view + focus — then narrowed to bindings
|
|
24012
|
+
* that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
|
|
24013
|
+
* are excluded: they're always available and already live in the footer and
|
|
24014
|
+
* onboarding tour, so the strip stays focused on the deliberate per-view
|
|
24015
|
+
* overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
|
|
24016
|
+
*
|
|
24017
|
+
* Sorted by the first bare key for stable, scannable output.
|
|
24018
|
+
*/
|
|
24019
|
+
function getLogInkViewKeyBindings(options) {
|
|
24020
|
+
return LOG_INK_KEY_BINDINGS
|
|
24021
|
+
.filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
|
|
24022
|
+
bindingMatchesViewContext(binding, options) &&
|
|
24023
|
+
binding.keys.some(isBareSingleKey))
|
|
24024
|
+
.sort((a, b) => {
|
|
24025
|
+
const aKey = a.keys.find(isBareSingleKey) ?? '';
|
|
24026
|
+
const bKey = b.keys.find(isBareSingleKey) ?? '';
|
|
24027
|
+
return aKey.localeCompare(bKey);
|
|
24028
|
+
});
|
|
24029
|
+
}
|
|
24030
|
+
/**
|
|
24031
|
+
* Format only the bare single keys of a binding for the view-keys strip
|
|
24032
|
+
* (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
|
|
24033
|
+
* about the single-key affordance, and the full key list lives in `?` help.
|
|
24034
|
+
*/
|
|
24035
|
+
function formatBindingBareKeys(binding) {
|
|
24036
|
+
return binding.keys.filter(isBareSingleKey).join(' / ');
|
|
24037
|
+
}
|
|
23715
24038
|
function bindingToPaletteCommand(binding) {
|
|
23716
24039
|
return {
|
|
23717
24040
|
id: binding.id,
|
|
@@ -24855,6 +25178,15 @@ function formatSortIndicator(mode, options = {}) {
|
|
|
24855
25178
|
return `${options.ascii ? 'v' : '▼'} ${mode}`;
|
|
24856
25179
|
}
|
|
24857
25180
|
|
|
25181
|
+
/**
|
|
25182
|
+
* True when `pending` (a `state.pendingDeletion`) targets this exact row.
|
|
25183
|
+
* Shared by every deletable surface + the sidebar so the spinner-swap
|
|
25184
|
+
* test is identical everywhere. Takes the field value (not the whole
|
|
25185
|
+
* state) so it can live next to the type without a forward reference.
|
|
25186
|
+
*/
|
|
25187
|
+
function isPendingDeletion(pending, kind, id) {
|
|
25188
|
+
return pending?.kind === kind && pending.id === id;
|
|
25189
|
+
}
|
|
24858
25190
|
const DEFAULT_CHANGELOG_VIEW_STATE = {
|
|
24859
25191
|
status: 'idle',
|
|
24860
25192
|
scrollOffset: 0,
|
|
@@ -25225,7 +25557,39 @@ function replaceRows(state, rows) {
|
|
|
25225
25557
|
}
|
|
25226
25558
|
function appendRows(state, rows) {
|
|
25227
25559
|
const selected = getSelectedInkCommit(state);
|
|
25228
|
-
|
|
25560
|
+
// Dedup the merged row list by commit hash so the graph renderer —
|
|
25561
|
+
// which windows directly over `state.rows` (toFullGraphItems →
|
|
25562
|
+
// expandRowsWithSpacers) — and the selection list (deduped commits)
|
|
25563
|
+
// agree on one canonical, duplicate-free row order. Overlapping
|
|
25564
|
+
// appends, notably the anchored `loadCommitContext` page that
|
|
25565
|
+
// re-walks history from the tip, otherwise stack the newest commits
|
|
25566
|
+
// below the oldest ones already loaded. The renderer then shows the
|
|
25567
|
+
// initial commit directly above HEAD and the cursor can scroll
|
|
25568
|
+
// forever through the duplicated tail — the history graph "looping
|
|
25569
|
+
// back on itself". Drop graph-only topology rows that trail a dropped
|
|
25570
|
+
// duplicate commit too, since they describe that duplicate's lanes
|
|
25571
|
+
// and would otherwise dangle.
|
|
25572
|
+
const seenHashes = new Set();
|
|
25573
|
+
const nextRows = [];
|
|
25574
|
+
let droppingTrailingGraph = false;
|
|
25575
|
+
for (const row of [...state.rows, ...rows]) {
|
|
25576
|
+
if (row.type === 'commit') {
|
|
25577
|
+
if (seenHashes.has(row.hash)) {
|
|
25578
|
+
droppingTrailingGraph = true;
|
|
25579
|
+
continue;
|
|
25580
|
+
}
|
|
25581
|
+
seenHashes.add(row.hash);
|
|
25582
|
+
droppingTrailingGraph = false;
|
|
25583
|
+
nextRows.push(row);
|
|
25584
|
+
continue;
|
|
25585
|
+
}
|
|
25586
|
+
// Graph-only topology row: keep it unless it trails a just-dropped
|
|
25587
|
+
// duplicate commit (then it belongs to the duplicate page's lanes).
|
|
25588
|
+
if (droppingTrailingGraph) {
|
|
25589
|
+
continue;
|
|
25590
|
+
}
|
|
25591
|
+
nextRows.push(row);
|
|
25592
|
+
}
|
|
25229
25593
|
const seen = new Set();
|
|
25230
25594
|
const commits = getCommitRows(nextRows).filter((commit) => {
|
|
25231
25595
|
if (seen.has(commit.hash)) {
|
|
@@ -25317,6 +25681,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
25317
25681
|
fullGraph: options.fullGraph ?? true,
|
|
25318
25682
|
showHelp: false,
|
|
25319
25683
|
helpScrollOffset: 0,
|
|
25684
|
+
showViewKeys: false,
|
|
25320
25685
|
showCommandPalette: false,
|
|
25321
25686
|
workflowActionId: undefined,
|
|
25322
25687
|
pendingConfirmationId: undefined,
|
|
@@ -25822,6 +26187,22 @@ function applyLogInkAction(state, action) {
|
|
|
25822
26187
|
pendingKey: undefined,
|
|
25823
26188
|
};
|
|
25824
26189
|
}
|
|
26190
|
+
case 'returnFromCommit': {
|
|
26191
|
+
// After a successful commit we leave the compose view automatically.
|
|
26192
|
+
// Where to: a still-dirty tree the user was staging from returns to
|
|
26193
|
+
// the Status view so they can finish the rest; an otherwise-complete
|
|
26194
|
+
// commit returns to the History view, where the new commit now shows.
|
|
26195
|
+
// We pop frames one at a time (reusing withPoppedView) so sidebar-tab
|
|
26196
|
+
// and diff-state restoration stays identical to manual Esc/back —
|
|
26197
|
+
// this also unwinds an intermediate `diff` frame (status → diff →
|
|
26198
|
+
// compose) back to the status frame it sits under.
|
|
26199
|
+
const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
|
|
26200
|
+
let next = state;
|
|
26201
|
+
while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
|
|
26202
|
+
next = withPoppedView(next);
|
|
26203
|
+
}
|
|
26204
|
+
return { ...next, pendingKey: undefined };
|
|
26205
|
+
}
|
|
25825
26206
|
case 'navigateOpenDiffForCommit': {
|
|
25826
26207
|
const next = withPushedView(state, 'diff');
|
|
25827
26208
|
const filteredCommits = state.filteredCommits;
|
|
@@ -26011,6 +26392,10 @@ function applyLogInkAction(state, action) {
|
|
|
26011
26392
|
workflowActionId: action.value ? undefined : state.workflowActionId,
|
|
26012
26393
|
pendingKey: undefined,
|
|
26013
26394
|
};
|
|
26395
|
+
case 'setPendingDeletion':
|
|
26396
|
+
// Pure marker for the in-flight delete; touches nothing else so the
|
|
26397
|
+
// list keeps rendering normally underneath the one spinner'd row.
|
|
26398
|
+
return { ...state, pendingDeletion: action.value };
|
|
26014
26399
|
case 'toggleFilterMode':
|
|
26015
26400
|
return {
|
|
26016
26401
|
...state,
|
|
@@ -26018,6 +26403,7 @@ function applyLogInkAction(state, action) {
|
|
|
26018
26403
|
showCommandPalette: false,
|
|
26019
26404
|
showHelp: false,
|
|
26020
26405
|
helpScrollOffset: 0,
|
|
26406
|
+
showViewKeys: false,
|
|
26021
26407
|
pendingKey: undefined,
|
|
26022
26408
|
};
|
|
26023
26409
|
case 'toggleGraph':
|
|
@@ -26036,9 +26422,24 @@ function applyLogInkAction(state, action) {
|
|
|
26036
26422
|
// than picking up where the user last scrolled.
|
|
26037
26423
|
helpScrollOffset: 0,
|
|
26038
26424
|
showCommandPalette: false,
|
|
26425
|
+
// Opening full help supersedes the compact view-keys strip — this
|
|
26426
|
+
// is the progressive-disclosure step (`?` from the strip expands
|
|
26427
|
+
// to the full categorized help, #1137).
|
|
26428
|
+
showViewKeys: false,
|
|
26039
26429
|
pendingKey: undefined,
|
|
26040
26430
|
};
|
|
26041
26431
|
}
|
|
26432
|
+
case 'toggleViewKeys':
|
|
26433
|
+
return {
|
|
26434
|
+
...state,
|
|
26435
|
+
showViewKeys: !state.showViewKeys,
|
|
26436
|
+
// The view-keys strip is mutually exclusive with the other
|
|
26437
|
+
// overlays; opening it closes anything else that was showing.
|
|
26438
|
+
showHelp: false,
|
|
26439
|
+
helpScrollOffset: 0,
|
|
26440
|
+
showCommandPalette: false,
|
|
26441
|
+
pendingKey: undefined,
|
|
26442
|
+
};
|
|
26042
26443
|
case 'scrollHelp':
|
|
26043
26444
|
// No upper-bound clamp here — the renderer caps the offset
|
|
26044
26445
|
// against the actual content height at render time. The
|
|
@@ -26055,6 +26456,7 @@ function applyLogInkAction(state, action) {
|
|
|
26055
26456
|
showCommandPalette: opening,
|
|
26056
26457
|
showHelp: false,
|
|
26057
26458
|
helpScrollOffset: 0,
|
|
26459
|
+
showViewKeys: false,
|
|
26058
26460
|
// Reset palette interaction state on every open/close so the next
|
|
26059
26461
|
// session starts from a clean slate.
|
|
26060
26462
|
paletteFilter: '',
|
|
@@ -26102,8 +26504,9 @@ function applyLogInkAction(state, action) {
|
|
|
26102
26504
|
return {
|
|
26103
26505
|
...state,
|
|
26104
26506
|
showThemePicker: opening,
|
|
26105
|
-
// Only one overlay at a time — close help / palette on open.
|
|
26507
|
+
// Only one overlay at a time — close help / palette / view-keys on open.
|
|
26106
26508
|
showHelp: false,
|
|
26509
|
+
showViewKeys: false,
|
|
26107
26510
|
showCommandPalette: false,
|
|
26108
26511
|
themePickerFilter: '',
|
|
26109
26512
|
themePickerIndex: 0,
|
|
@@ -26903,6 +27306,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26903
27306
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26904
27307
|
// this opens the theme picker.
|
|
26905
27308
|
return [action({ type: 'toggleThemePicker' })];
|
|
27309
|
+
case 'viewKeys':
|
|
27310
|
+
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
27311
|
+
// this opens the per-view which-key strip (#1137).
|
|
27312
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
26906
27313
|
case 'openProjectConfig':
|
|
26907
27314
|
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26908
27315
|
case 'openGlobalConfig':
|
|
@@ -27631,6 +28038,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27631
28038
|
}
|
|
27632
28039
|
return [];
|
|
27633
28040
|
}
|
|
28041
|
+
// #1137 — the `g?` which-key strip. While it's open the keyboard is
|
|
28042
|
+
// claimed (mirrors the help overlay) so a stray keystroke can't drop
|
|
28043
|
+
// the user into a per-view action they didn't mean to trigger. Esc
|
|
28044
|
+
// closes; `?` is the progressive-disclosure step up to the full
|
|
28045
|
+
// categorized help; `q` still quits. Everything else is swallowed —
|
|
28046
|
+
// the user peeks, dismisses, then presses the key they came for.
|
|
28047
|
+
if (state.showViewKeys) {
|
|
28048
|
+
if (key.escape) {
|
|
28049
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
28050
|
+
}
|
|
28051
|
+
if (inputValue === '?') {
|
|
28052
|
+
// Expand the compact strip into the full help overlay. `toggleHelp`
|
|
28053
|
+
// clears `showViewKeys` so the two never render at once.
|
|
28054
|
+
return [action({ type: 'toggleHelp' })];
|
|
28055
|
+
}
|
|
28056
|
+
if (inputValue === 'q') {
|
|
28057
|
+
return [{ type: 'exit' }];
|
|
28058
|
+
}
|
|
28059
|
+
return [];
|
|
28060
|
+
}
|
|
27634
28061
|
// #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
|
|
27635
28062
|
// BEFORE the generic `popView` so we both clear the wizard state
|
|
27636
28063
|
// and walk back to the bisect view in one keystroke. Without this
|
|
@@ -27674,6 +28101,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27674
28101
|
}
|
|
27675
28102
|
return [{ type: 'exit' }];
|
|
27676
28103
|
}
|
|
28104
|
+
// `g?` chord (#1137) — open the per-view which-key strip. Placed
|
|
28105
|
+
// BEFORE the bare `?` (full help) check below so the chord is read as
|
|
28106
|
+
// a unit: with `g` pending, `?` opens the view-keys strip rather than
|
|
28107
|
+
// toggling full help. Surfaces automatically in the `g` which-key menu
|
|
28108
|
+
// because its key is a two-char `g`-prefixed binding.
|
|
28109
|
+
if (state.pendingKey === 'g' && inputValue === '?') {
|
|
28110
|
+
return [
|
|
28111
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
28112
|
+
action({ type: 'toggleViewKeys' }),
|
|
28113
|
+
];
|
|
28114
|
+
}
|
|
27677
28115
|
if (inputValue === '?') {
|
|
27678
28116
|
return [action({ type: 'toggleHelp' })];
|
|
27679
28117
|
}
|
|
@@ -29582,6 +30020,24 @@ const SPINNER_TICK_MS = 80;
|
|
|
29582
30020
|
function pickSpinnerFrame(tick) {
|
|
29583
30021
|
return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
|
|
29584
30022
|
}
|
|
30023
|
+
/**
|
|
30024
|
+
* ASCII-safe spinner frames for `NO_COLOR` / ASCII terminals where the
|
|
30025
|
+
* braille dots either don't render or look like noise. The four-frame
|
|
30026
|
+
* `|/-\` cycle is the classic terminal spinner and reads as motion in
|
|
30027
|
+
* any encoding.
|
|
30028
|
+
*/
|
|
30029
|
+
const ASCII_SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
30030
|
+
/**
|
|
30031
|
+
* Inline per-item pending glyph — used in place of (or appended to) a
|
|
30032
|
+
* list row's status icon while that row's mutation (a delete) is in
|
|
30033
|
+
* flight. Braille spinner normally; the ASCII cycle under `ascii`
|
|
30034
|
+
* themes so the indicator survives `NO_COLOR` / dumb terminals.
|
|
30035
|
+
*/
|
|
30036
|
+
function inlineSpinnerGlyph(tick, ascii) {
|
|
30037
|
+
return ascii
|
|
30038
|
+
? ASCII_SPINNER_FRAMES[Math.max(0, tick) % ASCII_SPINNER_FRAMES.length]
|
|
30039
|
+
: pickSpinnerFrame(tick);
|
|
30040
|
+
}
|
|
29585
30041
|
|
|
29586
30042
|
/**
|
|
29587
30043
|
* Build the initial `LogInkContextStatus` for a freshly-created frame
|
|
@@ -30536,7 +30992,7 @@ function createBranch(git, branchName, startPoint) {
|
|
|
30536
30992
|
function renameBranch(git, oldName, newName) {
|
|
30537
30993
|
return runAction$5(() => git.raw(['branch', '-m', oldName, newName]), `Renamed ${oldName} to ${newName}`);
|
|
30538
30994
|
}
|
|
30539
|
-
function deleteBranch(git, branch) {
|
|
30995
|
+
function deleteBranch(git, branch, force = false) {
|
|
30540
30996
|
if (branch.type !== 'local') {
|
|
30541
30997
|
return Promise.resolve({
|
|
30542
30998
|
ok: false,
|
|
@@ -30549,7 +31005,18 @@ function deleteBranch(git, branch) {
|
|
|
30549
31005
|
message: 'Cannot delete the current branch.',
|
|
30550
31006
|
});
|
|
30551
31007
|
}
|
|
30552
|
-
|
|
31008
|
+
// `-d` is the safe delete (refuses unmerged branches); `-D` forces it.
|
|
31009
|
+
// The TUI starts with `-d` and only escalates to `-D` after the user
|
|
31010
|
+
// confirms a second time on the "not fully merged" error.
|
|
31011
|
+
return runAction$5(() => git.raw(['branch', force ? '-D' : '-d', branch.shortName]), force ? `Force-deleted branch ${branch.shortName}` : `Deleted branch ${branch.shortName}`);
|
|
31012
|
+
}
|
|
31013
|
+
/**
|
|
31014
|
+
* True when a failed `git branch -d` was rejected specifically because the
|
|
31015
|
+
* branch isn't fully merged (the one case worth offering a force-delete
|
|
31016
|
+
* for). Matches git's wording across versions ("not fully merged").
|
|
31017
|
+
*/
|
|
31018
|
+
function isBranchNotFullyMergedError(message) {
|
|
31019
|
+
return /not fully merged/i.test(message || '');
|
|
30553
31020
|
}
|
|
30554
31021
|
function fetchRemotes(git) {
|
|
30555
31022
|
return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
|
|
@@ -30656,7 +31123,7 @@ function fetchBranch(git, branch) {
|
|
|
30656
31123
|
if (!branch.upstream || !branch.remote) {
|
|
30657
31124
|
return Promise.resolve({
|
|
30658
31125
|
ok: false,
|
|
30659
|
-
message: `${branch.shortName} has no upstream —
|
|
31126
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
|
|
30660
31127
|
});
|
|
30661
31128
|
}
|
|
30662
31129
|
// `branch.upstream` is the short form (e.g. `origin/main`); the
|
|
@@ -30694,7 +31161,7 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30694
31161
|
if (!branch.upstream || !branch.remote) {
|
|
30695
31162
|
return Promise.resolve({
|
|
30696
31163
|
ok: false,
|
|
30697
|
-
message: `${branch.shortName} has no upstream —
|
|
31164
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
|
|
30698
31165
|
});
|
|
30699
31166
|
}
|
|
30700
31167
|
// Current branch — defer to the in-place workflow.
|
|
@@ -32002,13 +32469,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
32002
32469
|
message: 'No GitHub remote detected.',
|
|
32003
32470
|
};
|
|
32004
32471
|
}
|
|
32005
|
-
|
|
32472
|
+
const ghStatus = await getGhStatus(runner);
|
|
32473
|
+
if (ghStatus.kind !== 'ok') {
|
|
32006
32474
|
return {
|
|
32007
32475
|
available: true,
|
|
32008
32476
|
authenticated: false,
|
|
32009
32477
|
repository,
|
|
32010
32478
|
filter,
|
|
32011
|
-
message:
|
|
32479
|
+
message: describeGhStatus(ghStatus),
|
|
32012
32480
|
};
|
|
32013
32481
|
}
|
|
32014
32482
|
try {
|
|
@@ -32874,6 +33342,7 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32874
33342
|
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
32875
33343
|
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
32876
33344
|
state.showHelp ||
|
|
33345
|
+
state.showViewKeys ||
|
|
32877
33346
|
state.showCommandPalette ||
|
|
32878
33347
|
state.showThemePicker ||
|
|
32879
33348
|
state.gitignorePicker ||
|
|
@@ -33955,7 +34424,13 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
33955
34424
|
* rows so they read as the same severity scale used in the main status
|
|
33956
34425
|
* surface; every other tab falls through to selectable rows.
|
|
33957
34426
|
*/
|
|
33958
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
34427
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame) {
|
|
34428
|
+
// Inline pending-delete glyph: while a row's delete is in flight it
|
|
34429
|
+
// shows this spinner in place of its leading marker (branches /
|
|
34430
|
+
// worktrees) or appended to the row (tags / stashes, which have no
|
|
34431
|
+
// leading status icon). `pending` is the single in-flight target.
|
|
34432
|
+
const pending = state.pendingDeletion;
|
|
34433
|
+
const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
|
|
33959
34434
|
// Available rows for the active tab's list. The sidebar chrome
|
|
33960
34435
|
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
33961
34436
|
// spacers); the branches tab eats 3 more for its summary header
|
|
@@ -33992,7 +34467,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33992
34467
|
];
|
|
33993
34468
|
return [
|
|
33994
34469
|
...headerRows,
|
|
33995
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) =>
|
|
34470
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
|
|
34471
|
+
const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
|
|
34472
|
+
? spin
|
|
34473
|
+
: branchRowMarker(branch, { ascii: theme.ascii }).glyph;
|
|
34474
|
+
return `${glyph} ${branch.shortName}`;
|
|
34475
|
+
}, 'tab-branches', visibleListCount),
|
|
33996
34476
|
];
|
|
33997
34477
|
}
|
|
33998
34478
|
if (tab === 'tags') {
|
|
@@ -34003,7 +34483,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34003
34483
|
if (tags.length === 0) {
|
|
34004
34484
|
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
34005
34485
|
}
|
|
34006
|
-
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) =>
|
|
34486
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => {
|
|
34487
|
+
const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
|
|
34488
|
+
// Tags have no leading status icon, so the pending spinner is
|
|
34489
|
+
// appended to the row instead of replacing a glyph.
|
|
34490
|
+
return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
|
|
34491
|
+
}, 'tab-tags', visibleListCount);
|
|
34007
34492
|
}
|
|
34008
34493
|
if (tab === 'stashes') {
|
|
34009
34494
|
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
@@ -34013,7 +34498,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34013
34498
|
if (stashes.length === 0) {
|
|
34014
34499
|
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
34015
34500
|
}
|
|
34016
|
-
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) =>
|
|
34501
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => {
|
|
34502
|
+
const base = `@{${index}} ${stash.message || '(no message)'}`;
|
|
34503
|
+
// `@{N}` is the stash ref, not a status icon, so append the
|
|
34504
|
+
// spinner rather than replacing it.
|
|
34505
|
+
return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
|
|
34506
|
+
}, 'tab-stashes', visibleListCount);
|
|
34017
34507
|
}
|
|
34018
34508
|
// worktrees
|
|
34019
34509
|
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
@@ -34024,12 +34514,14 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34024
34514
|
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
34025
34515
|
}
|
|
34026
34516
|
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
34027
|
-
const marker =
|
|
34517
|
+
const marker = isPendingDeletion(pending, 'worktree', worktree.path)
|
|
34518
|
+
? spin
|
|
34519
|
+
: worktree.current ? '*' : ' ';
|
|
34028
34520
|
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
34029
34521
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
34030
34522
|
}, 'tab-worktrees', visibleListCount);
|
|
34031
34523
|
}
|
|
34032
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
34524
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, spinnerFrame = 0) {
|
|
34033
34525
|
const { Box, Text } = components;
|
|
34034
34526
|
const focused = state.focus === 'sidebar';
|
|
34035
34527
|
const tabs = getLogInkSidebarTabs();
|
|
@@ -34065,7 +34557,7 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
34065
34557
|
inverse: headerSelected,
|
|
34066
34558
|
}, headerText));
|
|
34067
34559
|
if (isActive) {
|
|
34068
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
34560
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame));
|
|
34069
34561
|
}
|
|
34070
34562
|
return blocks;
|
|
34071
34563
|
});
|
|
@@ -34416,7 +34908,7 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
34416
34908
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
34417
34909
|
* of #890. No behavior change.
|
|
34418
34910
|
*/
|
|
34419
|
-
function renderBranchesSurface(ctx) {
|
|
34911
|
+
function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
34420
34912
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34421
34913
|
const { Box, Text } = components;
|
|
34422
34914
|
const focused = state.focus === 'commits';
|
|
@@ -34455,7 +34947,14 @@ function renderBranchesSurface(ctx) {
|
|
|
34455
34947
|
const isSelected = index === selected;
|
|
34456
34948
|
const cursor = isSelected ? '>' : ' ';
|
|
34457
34949
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
34458
|
-
|
|
34950
|
+
// While this branch's delete is in flight, its sync-state marker
|
|
34951
|
+
// is replaced by an inline spinner (accent-coloured) so the row
|
|
34952
|
+
// reads as "deleting" until it vanishes on refresh.
|
|
34953
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
|
|
34954
|
+
const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
|
|
34955
|
+
const glyphColor = deleting
|
|
34956
|
+
? (theme.noColor ? undefined : theme.colors.accent)
|
|
34957
|
+
: getBranchRowMarkerColor(marker.kind, theme);
|
|
34459
34958
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
34460
34959
|
const lastTouched = formatBranchLastTouched(branch.date, getRenderNow());
|
|
34461
34960
|
// Split the row into spans so the timestamp stays dim even on the
|
|
@@ -34470,7 +34969,7 @@ function renderBranchesSurface(ctx) {
|
|
|
34470
34969
|
// Truncate the assembled line to the actual panel width so a
|
|
34471
34970
|
// narrow inspector / sidebar focus doesn't push branch rows
|
|
34472
34971
|
// onto a second visual line (#830).
|
|
34473
|
-
const fullText = `${cursorAndPad}${
|
|
34972
|
+
const fullText = `${cursorAndPad}${glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
|
|
34474
34973
|
const truncated = truncateCells(fullText, Math.max(20, width - 4));
|
|
34475
34974
|
// If truncation chopped into the timestamp/divergence portion,
|
|
34476
34975
|
// fall back to a single Text to keep the visible width honest.
|
|
@@ -34493,7 +34992,7 @@ function renderBranchesSurface(ctx) {
|
|
|
34493
34992
|
// no-upstream kinds return undefined from
|
|
34494
34993
|
// `getBranchRowMarkerColor`, so those markers inherit the
|
|
34495
34994
|
// row's dim and read as quiet chrome.
|
|
34496
|
-
h(Text, { color:
|
|
34995
|
+
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
34497
34996
|
});
|
|
34498
34997
|
// Scroll indicators — same "N more above/below" pattern as the
|
|
34499
34998
|
// sidebar and help overlay so the user knows the list continues.
|
|
@@ -34778,8 +35277,16 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34778
35277
|
const bodyVisualLines = compose.body
|
|
34779
35278
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
|
|
34780
35279
|
: ['<empty>'];
|
|
34781
|
-
|
|
34782
|
-
)
|
|
35280
|
+
// Summary now renders on its own indented line under the label (like the
|
|
35281
|
+
// body), so it wraps at the full content width instead of the cramped
|
|
35282
|
+
// "Summary " (9) + chrome budget it had when label and value shared a row.
|
|
35283
|
+
const summaryVisualLines = compose.summary
|
|
35284
|
+
? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
|
|
35285
|
+
: ['<empty>'];
|
|
35286
|
+
// Subject length drives a subtle counter on the Summary label: dim under
|
|
35287
|
+
// 50, warning past the conventional 50-char soft limit, danger past 72.
|
|
35288
|
+
// Counted in code points so multibyte subjects aren't over-counted.
|
|
35289
|
+
const summaryLength = [...compose.summary].length;
|
|
34783
35290
|
// State-line cycles through three modes (#881 phase 3 added the
|
|
34784
35291
|
// loading variant): editing copy when the user is typing, cancel
|
|
34785
35292
|
// hint when an AI draft is generating, default guidance otherwise.
|
|
@@ -34799,6 +35306,52 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34799
35306
|
const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
34800
35307
|
? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
|
|
34801
35308
|
: undefined;
|
|
35309
|
+
// Section header for a field (Summary / Body). The active field's label
|
|
35310
|
+
// carries an arrow marker + the repo's selection highlight (matching the
|
|
35311
|
+
// status surface, see status/index.ts) so the user can see which field
|
|
35312
|
+
// their keystrokes target — even before entering edit mode, and even
|
|
35313
|
+
// under NO_COLOR where the marker + bold/dim carry the signal alone. An
|
|
35314
|
+
// optional length counter (Summary only) trails the label outside the
|
|
35315
|
+
// highlight so its own warning/danger color stays legible.
|
|
35316
|
+
const renderSectionHeader = (name, field, count) => {
|
|
35317
|
+
const active = compose.field === field;
|
|
35318
|
+
const highlight = active && focused && !theme.noColor;
|
|
35319
|
+
const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
|
|
35320
|
+
const badge = active && compose.editing ? ' EDITING' : '';
|
|
35321
|
+
const children = [
|
|
35322
|
+
h(Text, {
|
|
35323
|
+
key: `compose-${field}-label`,
|
|
35324
|
+
bold: active,
|
|
35325
|
+
dimColor: !active,
|
|
35326
|
+
backgroundColor: highlight ? theme.colors.selection : undefined,
|
|
35327
|
+
color: highlight ? theme.colors.selectionForeground : undefined,
|
|
35328
|
+
}, `${marker}${name}${badge}`),
|
|
35329
|
+
];
|
|
35330
|
+
if (count !== undefined) {
|
|
35331
|
+
const countColor = theme.noColor
|
|
35332
|
+
? undefined
|
|
35333
|
+
: count > 72
|
|
35334
|
+
? theme.colors.danger
|
|
35335
|
+
: count > 50
|
|
35336
|
+
? theme.colors.warning
|
|
35337
|
+
: undefined;
|
|
35338
|
+
children.push(h(Text, {
|
|
35339
|
+
key: `compose-${field}-count`,
|
|
35340
|
+
color: countColor,
|
|
35341
|
+
dimColor: countColor === undefined,
|
|
35342
|
+
}, ` ${count}`));
|
|
35343
|
+
}
|
|
35344
|
+
return h(Box, { key: `compose-${field}-header` }, ...children);
|
|
35345
|
+
};
|
|
35346
|
+
// Content lines for a field — indented two cells under the header, with
|
|
35347
|
+
// the edit cursor parked on the final line when this field is active.
|
|
35348
|
+
const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
|
|
35349
|
+
const isLast = index === lines.length - 1;
|
|
35350
|
+
return h(Text, {
|
|
35351
|
+
key: `compose-${field}-${index}`,
|
|
35352
|
+
dimColor: line === '<empty>',
|
|
35353
|
+
}, ` ${line}${cursor && isLast ? cursor : ''}`);
|
|
35354
|
+
});
|
|
34802
35355
|
return h(Box, {
|
|
34803
35356
|
borderColor: focusBorderColor(theme, focused),
|
|
34804
35357
|
borderStyle: theme.borderStyle,
|
|
@@ -34806,20 +35359,7 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34806
35359
|
flexShrink: 0,
|
|
34807
35360
|
paddingX: 1,
|
|
34808
35361
|
width,
|
|
34809
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text,
|
|
34810
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34811
|
-
}, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
|
|
34812
|
-
key: `compose-summary-${index}`,
|
|
34813
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34814
|
-
}, ` ${line}`)), h(Text, undefined, ''), h(Text, {
|
|
34815
|
-
bold: compose.field === 'body' && compose.editing,
|
|
34816
|
-
}, 'Body'), ...bodyVisualLines.map((line, index) => {
|
|
34817
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
34818
|
-
return h(Text, {
|
|
34819
|
-
key: `compose-body-${index}`,
|
|
34820
|
-
dimColor: line === '<empty>',
|
|
34821
|
-
}, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
|
|
34822
|
-
}),
|
|
35362
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), renderSectionHeader('Summary', 'summary', summaryLength > 0 ? summaryLength : undefined), ...renderSectionContent(summaryVisualLines, 'summary', summaryCursor), h(Text, undefined, ''), renderSectionHeader('Body', 'body'), ...renderSectionContent(bodyVisualLines, 'body', bodyCursor),
|
|
34823
35363
|
// Loading indicator + post-action message belong inline with the draft
|
|
34824
35364
|
// (they describe what just happened to the fields above). The state-
|
|
34825
35365
|
// line ("Editing — Enter switches summary↔body…" / "Press e to edit
|
|
@@ -37074,9 +37614,13 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
|
37074
37614
|
? 'You have an unsaved commit draft. Press y to discard it and quit.'
|
|
37075
37615
|
: state.pendingMutationConfirmation
|
|
37076
37616
|
? 'This discards local changes and cannot be undone by Coco.'
|
|
37077
|
-
|
|
37078
|
-
|
|
37079
|
-
|
|
37617
|
+
// Second-stage confirm raised when a safe delete hit an unmerged
|
|
37618
|
+
// branch — name the reason so the force isn't a blind "y again".
|
|
37619
|
+
: state.pendingConfirmationId === 'force-delete-branch'
|
|
37620
|
+
? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
|
|
37621
|
+
: action?.kind === 'ai'
|
|
37622
|
+
? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
|
|
37623
|
+
: 'Destructive Git action requires confirmation.';
|
|
37080
37624
|
return h(Box, {
|
|
37081
37625
|
borderColor: focusBorderColor(theme, focused),
|
|
37082
37626
|
borderStyle: theme.borderStyle,
|
|
@@ -37157,6 +37701,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
|
|
|
37157
37701
|
paddingX: 1,
|
|
37158
37702
|
}, ...lines);
|
|
37159
37703
|
}
|
|
37704
|
+
/**
|
|
37705
|
+
* Which-key view-keys strip (#1137). The per-view counterpart to the
|
|
37706
|
+
* `g`-chord overlay: opened by `g?`, it lists the single-key actions
|
|
37707
|
+
* available in the current view (the deliberate overloads — `c`, `R`,
|
|
37708
|
+
* `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
|
|
37709
|
+
* `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
|
|
37710
|
+
*
|
|
37711
|
+
* Renders in the detail panel slot like the chord overlay. `?` steps up
|
|
37712
|
+
* to the full categorized help; Esc closes.
|
|
37713
|
+
*/
|
|
37714
|
+
function renderViewKeysOverlay(h, components, state, width, theme, focused) {
|
|
37715
|
+
const { Box, Text } = components;
|
|
37716
|
+
const bindings = getLogInkViewKeyBindings({
|
|
37717
|
+
activeView: state.activeView,
|
|
37718
|
+
focus: state.focus,
|
|
37719
|
+
});
|
|
37720
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
37721
|
+
const lines = [
|
|
37722
|
+
h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
|
|
37723
|
+
h(Text, { key: 'view-keys-spacer' }, ''),
|
|
37724
|
+
];
|
|
37725
|
+
if (bindings.length === 0) {
|
|
37726
|
+
lines.push(h(Text, {
|
|
37727
|
+
key: 'view-keys-empty',
|
|
37728
|
+
dimColor: true,
|
|
37729
|
+
}, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
|
|
37730
|
+
}
|
|
37731
|
+
else {
|
|
37732
|
+
// Pad keys to the widest entry so labels align into a scannable column.
|
|
37733
|
+
const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
|
|
37734
|
+
for (const binding of bindings) {
|
|
37735
|
+
const keys = formatBindingBareKeys(binding);
|
|
37736
|
+
lines.push(h(Text, { key: `view-keys-${binding.id}` }, h(Text, { color: accent, bold: true }, ` ${keys.padEnd(keyColumn)} `), h(Text, undefined, truncateCells(`${binding.label.padEnd(14)} ${binding.description}`, width - keyColumn - 7))));
|
|
37737
|
+
}
|
|
37738
|
+
}
|
|
37739
|
+
lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
|
|
37740
|
+
lines.push(h(Text, {
|
|
37741
|
+
key: 'view-keys-hint',
|
|
37742
|
+
dimColor: true,
|
|
37743
|
+
}, truncateCells('? full help · esc closes', width - 4)));
|
|
37744
|
+
return h(Box, {
|
|
37745
|
+
borderColor: focusBorderColor(theme, focused),
|
|
37746
|
+
borderStyle: theme.borderStyle,
|
|
37747
|
+
flexDirection: 'column',
|
|
37748
|
+
width,
|
|
37749
|
+
paddingX: 1,
|
|
37750
|
+
}, ...lines);
|
|
37751
|
+
}
|
|
37160
37752
|
function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
|
|
37161
37753
|
const { Box, Text } = components;
|
|
37162
37754
|
// Build the full list of body rows (everything below the title).
|
|
@@ -38199,7 +38791,7 @@ function renderReflogSurface(ctx) {
|
|
|
38199
38791
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38200
38792
|
* of #890. No behavior change.
|
|
38201
38793
|
*/
|
|
38202
|
-
function renderStashSurface(ctx) {
|
|
38794
|
+
function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
38203
38795
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38204
38796
|
const { Box, Text } = components;
|
|
38205
38797
|
const focused = state.focus === 'commits';
|
|
@@ -38245,11 +38837,18 @@ function renderStashSurface(ctx) {
|
|
|
38245
38837
|
const rowText = meta
|
|
38246
38838
|
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38247
38839
|
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
38840
|
+
// The `stash@{N}` ref is an identifier, not a status icon, so a
|
|
38841
|
+
// delete-in-flight appends an accent spinner at the row's end
|
|
38842
|
+
// (2 cells reserved from the width budget).
|
|
38843
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'stash', stash.ref);
|
|
38844
|
+
const spinnerSpan = deleting
|
|
38845
|
+
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
38846
|
+
: null;
|
|
38248
38847
|
return h(Text, {
|
|
38249
38848
|
key: `stash-${index}`,
|
|
38250
38849
|
bold: isSelected,
|
|
38251
38850
|
dimColor: !isSelected,
|
|
38252
|
-
}, truncateCells(rowText, rowWidth));
|
|
38851
|
+
}, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
|
|
38253
38852
|
});
|
|
38254
38853
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
38255
38854
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -38597,7 +39196,7 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38597
39196
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38598
39197
|
* of #890. No behavior change.
|
|
38599
39198
|
*/
|
|
38600
|
-
function renderTagsSurface(ctx) {
|
|
39199
|
+
function renderTagsSurface(ctx, spinnerFrame = 0) {
|
|
38601
39200
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38602
39201
|
const { Box, Text } = components;
|
|
38603
39202
|
const focused = state.focus === 'commits';
|
|
@@ -38638,13 +39237,20 @@ function renderTagsSurface(ctx) {
|
|
|
38638
39237
|
// intact.
|
|
38639
39238
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
38640
39239
|
const namePadded = truncateCells(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
38641
|
-
|
|
39240
|
+
// Tags have no leading status icon, so a delete-in-flight appends
|
|
39241
|
+
// an accent spinner at the row's end. Reserve its 2 cells from the
|
|
39242
|
+
// truncation budget so it never pushes the row past the panel.
|
|
39243
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
|
|
39244
|
+
const spinnerSpan = deleting
|
|
39245
|
+
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
39246
|
+
: null;
|
|
39247
|
+
const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4 - (deleting ? 2 : 0)));
|
|
38642
39248
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
38643
39249
|
return h(Text, {
|
|
38644
39250
|
key: `tag-${index}`,
|
|
38645
39251
|
bold: isSelected,
|
|
38646
39252
|
dimColor: !isSelected,
|
|
38647
|
-
}, lineText);
|
|
39253
|
+
}, lineText, spinnerSpan);
|
|
38648
39254
|
}
|
|
38649
39255
|
const linkStart = lineText.indexOf(namePadded);
|
|
38650
39256
|
const before = lineText.slice(0, linkStart);
|
|
@@ -38653,7 +39259,7 @@ function renderTagsSurface(ctx) {
|
|
|
38653
39259
|
key: `tag-${index}`,
|
|
38654
39260
|
bold: isSelected,
|
|
38655
39261
|
dimColor: !isSelected,
|
|
38656
|
-
}, before, formatHyperlink(namePadded, url), after);
|
|
39262
|
+
}, before, formatHyperlink(namePadded, url), after, spinnerSpan);
|
|
38657
39263
|
});
|
|
38658
39264
|
const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
|
|
38659
39265
|
const tagsHasMoreBelow = startIndex + listRows < tags.length;
|
|
@@ -38680,7 +39286,7 @@ function renderTagsSurface(ctx) {
|
|
|
38680
39286
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38681
39287
|
* of #890. No behavior change.
|
|
38682
39288
|
*/
|
|
38683
|
-
function renderWorktreesSurface(ctx) {
|
|
39289
|
+
function renderWorktreesSurface(ctx, spinnerFrame = 0) {
|
|
38684
39290
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38685
39291
|
const { Box, Text } = components;
|
|
38686
39292
|
const focused = state.focus === 'commits';
|
|
@@ -38716,7 +39322,9 @@ function renderWorktreesSurface(ctx) {
|
|
|
38716
39322
|
const index = startIndex + offset;
|
|
38717
39323
|
const isSelected = index === selected;
|
|
38718
39324
|
const cursor = isSelected ? '>' : ' ';
|
|
38719
|
-
const marker =
|
|
39325
|
+
const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
|
|
39326
|
+
? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
|
|
39327
|
+
: entry.current ? '*' : ' ';
|
|
38720
39328
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
38721
39329
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
38722
39330
|
const branchPadded = truncateCells(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
@@ -38786,10 +39394,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
|
|
|
38786
39394
|
return renderComposeSurface(surface, spinnerFrame);
|
|
38787
39395
|
}
|
|
38788
39396
|
if (state.activeView === 'branches') {
|
|
38789
|
-
return renderBranchesSurface(surface);
|
|
39397
|
+
return renderBranchesSurface(surface, spinnerFrame);
|
|
38790
39398
|
}
|
|
38791
39399
|
if (state.activeView === 'tags') {
|
|
38792
|
-
return renderTagsSurface(surface);
|
|
39400
|
+
return renderTagsSurface(surface, spinnerFrame);
|
|
38793
39401
|
}
|
|
38794
39402
|
if (state.activeView === 'reflog') {
|
|
38795
39403
|
return renderReflogSurface(surface);
|
|
@@ -38798,10 +39406,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
|
|
|
38798
39406
|
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38799
39407
|
}
|
|
38800
39408
|
if (state.activeView === 'stash') {
|
|
38801
|
-
return renderStashSurface(surface);
|
|
39409
|
+
return renderStashSurface(surface, spinnerFrame);
|
|
38802
39410
|
}
|
|
38803
39411
|
if (state.activeView === 'worktrees') {
|
|
38804
|
-
return renderWorktreesSurface(surface);
|
|
39412
|
+
return renderWorktreesSurface(surface, spinnerFrame);
|
|
38805
39413
|
}
|
|
38806
39414
|
if (state.activeView === 'submodules') {
|
|
38807
39415
|
return renderSubmodulesSurface(surface);
|
|
@@ -39835,6 +40443,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39835
40443
|
if (state.showHelp) {
|
|
39836
40444
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39837
40445
|
}
|
|
40446
|
+
// #1137 — the `g?` which-key strip lists the current view's single-key
|
|
40447
|
+
// actions. Checked alongside the other overlays; the reducer keeps it
|
|
40448
|
+
// mutually exclusive with help / palette / pickers.
|
|
40449
|
+
if (state.showViewKeys) {
|
|
40450
|
+
return renderViewKeysOverlay(h, components, state, width, theme, focused);
|
|
40451
|
+
}
|
|
39838
40452
|
if (state.showCommandPalette) {
|
|
39839
40453
|
return renderCommandPalette(h, components, state, width, theme, focused);
|
|
39840
40454
|
}
|
|
@@ -40168,6 +40782,53 @@ const REMOTE_OP_LOADERS = {
|
|
|
40168
40782
|
'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
|
|
40169
40783
|
'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
|
|
40170
40784
|
};
|
|
40785
|
+
/**
|
|
40786
|
+
* Resolve which list row a delete workflow is about to act on, so the
|
|
40787
|
+
* runner can mark it pending (inline spinner) for the duration of the
|
|
40788
|
+
* git call. Mirrors the cursored-target resolution inside each delete
|
|
40789
|
+
* handler exactly — same sort, same promoted-filter, same selection
|
|
40790
|
+
* index — so the spinner lands on the row that actually gets deleted.
|
|
40791
|
+
* Returns `undefined` for non-delete workflows (and when nothing is
|
|
40792
|
+
* selected), which the runner treats as "no pending marker".
|
|
40793
|
+
*/
|
|
40794
|
+
function resolvePendingDeletion(id, state, context) {
|
|
40795
|
+
const { filter } = state;
|
|
40796
|
+
if (id === 'delete-branch' || id === 'force-delete-branch') {
|
|
40797
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
40798
|
+
const visible = filter
|
|
40799
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
|
|
40800
|
+
: all;
|
|
40801
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
40802
|
+
return branch ? { kind: 'branch', id: branch.shortName } : undefined;
|
|
40803
|
+
}
|
|
40804
|
+
if (id === 'delete-tag') {
|
|
40805
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
40806
|
+
const visible = filter
|
|
40807
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
|
|
40808
|
+
: all;
|
|
40809
|
+
const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
|
|
40810
|
+
return tag ? { kind: 'tag', id: tag.name } : undefined;
|
|
40811
|
+
}
|
|
40812
|
+
if (id === 'drop-stash') {
|
|
40813
|
+
const all = context.stashes?.stashes || [];
|
|
40814
|
+
const visible = filter
|
|
40815
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
|
|
40816
|
+
: all;
|
|
40817
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
40818
|
+
return stash ? { kind: 'stash', id: stash.ref } : undefined;
|
|
40819
|
+
}
|
|
40820
|
+
if (id === 'remove-worktree') {
|
|
40821
|
+
const all = context.worktreeList?.worktrees || [];
|
|
40822
|
+
const visible = filter
|
|
40823
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], filter))
|
|
40824
|
+
: all;
|
|
40825
|
+
const wt = visible.length
|
|
40826
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
40827
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
40828
|
+
return wt ? { kind: 'worktree', id: wt.path } : undefined;
|
|
40829
|
+
}
|
|
40830
|
+
return undefined;
|
|
40831
|
+
}
|
|
40171
40832
|
function predictNextFilter(action, currentFilter) {
|
|
40172
40833
|
switch (action.type) {
|
|
40173
40834
|
case 'appendFilter':
|
|
@@ -40484,7 +41145,10 @@ function LogInkApp(deps) {
|
|
|
40484
41145
|
state.changelogView.status === 'loading' ||
|
|
40485
41146
|
state.commitCompose.loading ||
|
|
40486
41147
|
Boolean(state.remoteOp) ||
|
|
40487
|
-
Boolean(state.statusLoading)
|
|
41148
|
+
Boolean(state.statusLoading) ||
|
|
41149
|
+
// Keep the shared spinner ticking while a list-item delete is in
|
|
41150
|
+
// flight so its inline pending glyph animates instead of freezing.
|
|
41151
|
+
Boolean(state.pendingDeletion);
|
|
40488
41152
|
React.useEffect(() => {
|
|
40489
41153
|
if (!anyLoading) {
|
|
40490
41154
|
// Reset to 0 so the next loading state starts from a known
|
|
@@ -40740,6 +41404,10 @@ function LogInkApp(deps) {
|
|
|
40740
41404
|
worktree,
|
|
40741
41405
|
}), issuedAtDepth);
|
|
40742
41406
|
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
41407
|
+
// Returned so callers needing the *fresh* overview (e.g. post-commit
|
|
41408
|
+
// navigation) can read it directly instead of racing the async
|
|
41409
|
+
// `setContext` update, which won't be visible in their closure.
|
|
41410
|
+
return worktree;
|
|
40743
41411
|
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
40744
41412
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
40745
41413
|
// context when something changes outside the TUI (editor save, external
|
|
@@ -41602,7 +42270,14 @@ function LogInkApp(deps) {
|
|
|
41602
42270
|
// and see the pre-commit log (same silent-failure shape as
|
|
41603
42271
|
// the split-apply case caught in this PR).
|
|
41604
42272
|
await refreshHistoryRows();
|
|
41605
|
-
await refreshWorktreeContext();
|
|
42273
|
+
const worktree = await refreshWorktreeContext();
|
|
42274
|
+
// Leave the compose view automatically: a still-dirty tree returns
|
|
42275
|
+
// to Status (so the user can keep staging), an otherwise-complete
|
|
42276
|
+
// commit returns to History (where the new commit now shows). The
|
|
42277
|
+
// reducer inspects the live viewStack to pick the destination.
|
|
42278
|
+
const stillDirty = Boolean(worktree &&
|
|
42279
|
+
worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
|
|
42280
|
+
dispatch({ type: 'returnFromCommit', stillDirty });
|
|
41606
42281
|
}
|
|
41607
42282
|
}, [
|
|
41608
42283
|
context.worktree?.stagedCount,
|
|
@@ -42630,6 +43305,16 @@ function LogInkApp(deps) {
|
|
|
42630
43305
|
return { ok: false, message: 'No branch selected' };
|
|
42631
43306
|
return deleteBranch(git, branch);
|
|
42632
43307
|
},
|
|
43308
|
+
'force-delete-branch': async () => {
|
|
43309
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
43310
|
+
const visible = state.filter
|
|
43311
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
43312
|
+
: all;
|
|
43313
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
43314
|
+
if (!branch)
|
|
43315
|
+
return { ok: false, message: 'No branch selected' };
|
|
43316
|
+
return deleteBranch(git, branch, true);
|
|
43317
|
+
},
|
|
42633
43318
|
'delete-tag': async () => {
|
|
42634
43319
|
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
42635
43320
|
const visible = state.filter
|
|
@@ -43403,9 +44088,26 @@ function LogInkApp(deps) {
|
|
|
43403
44088
|
if (remoteOp) {
|
|
43404
44089
|
dispatch({ type: 'setRemoteOp', value: remoteOp });
|
|
43405
44090
|
}
|
|
44091
|
+
// Mark the cursored row as deleting so it shows an inline pending
|
|
44092
|
+
// spinner while the git call runs. Cleared in `finally` after the
|
|
44093
|
+
// refresh, so a successful delete hands straight off to the row
|
|
44094
|
+
// vanishing, and a failed one (e.g. an unmerged branch) restores
|
|
44095
|
+
// the row's normal icon alongside the error status.
|
|
44096
|
+
const pendingDeletion = resolvePendingDeletion(id, state, context);
|
|
44097
|
+
if (pendingDeletion) {
|
|
44098
|
+
dispatch({ type: 'setPendingDeletion', value: pendingDeletion });
|
|
44099
|
+
}
|
|
43406
44100
|
try {
|
|
43407
44101
|
const result = await handler();
|
|
43408
44102
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
44103
|
+
// A safe `delete-branch` (`git branch -d`) refuses branches that
|
|
44104
|
+
// aren't fully merged. Rather than dead-end on git's raw error, raise
|
|
44105
|
+
// a second y-confirm offering the force-delete (`git branch -D`). The
|
|
44106
|
+
// cursor hasn't moved (the delete failed), so the force handler
|
|
44107
|
+
// re-resolves the same branch.
|
|
44108
|
+
if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
|
|
44109
|
+
dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
|
|
44110
|
+
}
|
|
43409
44111
|
// Refresh history rows AS WELL when the workflow could have
|
|
43410
44112
|
// changed the commits the user sees (#945 follow-up). The
|
|
43411
44113
|
// workflow IDs below all either create/rewrite local commits or
|
|
@@ -43504,6 +44206,12 @@ function LogInkApp(deps) {
|
|
|
43504
44206
|
if (remoteOp) {
|
|
43505
44207
|
dispatch({ type: 'setRemoteOp', value: undefined });
|
|
43506
44208
|
}
|
|
44209
|
+
// Same guarantee for the per-row delete spinner: clear it whether
|
|
44210
|
+
// the delete succeeded, failed, or the refresh threw, so no row is
|
|
44211
|
+
// left spinning forever.
|
|
44212
|
+
if (pendingDeletion) {
|
|
44213
|
+
dispatch({ type: 'setPendingDeletion', value: undefined });
|
|
44214
|
+
}
|
|
43507
44215
|
}
|
|
43508
44216
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
43509
44217
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
@@ -44313,6 +45021,7 @@ function LogInkApp(deps) {
|
|
|
44313
45021
|
const forcedPane = state.splitPlan
|
|
44314
45022
|
? 'main'
|
|
44315
45023
|
: state.showHelp ||
|
|
45024
|
+
state.showViewKeys ||
|
|
44316
45025
|
state.showCommandPalette ||
|
|
44317
45026
|
state.showThemePicker ||
|
|
44318
45027
|
state.gitignorePicker ||
|
|
@@ -44362,7 +45071,7 @@ function LogInkApp(deps) {
|
|
|
44362
45071
|
// Panel renderers are thunks so single-pane mode can build only the
|
|
44363
45072
|
// visible pane — the main-panel render in particular is expensive, so
|
|
44364
45073
|
// we don't want to invoke the two hidden ones just to drop them.
|
|
44365
|
-
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
45074
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, spinnerFrame);
|
|
44366
45075
|
const mainSurface = {
|
|
44367
45076
|
h,
|
|
44368
45077
|
components: { Box, Text },
|
|
@@ -45125,6 +45834,7 @@ var prs = {
|
|
|
45125
45834
|
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
45126
45835
|
builder: builder$4,
|
|
45127
45836
|
handler: commandExecutor(handler$3),
|
|
45837
|
+
options: options$4,
|
|
45128
45838
|
};
|
|
45129
45839
|
|
|
45130
45840
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -45204,8 +45914,7 @@ const handler$2 = async (argv, logger) => {
|
|
|
45204
45914
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
45205
45915
|
const model = recapService.model;
|
|
45206
45916
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45207
|
-
logger
|
|
45208
|
-
commandExit(1);
|
|
45917
|
+
handleMissingApiKey(logger, config, { command: 'recap' });
|
|
45209
45918
|
}
|
|
45210
45919
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45211
45920
|
const llm = getLlm(provider, model, { ...config, service: recapService });
|
|
@@ -45793,8 +46502,7 @@ const handler$1 = async (argv, logger) => {
|
|
|
45793
46502
|
const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
|
|
45794
46503
|
const model = reviewService.model;
|
|
45795
46504
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45796
|
-
logger
|
|
45797
|
-
commandExit(1);
|
|
46505
|
+
handleMissingApiKey(logger, config, { command: 'review' });
|
|
45798
46506
|
}
|
|
45799
46507
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45800
46508
|
const llm = getLlm(provider, model, { ...config, service: reviewService });
|