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.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.62.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -292,10 +292,19 @@ class LangChainExecutionError extends LangChainError {
|
|
|
292
292
|
}
|
|
293
293
|
/**
|
|
294
294
|
* Authentication-related errors (missing API keys, invalid credentials, etc.)
|
|
295
|
+
*
|
|
296
|
+
* Carries `provider` + `endpoint` context so the formatter (in
|
|
297
|
+
* `commandExecutor`) can render provider-specific recovery hints
|
|
298
|
+
* ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
|
|
299
|
+
* generic "verify your API key" copy. Mirrors the shape of
|
|
300
|
+
* `LangChainNetworkError` so call sites can hand the same fields to
|
|
301
|
+
* either constructor depending on which condition fired.
|
|
295
302
|
*/
|
|
296
303
|
class LangChainAuthenticationError extends LangChainError {
|
|
297
|
-
constructor(message, context) {
|
|
298
|
-
super(message, context);
|
|
304
|
+
constructor(message, provider, endpoint, context) {
|
|
305
|
+
super(message, { ...context, provider, endpoint });
|
|
306
|
+
this.provider = provider;
|
|
307
|
+
this.endpoint = endpoint;
|
|
299
308
|
}
|
|
300
309
|
}
|
|
301
310
|
/**
|
|
@@ -433,21 +442,27 @@ function getDefaultServiceApiKey(config) {
|
|
|
433
442
|
const requiresAuth = provider === 'openai' || provider === 'anthropic';
|
|
434
443
|
if (service.authentication.type === 'APIKey') {
|
|
435
444
|
const apiKey = service.authentication.credentials?.apiKey;
|
|
445
|
+
// `endpoint` is optional on some service variants (Ollama / OpenAI-
|
|
446
|
+
// compatible) and absent on others (managed OpenAI / Anthropic).
|
|
447
|
+
// Read defensively so we still attach it when present.
|
|
448
|
+
const endpoint = service.endpoint;
|
|
436
449
|
if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
|
|
437
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`,
|
|
450
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
438
451
|
}
|
|
439
452
|
return apiKey || '';
|
|
440
453
|
}
|
|
441
454
|
if (service.authentication.type === 'OAuth') {
|
|
442
455
|
const token = service.authentication.credentials?.token;
|
|
456
|
+
const endpoint = service.endpoint;
|
|
443
457
|
if (requiresAuth && (!token || token.trim() === '')) {
|
|
444
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`,
|
|
458
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
445
459
|
}
|
|
446
460
|
return token || '';
|
|
447
461
|
}
|
|
448
462
|
if (service.authentication.type === 'None') {
|
|
449
463
|
if (requiresAuth) {
|
|
450
|
-
|
|
464
|
+
const endpoint = service.endpoint;
|
|
465
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
451
466
|
}
|
|
452
467
|
return '';
|
|
453
468
|
}
|
|
@@ -2580,18 +2595,48 @@ function formatNetworkError(error, logger) {
|
|
|
2580
2595
|
logger.log(' • Verify the service endpoint is correct', { color: 'white' });
|
|
2581
2596
|
logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
|
|
2582
2597
|
}
|
|
2598
|
+
logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
|
|
2583
2599
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2584
2600
|
}
|
|
2585
2601
|
/**
|
|
2586
|
-
* Formats an authentication error with
|
|
2602
|
+
* Formats an authentication error with provider-aware troubleshooting.
|
|
2603
|
+
*
|
|
2604
|
+
* Pre-MEDIUM-8 the formatter was generic — "verify your API key,
|
|
2605
|
+
* check it hasn't expired" — because the error class didn't carry
|
|
2606
|
+
* any provider context. Now that `LangChainAuthenticationError`
|
|
2607
|
+
* carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
|
|
2608
|
+
* we can name the env var the user actually needs to set and route
|
|
2609
|
+
* Ollama / OpenAI-compatible / managed-provider users through the
|
|
2610
|
+
* right next step.
|
|
2587
2611
|
*/
|
|
2588
2612
|
function formatAuthenticationError(error, logger) {
|
|
2613
|
+
const provider = error.provider || 'LLM service';
|
|
2614
|
+
const endpoint = error.endpoint;
|
|
2589
2615
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2590
|
-
logger.log(
|
|
2616
|
+
logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
|
|
2617
|
+
if (endpoint) {
|
|
2618
|
+
logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
|
|
2619
|
+
}
|
|
2591
2620
|
logger.log('\nTroubleshooting:', { color: 'cyan' });
|
|
2592
|
-
logger.log(' • Verify your API key is correct', { color: 'white' });
|
|
2593
|
-
|
|
2594
|
-
|
|
2621
|
+
logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
|
|
2622
|
+
// Provider-specific env var hint when we know the provider.
|
|
2623
|
+
if (provider === 'openai' || provider === 'OpenAI') {
|
|
2624
|
+
logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2625
|
+
}
|
|
2626
|
+
else if (provider === 'anthropic' || provider === 'Anthropic') {
|
|
2627
|
+
logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2628
|
+
}
|
|
2629
|
+
else if (provider === 'ollama' || provider === 'Ollama') {
|
|
2630
|
+
logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
|
|
2631
|
+
}
|
|
2632
|
+
else if (provider === 'openai-compatible') {
|
|
2633
|
+
logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
|
|
2634
|
+
}
|
|
2635
|
+
else {
|
|
2636
|
+
logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
|
|
2637
|
+
}
|
|
2638
|
+
logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
|
|
2639
|
+
logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
|
|
2595
2640
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2596
2641
|
}
|
|
2597
2642
|
/**
|
|
@@ -3561,8 +3606,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3561
3606
|
const result = clearDiffSummaryCache(repoPath);
|
|
3562
3607
|
if (!result.ok) {
|
|
3563
3608
|
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
3564
|
-
|
|
3565
|
-
return;
|
|
3609
|
+
commandExit(1, 'cache clear failed');
|
|
3566
3610
|
}
|
|
3567
3611
|
if (result.removed) {
|
|
3568
3612
|
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
@@ -3602,8 +3646,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3602
3646
|
if (interactive) {
|
|
3603
3647
|
const picked = await promptLanguageSelection(logger);
|
|
3604
3648
|
if (!picked) {
|
|
3605
|
-
|
|
3606
|
-
return;
|
|
3649
|
+
commandExit(1, 'cache prefetch cancelled');
|
|
3607
3650
|
}
|
|
3608
3651
|
resolved = picked;
|
|
3609
3652
|
}
|
|
@@ -3620,7 +3663,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3620
3663
|
`${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
|
|
3621
3664
|
`${chalk.red(`${result.failed.length} failed`)}`);
|
|
3622
3665
|
if (result.failed.length > 0) {
|
|
3623
|
-
|
|
3666
|
+
commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
|
|
3624
3667
|
}
|
|
3625
3668
|
return;
|
|
3626
3669
|
}
|
|
@@ -3653,12 +3696,12 @@ const handler$b = async (argv, logger) => {
|
|
|
3653
3696
|
}
|
|
3654
3697
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3655
3698
|
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3656
|
-
|
|
3699
|
+
commandExit(1, `unknown cache subcommand: ${subcommand}`);
|
|
3657
3700
|
};
|
|
3658
3701
|
|
|
3659
3702
|
var cache = {
|
|
3660
3703
|
command: command$b,
|
|
3661
|
-
desc: 'Manage
|
|
3704
|
+
desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
|
|
3662
3705
|
builder: builder$b,
|
|
3663
3706
|
handler: commandExecutor(handler$b),
|
|
3664
3707
|
};
|
|
@@ -8927,6 +8970,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
|
|
|
8927
8970
|
return result;
|
|
8928
8971
|
}
|
|
8929
8972
|
|
|
8973
|
+
/**
|
|
8974
|
+
* Centralised glyph + label vocabulary for diagnostic / status copy.
|
|
8975
|
+
*
|
|
8976
|
+
* Before this module each surface (commandExecutor, doctor, footer,
|
|
8977
|
+
* cache, issues, prs, commit-hook flow) picked its own marks for
|
|
8978
|
+
* pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
|
|
8979
|
+
* couldn't lean on a consistent visual signal to scan output, and the
|
|
8980
|
+
* audit flagged it as one of the bigger inconsistencies in the
|
|
8981
|
+
* codebase.
|
|
8982
|
+
*
|
|
8983
|
+
* The vocabulary mirrors what Linux package managers + git-aware
|
|
8984
|
+
* tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
|
|
8985
|
+
* green check / red fail / yellow warn / blue info. ASCII fallbacks
|
|
8986
|
+
* are first-class so dumb terminals (TERM=dumb / vt100) still render
|
|
8987
|
+
* a meaningful prefix.
|
|
8988
|
+
*
|
|
8989
|
+
* Conventions:
|
|
8990
|
+
* - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
|
|
8991
|
+
* output, command exit, doctor severity, footer message kinds.
|
|
8992
|
+
* Colour-coded variants live alongside as `*_COLORED` helpers
|
|
8993
|
+
* so callers can use either depending on context.
|
|
8994
|
+
* - Action glyphs (BULLET, ARROW) — for indented hint lines and
|
|
8995
|
+
* "next step" callouts.
|
|
8996
|
+
* - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
|
|
8997
|
+
* vocabularies (PR reviews, status checks) because their
|
|
8998
|
+
* semantic shape doesn't map cleanly onto pass/fail/warn/info.
|
|
8999
|
+
*
|
|
9000
|
+
* Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
|
|
9001
|
+
* `theme.ascii` mode in a single call site.
|
|
9002
|
+
*/
|
|
9003
|
+
/**
|
|
9004
|
+
* Status-severity glyph set. Same vocabulary as the workstation
|
|
9005
|
+
* footer's `kind` field (info / warning / error / success / loading)
|
|
9006
|
+
* plus `pass` for the doctor / "no problem" case.
|
|
9007
|
+
*/
|
|
9008
|
+
const GLYPHS = {
|
|
9009
|
+
pass: '✓',
|
|
9010
|
+
fail: '✖',
|
|
9011
|
+
warn: '⚠',
|
|
9012
|
+
info: 'ℹ',
|
|
9013
|
+
bullet: '•'};
|
|
9014
|
+
/**
|
|
9015
|
+
* Theme-tinted helpers for terminal output. These return chalk-wrapped
|
|
9016
|
+
* strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
|
|
9017
|
+
* pattern. Each maps to the canonical colour the codebase uses for
|
|
9018
|
+
* that severity:
|
|
9019
|
+
*
|
|
9020
|
+
* - PASS → green
|
|
9021
|
+
* - FAIL → red
|
|
9022
|
+
* - WARN → yellow
|
|
9023
|
+
* - INFO → blue
|
|
9024
|
+
*
|
|
9025
|
+
* Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
|
|
9026
|
+
* delegates here so the colours stay in sync if the theme palette
|
|
9027
|
+
* shifts in the future.
|
|
9028
|
+
*/
|
|
9029
|
+
const PASS = () => chalk.green(GLYPHS.pass);
|
|
9030
|
+
const FAIL = () => chalk.red(GLYPHS.fail);
|
|
9031
|
+
const WARN = () => chalk.yellow(GLYPHS.warn);
|
|
9032
|
+
const INFO = () => chalk.blue(GLYPHS.info);
|
|
9033
|
+
|
|
9034
|
+
/**
|
|
9035
|
+
* Maps each provider to the env var users should set + the kebab-case
|
|
9036
|
+
* provider label used in the recovery copy. `coco init` and `coco
|
|
9037
|
+
* doctor` both reference these names; keeping the lookup in one place
|
|
9038
|
+
* makes the messages stay aligned when a new provider lands.
|
|
9039
|
+
*/
|
|
9040
|
+
const PROVIDER_ENV_VARS = {
|
|
9041
|
+
openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
|
|
9042
|
+
anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
|
|
9043
|
+
ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
|
|
9044
|
+
'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
|
|
9045
|
+
};
|
|
9046
|
+
/**
|
|
9047
|
+
* Print a structured "missing API key" message + exit non-zero.
|
|
9048
|
+
*
|
|
9049
|
+
* Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
|
|
9050
|
+
* inline in commit / changelog / recap / review handlers. Centralised
|
|
9051
|
+
* because:
|
|
9052
|
+
*
|
|
9053
|
+
* 1. The message names the env var the user actually needs to set
|
|
9054
|
+
* (different per provider) — that was the single biggest gap in
|
|
9055
|
+
* the prior message.
|
|
9056
|
+
* 2. It surfaces the configured provider + model so the user can tell
|
|
9057
|
+
* which of their providers tripped the check (useful when running
|
|
9058
|
+
* with dynamic model routing).
|
|
9059
|
+
* 3. It points at `coco init` and `coco doctor` as the recovery
|
|
9060
|
+
* paths, mirroring the discoverability cue every other modern CLI
|
|
9061
|
+
* uses for first-run config errors.
|
|
9062
|
+
*
|
|
9063
|
+
* Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
|
|
9064
|
+
* to handle the return value.
|
|
9065
|
+
*/
|
|
9066
|
+
function handleMissingApiKey(logger, config, options) {
|
|
9067
|
+
const provider = config.service?.provider || 'unknown';
|
|
9068
|
+
const model = config.service?.model || 'unknown';
|
|
9069
|
+
const providerInfo = PROVIDER_ENV_VARS[provider] || {
|
|
9070
|
+
envVar: 'PROVIDER_API_KEY',
|
|
9071
|
+
label: provider,
|
|
9072
|
+
};
|
|
9073
|
+
const lines = [
|
|
9074
|
+
`${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
|
|
9075
|
+
'',
|
|
9076
|
+
`${chalk.bold('Next step')} — set up an API key one of these ways:`,
|
|
9077
|
+
` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
|
|
9078
|
+
` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
|
|
9079
|
+
` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
|
|
9080
|
+
'',
|
|
9081
|
+
`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
|
|
9082
|
+
];
|
|
9083
|
+
for (const line of lines) {
|
|
9084
|
+
logger.log(line);
|
|
9085
|
+
}
|
|
9086
|
+
// Tag the exit message with the failing command so process supervisors
|
|
9087
|
+
// / CI logs can grep for it without parsing the full body.
|
|
9088
|
+
commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
|
|
9089
|
+
}
|
|
9090
|
+
|
|
8930
9091
|
const logSuccess = () => {
|
|
8931
9092
|
console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
|
|
8932
9093
|
};
|
|
@@ -14419,8 +14580,7 @@ const handler$a = async (argv, logger) => {
|
|
|
14419
14580
|
commandExit(1);
|
|
14420
14581
|
}
|
|
14421
14582
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
14422
|
-
logger
|
|
14423
|
-
commandExit(1);
|
|
14583
|
+
handleMissingApiKey(logger, config, { command: 'changelog' });
|
|
14424
14584
|
}
|
|
14425
14585
|
const llm = getLlm(provider, model, { ...config, service: changelogService });
|
|
14426
14586
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
@@ -16316,8 +16476,7 @@ const handler$9 = async (argv, logger) => {
|
|
|
16316
16476
|
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
16317
16477
|
const model = commitService.model;
|
|
16318
16478
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
16319
|
-
logger
|
|
16320
|
-
commandExit(1);
|
|
16479
|
+
handleMissingApiKey(logger, config, { command: 'commit' });
|
|
16321
16480
|
}
|
|
16322
16481
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
16323
16482
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
@@ -17047,9 +17206,9 @@ function checkProjectConfigFile(diagnostics) {
|
|
|
17047
17206
|
}
|
|
17048
17207
|
|
|
17049
17208
|
const SEVERITY_ICON = {
|
|
17050
|
-
error:
|
|
17051
|
-
warn:
|
|
17052
|
-
info:
|
|
17209
|
+
error: FAIL(),
|
|
17210
|
+
warn: WARN(),
|
|
17211
|
+
info: INFO(),
|
|
17053
17212
|
};
|
|
17054
17213
|
const SEVERITY_LABEL = {
|
|
17055
17214
|
error: chalk.red('error'),
|
|
@@ -17068,10 +17227,10 @@ function formatSourceInfo(sources) {
|
|
|
17068
17227
|
for (const source of sources) {
|
|
17069
17228
|
const label = SOURCE_LABELS[source.source] || source.source;
|
|
17070
17229
|
if (source.path) {
|
|
17071
|
-
lines.push(` ${
|
|
17230
|
+
lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
|
|
17072
17231
|
}
|
|
17073
17232
|
else {
|
|
17074
|
-
lines.push(` ${
|
|
17233
|
+
lines.push(` ${PASS()} ${label}`);
|
|
17075
17234
|
}
|
|
17076
17235
|
}
|
|
17077
17236
|
return lines;
|
|
@@ -17108,7 +17267,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17108
17267
|
// Run diagnostics
|
|
17109
17268
|
const diagnostics = runDiagnostics(config);
|
|
17110
17269
|
if (diagnostics.length === 0) {
|
|
17111
|
-
logger.log(chalk.green(
|
|
17270
|
+
logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
|
|
17112
17271
|
return;
|
|
17113
17272
|
}
|
|
17114
17273
|
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
@@ -17161,7 +17320,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17161
17320
|
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
17162
17321
|
for (const diagnostic of fixable) {
|
|
17163
17322
|
diagnostic.autoFix(raw);
|
|
17164
|
-
logger.log(chalk.green(`
|
|
17323
|
+
logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
|
|
17165
17324
|
}
|
|
17166
17325
|
// Ensure $schema is present
|
|
17167
17326
|
if (!raw.$schema) {
|
|
@@ -17180,6 +17339,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
17180
17339
|
logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
|
|
17181
17340
|
}
|
|
17182
17341
|
}
|
|
17342
|
+
// Exit non-zero when error-severity diagnostics were surfaced so CI
|
|
17343
|
+
// pipelines can gate on `coco doctor` without parsing its stdout.
|
|
17344
|
+
// Warnings + infos still exit clean — they're informational, not
|
|
17345
|
+
// blockers. Auto-fixed errors keep the non-zero exit so the CI run
|
|
17346
|
+
// surfaces "we patched something for you, please commit it" rather
|
|
17347
|
+
// than masquerading as a passing check.
|
|
17348
|
+
if (errors.length > 0) {
|
|
17349
|
+
commandExit(1, `${errors.length} doctor error(s)`);
|
|
17350
|
+
}
|
|
17183
17351
|
};
|
|
17184
17352
|
|
|
17185
17353
|
var doctor = {
|
|
@@ -17187,6 +17355,7 @@ var doctor = {
|
|
|
17187
17355
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
17188
17356
|
builder: builder$8,
|
|
17189
17357
|
handler: commandExecutor(handler$8),
|
|
17358
|
+
options: options$8,
|
|
17190
17359
|
};
|
|
17191
17360
|
|
|
17192
17361
|
const command$7 = 'init';
|
|
@@ -17566,11 +17735,7 @@ const handler$7 = async (argv, logger) => {
|
|
|
17566
17735
|
// writes the project config to X, not the launcher's cwd. The
|
|
17567
17736
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
17568
17737
|
// its target path (it reads process.cwd).
|
|
17569
|
-
|
|
17570
|
-
// `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
|
|
17571
|
-
// union including Promise — pass just the `repo` field as a plain
|
|
17572
|
-
// object so the helper's narrow signature stays clean.
|
|
17573
|
-
applyRepoCwd({ repo: argv.repo });
|
|
17738
|
+
applyRepoCwd(argv);
|
|
17574
17739
|
const options = loadConfig(argv);
|
|
17575
17740
|
logger.log(LOGO);
|
|
17576
17741
|
let scope = options?.scope;
|
|
@@ -17705,6 +17870,44 @@ const handler$7 = async (argv, logger) => {
|
|
|
17705
17870
|
await installCommitlintPackages(scope, logger);
|
|
17706
17871
|
}
|
|
17707
17872
|
logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
|
|
17873
|
+
// Post-write verification — run the same check `coco doctor` runs
|
|
17874
|
+
// so the user finds out about typos / structural issues now,
|
|
17875
|
+
// before their first `coco commit`. Re-load from disk so we
|
|
17876
|
+
// verify the persisted config (not the in-memory shape we just
|
|
17877
|
+
// built), which catches transcription bugs in the appenders.
|
|
17878
|
+
try {
|
|
17879
|
+
const persistedConfig = loadConfig({});
|
|
17880
|
+
const diagnostics = runDiagnostics(persistedConfig);
|
|
17881
|
+
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
17882
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warn');
|
|
17883
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
17884
|
+
logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
|
|
17885
|
+
}
|
|
17886
|
+
else {
|
|
17887
|
+
if (errors.length > 0) {
|
|
17888
|
+
logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
|
|
17889
|
+
for (const diagnostic of errors) {
|
|
17890
|
+
logger.log(` ${chalk.red(diagnostic.message)}`);
|
|
17891
|
+
}
|
|
17892
|
+
}
|
|
17893
|
+
if (warnings.length > 0) {
|
|
17894
|
+
logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
|
|
17895
|
+
for (const diagnostic of warnings) {
|
|
17896
|
+
logger.log(` ${chalk.yellow(diagnostic.message)}`);
|
|
17897
|
+
}
|
|
17898
|
+
}
|
|
17899
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
|
|
17900
|
+
}
|
|
17901
|
+
}
|
|
17902
|
+
catch (verifyError) {
|
|
17903
|
+
// Verification is a polish step, not a blocker. If it crashes
|
|
17904
|
+
// (e.g. config file written to a path the loader can't reach
|
|
17905
|
+
// from the current cwd), fall through to a hint instead of
|
|
17906
|
+
// failing the whole init flow — the config is on disk and
|
|
17907
|
+
// the user can run `coco doctor` themselves.
|
|
17908
|
+
logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
|
|
17909
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
|
|
17910
|
+
}
|
|
17708
17911
|
}
|
|
17709
17912
|
else {
|
|
17710
17913
|
logger.log('\ninit cancelled.', { color: 'yellow' });
|
|
@@ -17746,7 +17949,7 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17746
17949
|
|
|
17747
17950
|
var init = {
|
|
17748
17951
|
command: command$7,
|
|
17749
|
-
desc: '
|
|
17952
|
+
desc: 'Install & configure coco globally or for the current project',
|
|
17750
17953
|
builder: builder$7,
|
|
17751
17954
|
handler: commandExecutor(handler$7),
|
|
17752
17955
|
options: options$7,
|
|
@@ -17830,19 +18033,76 @@ async function getGitHubRepository(git) {
|
|
|
17830
18033
|
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17831
18034
|
}
|
|
17832
18035
|
/**
|
|
17833
|
-
* Probe `gh auth status` and return
|
|
17834
|
-
*
|
|
17835
|
-
* circuit before issuing real API calls —
|
|
17836
|
-
*
|
|
17837
|
-
* of
|
|
18036
|
+
* Probe `gh auth status` and return a structured status describing
|
|
18037
|
+
* exactly which of the failure modes is in play. Used by every data
|
|
18038
|
+
* fetcher to short-circuit before issuing real API calls — and now
|
|
18039
|
+
* lets the caller surface a tailored recovery hint per failure mode
|
|
18040
|
+
* instead of one catch-all message.
|
|
18041
|
+
*
|
|
18042
|
+
* Distinguishing the modes:
|
|
18043
|
+
* - ENOENT (`gh: command not found`) → `not-installed`
|
|
18044
|
+
* - `gh auth status` exits non-zero with stderr matching the
|
|
18045
|
+
* "not logged into" / "authentication required" pattern →
|
|
18046
|
+
* `not-authenticated`
|
|
18047
|
+
* - Anything else (permission denied on the binary, timeout, etc.)
|
|
18048
|
+
* → `unknown` with the underlying error message attached for
|
|
18049
|
+
* diagnostic display.
|
|
17838
18050
|
*/
|
|
17839
|
-
async function
|
|
18051
|
+
async function getGhStatus(runner) {
|
|
17840
18052
|
try {
|
|
17841
18053
|
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17842
|
-
return
|
|
18054
|
+
return { kind: 'ok' };
|
|
17843
18055
|
}
|
|
17844
|
-
catch {
|
|
17845
|
-
|
|
18056
|
+
catch (error) {
|
|
18057
|
+
const err = error;
|
|
18058
|
+
// ENOENT = the binary itself is missing. exec/spawn surfaces this
|
|
18059
|
+
// as either `code === 'ENOENT'` (Node's spawn error code) or a
|
|
18060
|
+
// message containing "ENOENT". Either form is unambiguous.
|
|
18061
|
+
if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
|
|
18062
|
+
return { kind: 'not-installed' };
|
|
18063
|
+
}
|
|
18064
|
+
// gh exits non-zero from `auth status` when the user isn't logged
|
|
18065
|
+
// in. The message body contains "not logged into" or "logged in
|
|
18066
|
+
// failed" depending on the gh version. Both patterns are stable
|
|
18067
|
+
// enough to gate on without scope-locking to a specific gh
|
|
18068
|
+
// release.
|
|
18069
|
+
const stderr = err.stderr || err.message || '';
|
|
18070
|
+
if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
|
|
18071
|
+
return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
|
|
18072
|
+
}
|
|
18073
|
+
// Anything else — permission denied, timeout, etc. Surface the
|
|
18074
|
+
// raw message so the user can read it; treat as unavailable.
|
|
18075
|
+
return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
|
|
18076
|
+
}
|
|
18077
|
+
}
|
|
18078
|
+
/**
|
|
18079
|
+
* Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
|
|
18080
|
+
* existing callers (data loaders, sidebar fetchers) don't all have to
|
|
18081
|
+
* migrate at once. New call sites should use `getGhStatus` directly
|
|
18082
|
+
* to access the discriminated failure modes.
|
|
18083
|
+
*/
|
|
18084
|
+
async function isGhAuthenticated(runner) {
|
|
18085
|
+
const status = await getGhStatus(runner);
|
|
18086
|
+
return status.kind === 'ok';
|
|
18087
|
+
}
|
|
18088
|
+
/**
|
|
18089
|
+
* Render a user-facing recovery hint for a non-`ok` gh status. Used by
|
|
18090
|
+
* `commands/issues` / `commands/prs` / pull-request workflow surfaces
|
|
18091
|
+
* so every "gh is unavailable" message tells the user the exact next
|
|
18092
|
+
* step. Keeps the wording in sync across surfaces — if a user runs
|
|
18093
|
+
* `coco prs` and `coco issues` back to back, the same broken state
|
|
18094
|
+
* surfaces the same fix.
|
|
18095
|
+
*/
|
|
18096
|
+
function describeGhStatus(status) {
|
|
18097
|
+
switch (status.kind) {
|
|
18098
|
+
case 'ok':
|
|
18099
|
+
return 'GitHub CLI is installed and authenticated.';
|
|
18100
|
+
case 'not-installed':
|
|
18101
|
+
return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
|
|
18102
|
+
case 'not-authenticated':
|
|
18103
|
+
return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
|
|
18104
|
+
case 'unknown':
|
|
18105
|
+
return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
|
|
17846
18106
|
}
|
|
17847
18107
|
}
|
|
17848
18108
|
|
|
@@ -18034,13 +18294,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
18034
18294
|
message: 'No GitHub remote detected.',
|
|
18035
18295
|
};
|
|
18036
18296
|
}
|
|
18037
|
-
|
|
18297
|
+
const ghStatus = await getGhStatus(runner);
|
|
18298
|
+
if (ghStatus.kind !== 'ok') {
|
|
18038
18299
|
return {
|
|
18039
18300
|
available: true,
|
|
18040
18301
|
authenticated: false,
|
|
18041
18302
|
repository,
|
|
18042
18303
|
filter,
|
|
18043
|
-
message:
|
|
18304
|
+
message: describeGhStatus(ghStatus),
|
|
18044
18305
|
};
|
|
18045
18306
|
}
|
|
18046
18307
|
try {
|
|
@@ -18152,6 +18413,7 @@ var issues = {
|
|
|
18152
18413
|
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
18153
18414
|
builder: builder$6,
|
|
18154
18415
|
handler: commandExecutor(handler$6),
|
|
18416
|
+
options: options$6,
|
|
18155
18417
|
};
|
|
18156
18418
|
|
|
18157
18419
|
const command$5 = 'log';
|
|
@@ -22171,6 +22433,18 @@ function getLogInkWorkflowActions() {
|
|
|
22171
22433
|
kind: 'destructive',
|
|
22172
22434
|
requiresConfirmation: true,
|
|
22173
22435
|
},
|
|
22436
|
+
{
|
|
22437
|
+
// No key binding — this is raised by the runtime as a second
|
|
22438
|
+
// confirmation when a safe `delete-branch` (`git branch -d`) is
|
|
22439
|
+
// rejected for an unmerged branch. Reachable from the `:` palette
|
|
22440
|
+
// too, as an explicit force-delete that still gates on y-confirm.
|
|
22441
|
+
id: 'force-delete-branch',
|
|
22442
|
+
key: '',
|
|
22443
|
+
label: 'Force-delete branch',
|
|
22444
|
+
description: 'Force-delete the selected branch even if it is not fully merged (git branch -D).',
|
|
22445
|
+
kind: 'destructive',
|
|
22446
|
+
requiresConfirmation: true,
|
|
22447
|
+
},
|
|
22174
22448
|
{
|
|
22175
22449
|
id: 'delete-tag',
|
|
22176
22450
|
key: 'T',
|
|
@@ -22968,6 +23242,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22968
23242
|
description: 'Create a lightweight tag at the cursored commit.',
|
|
22969
23243
|
contexts: ['history'],
|
|
22970
23244
|
},
|
|
23245
|
+
{
|
|
23246
|
+
id: 'viewKeys',
|
|
23247
|
+
keys: ['g?'],
|
|
23248
|
+
label: 'keys',
|
|
23249
|
+
description: 'Show the single-key actions available in the current view (which-key strip).',
|
|
23250
|
+
contexts: ['normal'],
|
|
23251
|
+
},
|
|
22971
23252
|
{
|
|
22972
23253
|
id: 'themePicker',
|
|
22973
23254
|
keys: ['gC'],
|
|
@@ -23695,6 +23976,48 @@ function getLogInkHelpSections(options) {
|
|
|
23695
23976
|
},
|
|
23696
23977
|
];
|
|
23697
23978
|
}
|
|
23979
|
+
/**
|
|
23980
|
+
* True when a key string is a single, bare printable key (e.g. `c`, `R`,
|
|
23981
|
+
* `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
|
|
23982
|
+
* `page down`). Used by the which-key view-keys strip, which surfaces only
|
|
23983
|
+
* the single-key overloads — the chord set already has its own overlay.
|
|
23984
|
+
*/
|
|
23985
|
+
function isBareSingleKey(key) {
|
|
23986
|
+
return key.length === 1 && key !== ' ';
|
|
23987
|
+
}
|
|
23988
|
+
/**
|
|
23989
|
+
* Single-key bindings available in the current view (#1137). Powers the
|
|
23990
|
+
* `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
|
|
23991
|
+
*
|
|
23992
|
+
* Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
|
|
23993
|
+
* filtered the same way the help overlay's "This view" section is — by
|
|
23994
|
+
* `contexts` against the active view + focus — then narrowed to bindings
|
|
23995
|
+
* that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
|
|
23996
|
+
* are excluded: they're always available and already live in the footer and
|
|
23997
|
+
* onboarding tour, so the strip stays focused on the deliberate per-view
|
|
23998
|
+
* overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
|
|
23999
|
+
*
|
|
24000
|
+
* Sorted by the first bare key for stable, scannable output.
|
|
24001
|
+
*/
|
|
24002
|
+
function getLogInkViewKeyBindings(options) {
|
|
24003
|
+
return LOG_INK_KEY_BINDINGS
|
|
24004
|
+
.filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
|
|
24005
|
+
bindingMatchesViewContext(binding, options) &&
|
|
24006
|
+
binding.keys.some(isBareSingleKey))
|
|
24007
|
+
.sort((a, b) => {
|
|
24008
|
+
const aKey = a.keys.find(isBareSingleKey) ?? '';
|
|
24009
|
+
const bKey = b.keys.find(isBareSingleKey) ?? '';
|
|
24010
|
+
return aKey.localeCompare(bKey);
|
|
24011
|
+
});
|
|
24012
|
+
}
|
|
24013
|
+
/**
|
|
24014
|
+
* Format only the bare single keys of a binding for the view-keys strip
|
|
24015
|
+
* (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
|
|
24016
|
+
* about the single-key affordance, and the full key list lives in `?` help.
|
|
24017
|
+
*/
|
|
24018
|
+
function formatBindingBareKeys(binding) {
|
|
24019
|
+
return binding.keys.filter(isBareSingleKey).join(' / ');
|
|
24020
|
+
}
|
|
23698
24021
|
function bindingToPaletteCommand(binding) {
|
|
23699
24022
|
return {
|
|
23700
24023
|
id: binding.id,
|
|
@@ -24838,6 +25161,15 @@ function formatSortIndicator(mode, options = {}) {
|
|
|
24838
25161
|
return `${options.ascii ? 'v' : '▼'} ${mode}`;
|
|
24839
25162
|
}
|
|
24840
25163
|
|
|
25164
|
+
/**
|
|
25165
|
+
* True when `pending` (a `state.pendingDeletion`) targets this exact row.
|
|
25166
|
+
* Shared by every deletable surface + the sidebar so the spinner-swap
|
|
25167
|
+
* test is identical everywhere. Takes the field value (not the whole
|
|
25168
|
+
* state) so it can live next to the type without a forward reference.
|
|
25169
|
+
*/
|
|
25170
|
+
function isPendingDeletion(pending, kind, id) {
|
|
25171
|
+
return pending?.kind === kind && pending.id === id;
|
|
25172
|
+
}
|
|
24841
25173
|
const DEFAULT_CHANGELOG_VIEW_STATE = {
|
|
24842
25174
|
status: 'idle',
|
|
24843
25175
|
scrollOffset: 0,
|
|
@@ -25208,7 +25540,39 @@ function replaceRows(state, rows) {
|
|
|
25208
25540
|
}
|
|
25209
25541
|
function appendRows(state, rows) {
|
|
25210
25542
|
const selected = getSelectedInkCommit(state);
|
|
25211
|
-
|
|
25543
|
+
// Dedup the merged row list by commit hash so the graph renderer —
|
|
25544
|
+
// which windows directly over `state.rows` (toFullGraphItems →
|
|
25545
|
+
// expandRowsWithSpacers) — and the selection list (deduped commits)
|
|
25546
|
+
// agree on one canonical, duplicate-free row order. Overlapping
|
|
25547
|
+
// appends, notably the anchored `loadCommitContext` page that
|
|
25548
|
+
// re-walks history from the tip, otherwise stack the newest commits
|
|
25549
|
+
// below the oldest ones already loaded. The renderer then shows the
|
|
25550
|
+
// initial commit directly above HEAD and the cursor can scroll
|
|
25551
|
+
// forever through the duplicated tail — the history graph "looping
|
|
25552
|
+
// back on itself". Drop graph-only topology rows that trail a dropped
|
|
25553
|
+
// duplicate commit too, since they describe that duplicate's lanes
|
|
25554
|
+
// and would otherwise dangle.
|
|
25555
|
+
const seenHashes = new Set();
|
|
25556
|
+
const nextRows = [];
|
|
25557
|
+
let droppingTrailingGraph = false;
|
|
25558
|
+
for (const row of [...state.rows, ...rows]) {
|
|
25559
|
+
if (row.type === 'commit') {
|
|
25560
|
+
if (seenHashes.has(row.hash)) {
|
|
25561
|
+
droppingTrailingGraph = true;
|
|
25562
|
+
continue;
|
|
25563
|
+
}
|
|
25564
|
+
seenHashes.add(row.hash);
|
|
25565
|
+
droppingTrailingGraph = false;
|
|
25566
|
+
nextRows.push(row);
|
|
25567
|
+
continue;
|
|
25568
|
+
}
|
|
25569
|
+
// Graph-only topology row: keep it unless it trails a just-dropped
|
|
25570
|
+
// duplicate commit (then it belongs to the duplicate page's lanes).
|
|
25571
|
+
if (droppingTrailingGraph) {
|
|
25572
|
+
continue;
|
|
25573
|
+
}
|
|
25574
|
+
nextRows.push(row);
|
|
25575
|
+
}
|
|
25212
25576
|
const seen = new Set();
|
|
25213
25577
|
const commits = getCommitRows(nextRows).filter((commit) => {
|
|
25214
25578
|
if (seen.has(commit.hash)) {
|
|
@@ -25300,6 +25664,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
25300
25664
|
fullGraph: options.fullGraph ?? true,
|
|
25301
25665
|
showHelp: false,
|
|
25302
25666
|
helpScrollOffset: 0,
|
|
25667
|
+
showViewKeys: false,
|
|
25303
25668
|
showCommandPalette: false,
|
|
25304
25669
|
workflowActionId: undefined,
|
|
25305
25670
|
pendingConfirmationId: undefined,
|
|
@@ -25805,6 +26170,22 @@ function applyLogInkAction(state, action) {
|
|
|
25805
26170
|
pendingKey: undefined,
|
|
25806
26171
|
};
|
|
25807
26172
|
}
|
|
26173
|
+
case 'returnFromCommit': {
|
|
26174
|
+
// After a successful commit we leave the compose view automatically.
|
|
26175
|
+
// Where to: a still-dirty tree the user was staging from returns to
|
|
26176
|
+
// the Status view so they can finish the rest; an otherwise-complete
|
|
26177
|
+
// commit returns to the History view, where the new commit now shows.
|
|
26178
|
+
// We pop frames one at a time (reusing withPoppedView) so sidebar-tab
|
|
26179
|
+
// and diff-state restoration stays identical to manual Esc/back —
|
|
26180
|
+
// this also unwinds an intermediate `diff` frame (status → diff →
|
|
26181
|
+
// compose) back to the status frame it sits under.
|
|
26182
|
+
const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
|
|
26183
|
+
let next = state;
|
|
26184
|
+
while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
|
|
26185
|
+
next = withPoppedView(next);
|
|
26186
|
+
}
|
|
26187
|
+
return { ...next, pendingKey: undefined };
|
|
26188
|
+
}
|
|
25808
26189
|
case 'navigateOpenDiffForCommit': {
|
|
25809
26190
|
const next = withPushedView(state, 'diff');
|
|
25810
26191
|
const filteredCommits = state.filteredCommits;
|
|
@@ -25994,6 +26375,10 @@ function applyLogInkAction(state, action) {
|
|
|
25994
26375
|
workflowActionId: action.value ? undefined : state.workflowActionId,
|
|
25995
26376
|
pendingKey: undefined,
|
|
25996
26377
|
};
|
|
26378
|
+
case 'setPendingDeletion':
|
|
26379
|
+
// Pure marker for the in-flight delete; touches nothing else so the
|
|
26380
|
+
// list keeps rendering normally underneath the one spinner'd row.
|
|
26381
|
+
return { ...state, pendingDeletion: action.value };
|
|
25997
26382
|
case 'toggleFilterMode':
|
|
25998
26383
|
return {
|
|
25999
26384
|
...state,
|
|
@@ -26001,6 +26386,7 @@ function applyLogInkAction(state, action) {
|
|
|
26001
26386
|
showCommandPalette: false,
|
|
26002
26387
|
showHelp: false,
|
|
26003
26388
|
helpScrollOffset: 0,
|
|
26389
|
+
showViewKeys: false,
|
|
26004
26390
|
pendingKey: undefined,
|
|
26005
26391
|
};
|
|
26006
26392
|
case 'toggleGraph':
|
|
@@ -26019,9 +26405,24 @@ function applyLogInkAction(state, action) {
|
|
|
26019
26405
|
// than picking up where the user last scrolled.
|
|
26020
26406
|
helpScrollOffset: 0,
|
|
26021
26407
|
showCommandPalette: false,
|
|
26408
|
+
// Opening full help supersedes the compact view-keys strip — this
|
|
26409
|
+
// is the progressive-disclosure step (`?` from the strip expands
|
|
26410
|
+
// to the full categorized help, #1137).
|
|
26411
|
+
showViewKeys: false,
|
|
26022
26412
|
pendingKey: undefined,
|
|
26023
26413
|
};
|
|
26024
26414
|
}
|
|
26415
|
+
case 'toggleViewKeys':
|
|
26416
|
+
return {
|
|
26417
|
+
...state,
|
|
26418
|
+
showViewKeys: !state.showViewKeys,
|
|
26419
|
+
// The view-keys strip is mutually exclusive with the other
|
|
26420
|
+
// overlays; opening it closes anything else that was showing.
|
|
26421
|
+
showHelp: false,
|
|
26422
|
+
helpScrollOffset: 0,
|
|
26423
|
+
showCommandPalette: false,
|
|
26424
|
+
pendingKey: undefined,
|
|
26425
|
+
};
|
|
26025
26426
|
case 'scrollHelp':
|
|
26026
26427
|
// No upper-bound clamp here — the renderer caps the offset
|
|
26027
26428
|
// against the actual content height at render time. The
|
|
@@ -26038,6 +26439,7 @@ function applyLogInkAction(state, action) {
|
|
|
26038
26439
|
showCommandPalette: opening,
|
|
26039
26440
|
showHelp: false,
|
|
26040
26441
|
helpScrollOffset: 0,
|
|
26442
|
+
showViewKeys: false,
|
|
26041
26443
|
// Reset palette interaction state on every open/close so the next
|
|
26042
26444
|
// session starts from a clean slate.
|
|
26043
26445
|
paletteFilter: '',
|
|
@@ -26085,8 +26487,9 @@ function applyLogInkAction(state, action) {
|
|
|
26085
26487
|
return {
|
|
26086
26488
|
...state,
|
|
26087
26489
|
showThemePicker: opening,
|
|
26088
|
-
// Only one overlay at a time — close help / palette on open.
|
|
26490
|
+
// Only one overlay at a time — close help / palette / view-keys on open.
|
|
26089
26491
|
showHelp: false,
|
|
26492
|
+
showViewKeys: false,
|
|
26090
26493
|
showCommandPalette: false,
|
|
26091
26494
|
themePickerFilter: '',
|
|
26092
26495
|
themePickerIndex: 0,
|
|
@@ -26886,6 +27289,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26886
27289
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26887
27290
|
// this opens the theme picker.
|
|
26888
27291
|
return [action({ type: 'toggleThemePicker' })];
|
|
27292
|
+
case 'viewKeys':
|
|
27293
|
+
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
27294
|
+
// this opens the per-view which-key strip (#1137).
|
|
27295
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
26889
27296
|
case 'openProjectConfig':
|
|
26890
27297
|
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26891
27298
|
case 'openGlobalConfig':
|
|
@@ -27614,6 +28021,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27614
28021
|
}
|
|
27615
28022
|
return [];
|
|
27616
28023
|
}
|
|
28024
|
+
// #1137 — the `g?` which-key strip. While it's open the keyboard is
|
|
28025
|
+
// claimed (mirrors the help overlay) so a stray keystroke can't drop
|
|
28026
|
+
// the user into a per-view action they didn't mean to trigger. Esc
|
|
28027
|
+
// closes; `?` is the progressive-disclosure step up to the full
|
|
28028
|
+
// categorized help; `q` still quits. Everything else is swallowed —
|
|
28029
|
+
// the user peeks, dismisses, then presses the key they came for.
|
|
28030
|
+
if (state.showViewKeys) {
|
|
28031
|
+
if (key.escape) {
|
|
28032
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
28033
|
+
}
|
|
28034
|
+
if (inputValue === '?') {
|
|
28035
|
+
// Expand the compact strip into the full help overlay. `toggleHelp`
|
|
28036
|
+
// clears `showViewKeys` so the two never render at once.
|
|
28037
|
+
return [action({ type: 'toggleHelp' })];
|
|
28038
|
+
}
|
|
28039
|
+
if (inputValue === 'q') {
|
|
28040
|
+
return [{ type: 'exit' }];
|
|
28041
|
+
}
|
|
28042
|
+
return [];
|
|
28043
|
+
}
|
|
27617
28044
|
// #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
|
|
27618
28045
|
// BEFORE the generic `popView` so we both clear the wizard state
|
|
27619
28046
|
// and walk back to the bisect view in one keystroke. Without this
|
|
@@ -27657,6 +28084,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27657
28084
|
}
|
|
27658
28085
|
return [{ type: 'exit' }];
|
|
27659
28086
|
}
|
|
28087
|
+
// `g?` chord (#1137) — open the per-view which-key strip. Placed
|
|
28088
|
+
// BEFORE the bare `?` (full help) check below so the chord is read as
|
|
28089
|
+
// a unit: with `g` pending, `?` opens the view-keys strip rather than
|
|
28090
|
+
// toggling full help. Surfaces automatically in the `g` which-key menu
|
|
28091
|
+
// because its key is a two-char `g`-prefixed binding.
|
|
28092
|
+
if (state.pendingKey === 'g' && inputValue === '?') {
|
|
28093
|
+
return [
|
|
28094
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
28095
|
+
action({ type: 'toggleViewKeys' }),
|
|
28096
|
+
];
|
|
28097
|
+
}
|
|
27660
28098
|
if (inputValue === '?') {
|
|
27661
28099
|
return [action({ type: 'toggleHelp' })];
|
|
27662
28100
|
}
|
|
@@ -29565,6 +30003,24 @@ const SPINNER_TICK_MS = 80;
|
|
|
29565
30003
|
function pickSpinnerFrame(tick) {
|
|
29566
30004
|
return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
|
|
29567
30005
|
}
|
|
30006
|
+
/**
|
|
30007
|
+
* ASCII-safe spinner frames for `NO_COLOR` / ASCII terminals where the
|
|
30008
|
+
* braille dots either don't render or look like noise. The four-frame
|
|
30009
|
+
* `|/-\` cycle is the classic terminal spinner and reads as motion in
|
|
30010
|
+
* any encoding.
|
|
30011
|
+
*/
|
|
30012
|
+
const ASCII_SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
30013
|
+
/**
|
|
30014
|
+
* Inline per-item pending glyph — used in place of (or appended to) a
|
|
30015
|
+
* list row's status icon while that row's mutation (a delete) is in
|
|
30016
|
+
* flight. Braille spinner normally; the ASCII cycle under `ascii`
|
|
30017
|
+
* themes so the indicator survives `NO_COLOR` / dumb terminals.
|
|
30018
|
+
*/
|
|
30019
|
+
function inlineSpinnerGlyph(tick, ascii) {
|
|
30020
|
+
return ascii
|
|
30021
|
+
? ASCII_SPINNER_FRAMES[Math.max(0, tick) % ASCII_SPINNER_FRAMES.length]
|
|
30022
|
+
: pickSpinnerFrame(tick);
|
|
30023
|
+
}
|
|
29568
30024
|
|
|
29569
30025
|
/**
|
|
29570
30026
|
* Build the initial `LogInkContextStatus` for a freshly-created frame
|
|
@@ -30519,7 +30975,7 @@ function createBranch(git, branchName, startPoint) {
|
|
|
30519
30975
|
function renameBranch(git, oldName, newName) {
|
|
30520
30976
|
return runAction$5(() => git.raw(['branch', '-m', oldName, newName]), `Renamed ${oldName} to ${newName}`);
|
|
30521
30977
|
}
|
|
30522
|
-
function deleteBranch(git, branch) {
|
|
30978
|
+
function deleteBranch(git, branch, force = false) {
|
|
30523
30979
|
if (branch.type !== 'local') {
|
|
30524
30980
|
return Promise.resolve({
|
|
30525
30981
|
ok: false,
|
|
@@ -30532,7 +30988,18 @@ function deleteBranch(git, branch) {
|
|
|
30532
30988
|
message: 'Cannot delete the current branch.',
|
|
30533
30989
|
});
|
|
30534
30990
|
}
|
|
30535
|
-
|
|
30991
|
+
// `-d` is the safe delete (refuses unmerged branches); `-D` forces it.
|
|
30992
|
+
// The TUI starts with `-d` and only escalates to `-D` after the user
|
|
30993
|
+
// confirms a second time on the "not fully merged" error.
|
|
30994
|
+
return runAction$5(() => git.raw(['branch', force ? '-D' : '-d', branch.shortName]), force ? `Force-deleted branch ${branch.shortName}` : `Deleted branch ${branch.shortName}`);
|
|
30995
|
+
}
|
|
30996
|
+
/**
|
|
30997
|
+
* True when a failed `git branch -d` was rejected specifically because the
|
|
30998
|
+
* branch isn't fully merged (the one case worth offering a force-delete
|
|
30999
|
+
* for). Matches git's wording across versions ("not fully merged").
|
|
31000
|
+
*/
|
|
31001
|
+
function isBranchNotFullyMergedError(message) {
|
|
31002
|
+
return /not fully merged/i.test(message || '');
|
|
30536
31003
|
}
|
|
30537
31004
|
function fetchRemotes(git) {
|
|
30538
31005
|
return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
|
|
@@ -30639,7 +31106,7 @@ function fetchBranch(git, branch) {
|
|
|
30639
31106
|
if (!branch.upstream || !branch.remote) {
|
|
30640
31107
|
return Promise.resolve({
|
|
30641
31108
|
ok: false,
|
|
30642
|
-
message: `${branch.shortName} has no upstream —
|
|
31109
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
|
|
30643
31110
|
});
|
|
30644
31111
|
}
|
|
30645
31112
|
// `branch.upstream` is the short form (e.g. `origin/main`); the
|
|
@@ -30677,7 +31144,7 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30677
31144
|
if (!branch.upstream || !branch.remote) {
|
|
30678
31145
|
return Promise.resolve({
|
|
30679
31146
|
ok: false,
|
|
30680
|
-
message: `${branch.shortName} has no upstream —
|
|
31147
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
|
|
30681
31148
|
});
|
|
30682
31149
|
}
|
|
30683
31150
|
// Current branch — defer to the in-place workflow.
|
|
@@ -31985,13 +32452,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
31985
32452
|
message: 'No GitHub remote detected.',
|
|
31986
32453
|
};
|
|
31987
32454
|
}
|
|
31988
|
-
|
|
32455
|
+
const ghStatus = await getGhStatus(runner);
|
|
32456
|
+
if (ghStatus.kind !== 'ok') {
|
|
31989
32457
|
return {
|
|
31990
32458
|
available: true,
|
|
31991
32459
|
authenticated: false,
|
|
31992
32460
|
repository,
|
|
31993
32461
|
filter,
|
|
31994
|
-
message:
|
|
32462
|
+
message: describeGhStatus(ghStatus),
|
|
31995
32463
|
};
|
|
31996
32464
|
}
|
|
31997
32465
|
try {
|
|
@@ -32857,6 +33325,7 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32857
33325
|
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
32858
33326
|
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
32859
33327
|
state.showHelp ||
|
|
33328
|
+
state.showViewKeys ||
|
|
32860
33329
|
state.showCommandPalette ||
|
|
32861
33330
|
state.showThemePicker ||
|
|
32862
33331
|
state.gitignorePicker ||
|
|
@@ -33938,7 +34407,13 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
33938
34407
|
* rows so they read as the same severity scale used in the main status
|
|
33939
34408
|
* surface; every other tab falls through to selectable rows.
|
|
33940
34409
|
*/
|
|
33941
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
34410
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame) {
|
|
34411
|
+
// Inline pending-delete glyph: while a row's delete is in flight it
|
|
34412
|
+
// shows this spinner in place of its leading marker (branches /
|
|
34413
|
+
// worktrees) or appended to the row (tags / stashes, which have no
|
|
34414
|
+
// leading status icon). `pending` is the single in-flight target.
|
|
34415
|
+
const pending = state.pendingDeletion;
|
|
34416
|
+
const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
|
|
33942
34417
|
// Available rows for the active tab's list. The sidebar chrome
|
|
33943
34418
|
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
33944
34419
|
// spacers); the branches tab eats 3 more for its summary header
|
|
@@ -33975,7 +34450,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33975
34450
|
];
|
|
33976
34451
|
return [
|
|
33977
34452
|
...headerRows,
|
|
33978
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) =>
|
|
34453
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
|
|
34454
|
+
const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
|
|
34455
|
+
? spin
|
|
34456
|
+
: branchRowMarker(branch, { ascii: theme.ascii }).glyph;
|
|
34457
|
+
return `${glyph} ${branch.shortName}`;
|
|
34458
|
+
}, 'tab-branches', visibleListCount),
|
|
33979
34459
|
];
|
|
33980
34460
|
}
|
|
33981
34461
|
if (tab === 'tags') {
|
|
@@ -33986,7 +34466,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33986
34466
|
if (tags.length === 0) {
|
|
33987
34467
|
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
33988
34468
|
}
|
|
33989
|
-
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) =>
|
|
34469
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => {
|
|
34470
|
+
const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
|
|
34471
|
+
// Tags have no leading status icon, so the pending spinner is
|
|
34472
|
+
// appended to the row instead of replacing a glyph.
|
|
34473
|
+
return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
|
|
34474
|
+
}, 'tab-tags', visibleListCount);
|
|
33990
34475
|
}
|
|
33991
34476
|
if (tab === 'stashes') {
|
|
33992
34477
|
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
@@ -33996,7 +34481,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33996
34481
|
if (stashes.length === 0) {
|
|
33997
34482
|
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
33998
34483
|
}
|
|
33999
|
-
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) =>
|
|
34484
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => {
|
|
34485
|
+
const base = `@{${index}} ${stash.message || '(no message)'}`;
|
|
34486
|
+
// `@{N}` is the stash ref, not a status icon, so append the
|
|
34487
|
+
// spinner rather than replacing it.
|
|
34488
|
+
return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
|
|
34489
|
+
}, 'tab-stashes', visibleListCount);
|
|
34000
34490
|
}
|
|
34001
34491
|
// worktrees
|
|
34002
34492
|
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
@@ -34007,12 +34497,14 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34007
34497
|
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
34008
34498
|
}
|
|
34009
34499
|
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
34010
|
-
const marker =
|
|
34500
|
+
const marker = isPendingDeletion(pending, 'worktree', worktree.path)
|
|
34501
|
+
? spin
|
|
34502
|
+
: worktree.current ? '*' : ' ';
|
|
34011
34503
|
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
34012
34504
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
34013
34505
|
}, 'tab-worktrees', visibleListCount);
|
|
34014
34506
|
}
|
|
34015
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
34507
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, spinnerFrame = 0) {
|
|
34016
34508
|
const { Box, Text } = components;
|
|
34017
34509
|
const focused = state.focus === 'sidebar';
|
|
34018
34510
|
const tabs = getLogInkSidebarTabs();
|
|
@@ -34048,7 +34540,7 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
34048
34540
|
inverse: headerSelected,
|
|
34049
34541
|
}, headerText));
|
|
34050
34542
|
if (isActive) {
|
|
34051
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
34543
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame));
|
|
34052
34544
|
}
|
|
34053
34545
|
return blocks;
|
|
34054
34546
|
});
|
|
@@ -34399,7 +34891,7 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
34399
34891
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
34400
34892
|
* of #890. No behavior change.
|
|
34401
34893
|
*/
|
|
34402
|
-
function renderBranchesSurface(ctx) {
|
|
34894
|
+
function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
34403
34895
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34404
34896
|
const { Box, Text } = components;
|
|
34405
34897
|
const focused = state.focus === 'commits';
|
|
@@ -34438,7 +34930,14 @@ function renderBranchesSurface(ctx) {
|
|
|
34438
34930
|
const isSelected = index === selected;
|
|
34439
34931
|
const cursor = isSelected ? '>' : ' ';
|
|
34440
34932
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
34441
|
-
|
|
34933
|
+
// While this branch's delete is in flight, its sync-state marker
|
|
34934
|
+
// is replaced by an inline spinner (accent-coloured) so the row
|
|
34935
|
+
// reads as "deleting" until it vanishes on refresh.
|
|
34936
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
|
|
34937
|
+
const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
|
|
34938
|
+
const glyphColor = deleting
|
|
34939
|
+
? (theme.noColor ? undefined : theme.colors.accent)
|
|
34940
|
+
: getBranchRowMarkerColor(marker.kind, theme);
|
|
34442
34941
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
34443
34942
|
const lastTouched = formatBranchLastTouched(branch.date, getRenderNow());
|
|
34444
34943
|
// Split the row into spans so the timestamp stays dim even on the
|
|
@@ -34453,7 +34952,7 @@ function renderBranchesSurface(ctx) {
|
|
|
34453
34952
|
// Truncate the assembled line to the actual panel width so a
|
|
34454
34953
|
// narrow inspector / sidebar focus doesn't push branch rows
|
|
34455
34954
|
// onto a second visual line (#830).
|
|
34456
|
-
const fullText = `${cursorAndPad}${
|
|
34955
|
+
const fullText = `${cursorAndPad}${glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
|
|
34457
34956
|
const truncated = truncateCells(fullText, Math.max(20, width - 4));
|
|
34458
34957
|
// If truncation chopped into the timestamp/divergence portion,
|
|
34459
34958
|
// fall back to a single Text to keep the visible width honest.
|
|
@@ -34476,7 +34975,7 @@ function renderBranchesSurface(ctx) {
|
|
|
34476
34975
|
// no-upstream kinds return undefined from
|
|
34477
34976
|
// `getBranchRowMarkerColor`, so those markers inherit the
|
|
34478
34977
|
// row's dim and read as quiet chrome.
|
|
34479
|
-
h(Text, { color:
|
|
34978
|
+
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
34480
34979
|
});
|
|
34481
34980
|
// Scroll indicators — same "N more above/below" pattern as the
|
|
34482
34981
|
// sidebar and help overlay so the user knows the list continues.
|
|
@@ -34761,8 +35260,16 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34761
35260
|
const bodyVisualLines = compose.body
|
|
34762
35261
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
|
|
34763
35262
|
: ['<empty>'];
|
|
34764
|
-
|
|
34765
|
-
)
|
|
35263
|
+
// Summary now renders on its own indented line under the label (like the
|
|
35264
|
+
// body), so it wraps at the full content width instead of the cramped
|
|
35265
|
+
// "Summary " (9) + chrome budget it had when label and value shared a row.
|
|
35266
|
+
const summaryVisualLines = compose.summary
|
|
35267
|
+
? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
|
|
35268
|
+
: ['<empty>'];
|
|
35269
|
+
// Subject length drives a subtle counter on the Summary label: dim under
|
|
35270
|
+
// 50, warning past the conventional 50-char soft limit, danger past 72.
|
|
35271
|
+
// Counted in code points so multibyte subjects aren't over-counted.
|
|
35272
|
+
const summaryLength = [...compose.summary].length;
|
|
34766
35273
|
// State-line cycles through three modes (#881 phase 3 added the
|
|
34767
35274
|
// loading variant): editing copy when the user is typing, cancel
|
|
34768
35275
|
// hint when an AI draft is generating, default guidance otherwise.
|
|
@@ -34782,6 +35289,52 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34782
35289
|
const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
34783
35290
|
? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
|
|
34784
35291
|
: undefined;
|
|
35292
|
+
// Section header for a field (Summary / Body). The active field's label
|
|
35293
|
+
// carries an arrow marker + the repo's selection highlight (matching the
|
|
35294
|
+
// status surface, see status/index.ts) so the user can see which field
|
|
35295
|
+
// their keystrokes target — even before entering edit mode, and even
|
|
35296
|
+
// under NO_COLOR where the marker + bold/dim carry the signal alone. An
|
|
35297
|
+
// optional length counter (Summary only) trails the label outside the
|
|
35298
|
+
// highlight so its own warning/danger color stays legible.
|
|
35299
|
+
const renderSectionHeader = (name, field, count) => {
|
|
35300
|
+
const active = compose.field === field;
|
|
35301
|
+
const highlight = active && focused && !theme.noColor;
|
|
35302
|
+
const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
|
|
35303
|
+
const badge = active && compose.editing ? ' EDITING' : '';
|
|
35304
|
+
const children = [
|
|
35305
|
+
h(Text, {
|
|
35306
|
+
key: `compose-${field}-label`,
|
|
35307
|
+
bold: active,
|
|
35308
|
+
dimColor: !active,
|
|
35309
|
+
backgroundColor: highlight ? theme.colors.selection : undefined,
|
|
35310
|
+
color: highlight ? theme.colors.selectionForeground : undefined,
|
|
35311
|
+
}, `${marker}${name}${badge}`),
|
|
35312
|
+
];
|
|
35313
|
+
if (count !== undefined) {
|
|
35314
|
+
const countColor = theme.noColor
|
|
35315
|
+
? undefined
|
|
35316
|
+
: count > 72
|
|
35317
|
+
? theme.colors.danger
|
|
35318
|
+
: count > 50
|
|
35319
|
+
? theme.colors.warning
|
|
35320
|
+
: undefined;
|
|
35321
|
+
children.push(h(Text, {
|
|
35322
|
+
key: `compose-${field}-count`,
|
|
35323
|
+
color: countColor,
|
|
35324
|
+
dimColor: countColor === undefined,
|
|
35325
|
+
}, ` ${count}`));
|
|
35326
|
+
}
|
|
35327
|
+
return h(Box, { key: `compose-${field}-header` }, ...children);
|
|
35328
|
+
};
|
|
35329
|
+
// Content lines for a field — indented two cells under the header, with
|
|
35330
|
+
// the edit cursor parked on the final line when this field is active.
|
|
35331
|
+
const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
|
|
35332
|
+
const isLast = index === lines.length - 1;
|
|
35333
|
+
return h(Text, {
|
|
35334
|
+
key: `compose-${field}-${index}`,
|
|
35335
|
+
dimColor: line === '<empty>',
|
|
35336
|
+
}, ` ${line}${cursor && isLast ? cursor : ''}`);
|
|
35337
|
+
});
|
|
34785
35338
|
return h(Box, {
|
|
34786
35339
|
borderColor: focusBorderColor(theme, focused),
|
|
34787
35340
|
borderStyle: theme.borderStyle,
|
|
@@ -34789,20 +35342,7 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
|
34789
35342
|
flexShrink: 0,
|
|
34790
35343
|
paddingX: 1,
|
|
34791
35344
|
width,
|
|
34792
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text,
|
|
34793
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34794
|
-
}, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
|
|
34795
|
-
key: `compose-summary-${index}`,
|
|
34796
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34797
|
-
}, ` ${line}`)), h(Text, undefined, ''), h(Text, {
|
|
34798
|
-
bold: compose.field === 'body' && compose.editing,
|
|
34799
|
-
}, 'Body'), ...bodyVisualLines.map((line, index) => {
|
|
34800
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
34801
|
-
return h(Text, {
|
|
34802
|
-
key: `compose-body-${index}`,
|
|
34803
|
-
dimColor: line === '<empty>',
|
|
34804
|
-
}, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
|
|
34805
|
-
}),
|
|
35345
|
+
}, 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),
|
|
34806
35346
|
// Loading indicator + post-action message belong inline with the draft
|
|
34807
35347
|
// (they describe what just happened to the fields above). The state-
|
|
34808
35348
|
// line ("Editing — Enter switches summary↔body…" / "Press e to edit
|
|
@@ -37057,9 +37597,13 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
|
37057
37597
|
? 'You have an unsaved commit draft. Press y to discard it and quit.'
|
|
37058
37598
|
: state.pendingMutationConfirmation
|
|
37059
37599
|
? 'This discards local changes and cannot be undone by Coco.'
|
|
37060
|
-
|
|
37061
|
-
|
|
37062
|
-
|
|
37600
|
+
// Second-stage confirm raised when a safe delete hit an unmerged
|
|
37601
|
+
// branch — name the reason so the force isn't a blind "y again".
|
|
37602
|
+
: state.pendingConfirmationId === 'force-delete-branch'
|
|
37603
|
+
? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
|
|
37604
|
+
: action?.kind === 'ai'
|
|
37605
|
+
? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
|
|
37606
|
+
: 'Destructive Git action requires confirmation.';
|
|
37063
37607
|
return h(Box, {
|
|
37064
37608
|
borderColor: focusBorderColor(theme, focused),
|
|
37065
37609
|
borderStyle: theme.borderStyle,
|
|
@@ -37140,6 +37684,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
|
|
|
37140
37684
|
paddingX: 1,
|
|
37141
37685
|
}, ...lines);
|
|
37142
37686
|
}
|
|
37687
|
+
/**
|
|
37688
|
+
* Which-key view-keys strip (#1137). The per-view counterpart to the
|
|
37689
|
+
* `g`-chord overlay: opened by `g?`, it lists the single-key actions
|
|
37690
|
+
* available in the current view (the deliberate overloads — `c`, `R`,
|
|
37691
|
+
* `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
|
|
37692
|
+
* `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
|
|
37693
|
+
*
|
|
37694
|
+
* Renders in the detail panel slot like the chord overlay. `?` steps up
|
|
37695
|
+
* to the full categorized help; Esc closes.
|
|
37696
|
+
*/
|
|
37697
|
+
function renderViewKeysOverlay(h, components, state, width, theme, focused) {
|
|
37698
|
+
const { Box, Text } = components;
|
|
37699
|
+
const bindings = getLogInkViewKeyBindings({
|
|
37700
|
+
activeView: state.activeView,
|
|
37701
|
+
focus: state.focus,
|
|
37702
|
+
});
|
|
37703
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
37704
|
+
const lines = [
|
|
37705
|
+
h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
|
|
37706
|
+
h(Text, { key: 'view-keys-spacer' }, ''),
|
|
37707
|
+
];
|
|
37708
|
+
if (bindings.length === 0) {
|
|
37709
|
+
lines.push(h(Text, {
|
|
37710
|
+
key: 'view-keys-empty',
|
|
37711
|
+
dimColor: true,
|
|
37712
|
+
}, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
|
|
37713
|
+
}
|
|
37714
|
+
else {
|
|
37715
|
+
// Pad keys to the widest entry so labels align into a scannable column.
|
|
37716
|
+
const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
|
|
37717
|
+
for (const binding of bindings) {
|
|
37718
|
+
const keys = formatBindingBareKeys(binding);
|
|
37719
|
+
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))));
|
|
37720
|
+
}
|
|
37721
|
+
}
|
|
37722
|
+
lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
|
|
37723
|
+
lines.push(h(Text, {
|
|
37724
|
+
key: 'view-keys-hint',
|
|
37725
|
+
dimColor: true,
|
|
37726
|
+
}, truncateCells('? full help · esc closes', width - 4)));
|
|
37727
|
+
return h(Box, {
|
|
37728
|
+
borderColor: focusBorderColor(theme, focused),
|
|
37729
|
+
borderStyle: theme.borderStyle,
|
|
37730
|
+
flexDirection: 'column',
|
|
37731
|
+
width,
|
|
37732
|
+
paddingX: 1,
|
|
37733
|
+
}, ...lines);
|
|
37734
|
+
}
|
|
37143
37735
|
function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
|
|
37144
37736
|
const { Box, Text } = components;
|
|
37145
37737
|
// Build the full list of body rows (everything below the title).
|
|
@@ -38182,7 +38774,7 @@ function renderReflogSurface(ctx) {
|
|
|
38182
38774
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38183
38775
|
* of #890. No behavior change.
|
|
38184
38776
|
*/
|
|
38185
|
-
function renderStashSurface(ctx) {
|
|
38777
|
+
function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
38186
38778
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38187
38779
|
const { Box, Text } = components;
|
|
38188
38780
|
const focused = state.focus === 'commits';
|
|
@@ -38228,11 +38820,18 @@ function renderStashSurface(ctx) {
|
|
|
38228
38820
|
const rowText = meta
|
|
38229
38821
|
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38230
38822
|
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
38823
|
+
// The `stash@{N}` ref is an identifier, not a status icon, so a
|
|
38824
|
+
// delete-in-flight appends an accent spinner at the row's end
|
|
38825
|
+
// (2 cells reserved from the width budget).
|
|
38826
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'stash', stash.ref);
|
|
38827
|
+
const spinnerSpan = deleting
|
|
38828
|
+
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
38829
|
+
: null;
|
|
38231
38830
|
return h(Text, {
|
|
38232
38831
|
key: `stash-${index}`,
|
|
38233
38832
|
bold: isSelected,
|
|
38234
38833
|
dimColor: !isSelected,
|
|
38235
|
-
}, truncateCells(rowText, rowWidth));
|
|
38834
|
+
}, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
|
|
38236
38835
|
});
|
|
38237
38836
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
38238
38837
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -38580,7 +39179,7 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38580
39179
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38581
39180
|
* of #890. No behavior change.
|
|
38582
39181
|
*/
|
|
38583
|
-
function renderTagsSurface(ctx) {
|
|
39182
|
+
function renderTagsSurface(ctx, spinnerFrame = 0) {
|
|
38584
39183
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38585
39184
|
const { Box, Text } = components;
|
|
38586
39185
|
const focused = state.focus === 'commits';
|
|
@@ -38621,13 +39220,20 @@ function renderTagsSurface(ctx) {
|
|
|
38621
39220
|
// intact.
|
|
38622
39221
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
38623
39222
|
const namePadded = truncateCells(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
38624
|
-
|
|
39223
|
+
// Tags have no leading status icon, so a delete-in-flight appends
|
|
39224
|
+
// an accent spinner at the row's end. Reserve its 2 cells from the
|
|
39225
|
+
// truncation budget so it never pushes the row past the panel.
|
|
39226
|
+
const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
|
|
39227
|
+
const spinnerSpan = deleting
|
|
39228
|
+
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
39229
|
+
: null;
|
|
39230
|
+
const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4 - (deleting ? 2 : 0)));
|
|
38625
39231
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
38626
39232
|
return h(Text, {
|
|
38627
39233
|
key: `tag-${index}`,
|
|
38628
39234
|
bold: isSelected,
|
|
38629
39235
|
dimColor: !isSelected,
|
|
38630
|
-
}, lineText);
|
|
39236
|
+
}, lineText, spinnerSpan);
|
|
38631
39237
|
}
|
|
38632
39238
|
const linkStart = lineText.indexOf(namePadded);
|
|
38633
39239
|
const before = lineText.slice(0, linkStart);
|
|
@@ -38636,7 +39242,7 @@ function renderTagsSurface(ctx) {
|
|
|
38636
39242
|
key: `tag-${index}`,
|
|
38637
39243
|
bold: isSelected,
|
|
38638
39244
|
dimColor: !isSelected,
|
|
38639
|
-
}, before, formatHyperlink(namePadded, url), after);
|
|
39245
|
+
}, before, formatHyperlink(namePadded, url), after, spinnerSpan);
|
|
38640
39246
|
});
|
|
38641
39247
|
const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
|
|
38642
39248
|
const tagsHasMoreBelow = startIndex + listRows < tags.length;
|
|
@@ -38663,7 +39269,7 @@ function renderTagsSurface(ctx) {
|
|
|
38663
39269
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38664
39270
|
* of #890. No behavior change.
|
|
38665
39271
|
*/
|
|
38666
|
-
function renderWorktreesSurface(ctx) {
|
|
39272
|
+
function renderWorktreesSurface(ctx, spinnerFrame = 0) {
|
|
38667
39273
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38668
39274
|
const { Box, Text } = components;
|
|
38669
39275
|
const focused = state.focus === 'commits';
|
|
@@ -38699,7 +39305,9 @@ function renderWorktreesSurface(ctx) {
|
|
|
38699
39305
|
const index = startIndex + offset;
|
|
38700
39306
|
const isSelected = index === selected;
|
|
38701
39307
|
const cursor = isSelected ? '>' : ' ';
|
|
38702
|
-
const marker =
|
|
39308
|
+
const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
|
|
39309
|
+
? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
|
|
39310
|
+
: entry.current ? '*' : ' ';
|
|
38703
39311
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
38704
39312
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
38705
39313
|
const branchPadded = truncateCells(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
@@ -38769,10 +39377,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
|
|
|
38769
39377
|
return renderComposeSurface(surface, spinnerFrame);
|
|
38770
39378
|
}
|
|
38771
39379
|
if (state.activeView === 'branches') {
|
|
38772
|
-
return renderBranchesSurface(surface);
|
|
39380
|
+
return renderBranchesSurface(surface, spinnerFrame);
|
|
38773
39381
|
}
|
|
38774
39382
|
if (state.activeView === 'tags') {
|
|
38775
|
-
return renderTagsSurface(surface);
|
|
39383
|
+
return renderTagsSurface(surface, spinnerFrame);
|
|
38776
39384
|
}
|
|
38777
39385
|
if (state.activeView === 'reflog') {
|
|
38778
39386
|
return renderReflogSurface(surface);
|
|
@@ -38781,10 +39389,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
|
|
|
38781
39389
|
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38782
39390
|
}
|
|
38783
39391
|
if (state.activeView === 'stash') {
|
|
38784
|
-
return renderStashSurface(surface);
|
|
39392
|
+
return renderStashSurface(surface, spinnerFrame);
|
|
38785
39393
|
}
|
|
38786
39394
|
if (state.activeView === 'worktrees') {
|
|
38787
|
-
return renderWorktreesSurface(surface);
|
|
39395
|
+
return renderWorktreesSurface(surface, spinnerFrame);
|
|
38788
39396
|
}
|
|
38789
39397
|
if (state.activeView === 'submodules') {
|
|
38790
39398
|
return renderSubmodulesSurface(surface);
|
|
@@ -39818,6 +40426,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39818
40426
|
if (state.showHelp) {
|
|
39819
40427
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39820
40428
|
}
|
|
40429
|
+
// #1137 — the `g?` which-key strip lists the current view's single-key
|
|
40430
|
+
// actions. Checked alongside the other overlays; the reducer keeps it
|
|
40431
|
+
// mutually exclusive with help / palette / pickers.
|
|
40432
|
+
if (state.showViewKeys) {
|
|
40433
|
+
return renderViewKeysOverlay(h, components, state, width, theme, focused);
|
|
40434
|
+
}
|
|
39821
40435
|
if (state.showCommandPalette) {
|
|
39822
40436
|
return renderCommandPalette(h, components, state, width, theme, focused);
|
|
39823
40437
|
}
|
|
@@ -40151,6 +40765,53 @@ const REMOTE_OP_LOADERS = {
|
|
|
40151
40765
|
'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
|
|
40152
40766
|
'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
|
|
40153
40767
|
};
|
|
40768
|
+
/**
|
|
40769
|
+
* Resolve which list row a delete workflow is about to act on, so the
|
|
40770
|
+
* runner can mark it pending (inline spinner) for the duration of the
|
|
40771
|
+
* git call. Mirrors the cursored-target resolution inside each delete
|
|
40772
|
+
* handler exactly — same sort, same promoted-filter, same selection
|
|
40773
|
+
* index — so the spinner lands on the row that actually gets deleted.
|
|
40774
|
+
* Returns `undefined` for non-delete workflows (and when nothing is
|
|
40775
|
+
* selected), which the runner treats as "no pending marker".
|
|
40776
|
+
*/
|
|
40777
|
+
function resolvePendingDeletion(id, state, context) {
|
|
40778
|
+
const { filter } = state;
|
|
40779
|
+
if (id === 'delete-branch' || id === 'force-delete-branch') {
|
|
40780
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
40781
|
+
const visible = filter
|
|
40782
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
|
|
40783
|
+
: all;
|
|
40784
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
40785
|
+
return branch ? { kind: 'branch', id: branch.shortName } : undefined;
|
|
40786
|
+
}
|
|
40787
|
+
if (id === 'delete-tag') {
|
|
40788
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
40789
|
+
const visible = filter
|
|
40790
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
|
|
40791
|
+
: all;
|
|
40792
|
+
const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
|
|
40793
|
+
return tag ? { kind: 'tag', id: tag.name } : undefined;
|
|
40794
|
+
}
|
|
40795
|
+
if (id === 'drop-stash') {
|
|
40796
|
+
const all = context.stashes?.stashes || [];
|
|
40797
|
+
const visible = filter
|
|
40798
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
|
|
40799
|
+
: all;
|
|
40800
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
40801
|
+
return stash ? { kind: 'stash', id: stash.ref } : undefined;
|
|
40802
|
+
}
|
|
40803
|
+
if (id === 'remove-worktree') {
|
|
40804
|
+
const all = context.worktreeList?.worktrees || [];
|
|
40805
|
+
const visible = filter
|
|
40806
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], filter))
|
|
40807
|
+
: all;
|
|
40808
|
+
const wt = visible.length
|
|
40809
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
40810
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
40811
|
+
return wt ? { kind: 'worktree', id: wt.path } : undefined;
|
|
40812
|
+
}
|
|
40813
|
+
return undefined;
|
|
40814
|
+
}
|
|
40154
40815
|
function predictNextFilter(action, currentFilter) {
|
|
40155
40816
|
switch (action.type) {
|
|
40156
40817
|
case 'appendFilter':
|
|
@@ -40467,7 +41128,10 @@ function LogInkApp(deps) {
|
|
|
40467
41128
|
state.changelogView.status === 'loading' ||
|
|
40468
41129
|
state.commitCompose.loading ||
|
|
40469
41130
|
Boolean(state.remoteOp) ||
|
|
40470
|
-
Boolean(state.statusLoading)
|
|
41131
|
+
Boolean(state.statusLoading) ||
|
|
41132
|
+
// Keep the shared spinner ticking while a list-item delete is in
|
|
41133
|
+
// flight so its inline pending glyph animates instead of freezing.
|
|
41134
|
+
Boolean(state.pendingDeletion);
|
|
40471
41135
|
React.useEffect(() => {
|
|
40472
41136
|
if (!anyLoading) {
|
|
40473
41137
|
// Reset to 0 so the next loading state starts from a known
|
|
@@ -40723,6 +41387,10 @@ function LogInkApp(deps) {
|
|
|
40723
41387
|
worktree,
|
|
40724
41388
|
}), issuedAtDepth);
|
|
40725
41389
|
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
41390
|
+
// Returned so callers needing the *fresh* overview (e.g. post-commit
|
|
41391
|
+
// navigation) can read it directly instead of racing the async
|
|
41392
|
+
// `setContext` update, which won't be visible in their closure.
|
|
41393
|
+
return worktree;
|
|
40726
41394
|
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
40727
41395
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
40728
41396
|
// context when something changes outside the TUI (editor save, external
|
|
@@ -41585,7 +42253,14 @@ function LogInkApp(deps) {
|
|
|
41585
42253
|
// and see the pre-commit log (same silent-failure shape as
|
|
41586
42254
|
// the split-apply case caught in this PR).
|
|
41587
42255
|
await refreshHistoryRows();
|
|
41588
|
-
await refreshWorktreeContext();
|
|
42256
|
+
const worktree = await refreshWorktreeContext();
|
|
42257
|
+
// Leave the compose view automatically: a still-dirty tree returns
|
|
42258
|
+
// to Status (so the user can keep staging), an otherwise-complete
|
|
42259
|
+
// commit returns to History (where the new commit now shows). The
|
|
42260
|
+
// reducer inspects the live viewStack to pick the destination.
|
|
42261
|
+
const stillDirty = Boolean(worktree &&
|
|
42262
|
+
worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
|
|
42263
|
+
dispatch({ type: 'returnFromCommit', stillDirty });
|
|
41589
42264
|
}
|
|
41590
42265
|
}, [
|
|
41591
42266
|
context.worktree?.stagedCount,
|
|
@@ -42613,6 +43288,16 @@ function LogInkApp(deps) {
|
|
|
42613
43288
|
return { ok: false, message: 'No branch selected' };
|
|
42614
43289
|
return deleteBranch(git, branch);
|
|
42615
43290
|
},
|
|
43291
|
+
'force-delete-branch': async () => {
|
|
43292
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
43293
|
+
const visible = state.filter
|
|
43294
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
43295
|
+
: all;
|
|
43296
|
+
const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
|
|
43297
|
+
if (!branch)
|
|
43298
|
+
return { ok: false, message: 'No branch selected' };
|
|
43299
|
+
return deleteBranch(git, branch, true);
|
|
43300
|
+
},
|
|
42616
43301
|
'delete-tag': async () => {
|
|
42617
43302
|
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
42618
43303
|
const visible = state.filter
|
|
@@ -43386,9 +44071,26 @@ function LogInkApp(deps) {
|
|
|
43386
44071
|
if (remoteOp) {
|
|
43387
44072
|
dispatch({ type: 'setRemoteOp', value: remoteOp });
|
|
43388
44073
|
}
|
|
44074
|
+
// Mark the cursored row as deleting so it shows an inline pending
|
|
44075
|
+
// spinner while the git call runs. Cleared in `finally` after the
|
|
44076
|
+
// refresh, so a successful delete hands straight off to the row
|
|
44077
|
+
// vanishing, and a failed one (e.g. an unmerged branch) restores
|
|
44078
|
+
// the row's normal icon alongside the error status.
|
|
44079
|
+
const pendingDeletion = resolvePendingDeletion(id, state, context);
|
|
44080
|
+
if (pendingDeletion) {
|
|
44081
|
+
dispatch({ type: 'setPendingDeletion', value: pendingDeletion });
|
|
44082
|
+
}
|
|
43389
44083
|
try {
|
|
43390
44084
|
const result = await handler();
|
|
43391
44085
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
44086
|
+
// A safe `delete-branch` (`git branch -d`) refuses branches that
|
|
44087
|
+
// aren't fully merged. Rather than dead-end on git's raw error, raise
|
|
44088
|
+
// a second y-confirm offering the force-delete (`git branch -D`). The
|
|
44089
|
+
// cursor hasn't moved (the delete failed), so the force handler
|
|
44090
|
+
// re-resolves the same branch.
|
|
44091
|
+
if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
|
|
44092
|
+
dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
|
|
44093
|
+
}
|
|
43392
44094
|
// Refresh history rows AS WELL when the workflow could have
|
|
43393
44095
|
// changed the commits the user sees (#945 follow-up). The
|
|
43394
44096
|
// workflow IDs below all either create/rewrite local commits or
|
|
@@ -43487,6 +44189,12 @@ function LogInkApp(deps) {
|
|
|
43487
44189
|
if (remoteOp) {
|
|
43488
44190
|
dispatch({ type: 'setRemoteOp', value: undefined });
|
|
43489
44191
|
}
|
|
44192
|
+
// Same guarantee for the per-row delete spinner: clear it whether
|
|
44193
|
+
// the delete succeeded, failed, or the refresh threw, so no row is
|
|
44194
|
+
// left spinning forever.
|
|
44195
|
+
if (pendingDeletion) {
|
|
44196
|
+
dispatch({ type: 'setPendingDeletion', value: undefined });
|
|
44197
|
+
}
|
|
43490
44198
|
}
|
|
43491
44199
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
43492
44200
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
@@ -44296,6 +45004,7 @@ function LogInkApp(deps) {
|
|
|
44296
45004
|
const forcedPane = state.splitPlan
|
|
44297
45005
|
? 'main'
|
|
44298
45006
|
: state.showHelp ||
|
|
45007
|
+
state.showViewKeys ||
|
|
44299
45008
|
state.showCommandPalette ||
|
|
44300
45009
|
state.showThemePicker ||
|
|
44301
45010
|
state.gitignorePicker ||
|
|
@@ -44345,7 +45054,7 @@ function LogInkApp(deps) {
|
|
|
44345
45054
|
// Panel renderers are thunks so single-pane mode can build only the
|
|
44346
45055
|
// visible pane — the main-panel render in particular is expensive, so
|
|
44347
45056
|
// we don't want to invoke the two hidden ones just to drop them.
|
|
44348
|
-
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
45057
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, spinnerFrame);
|
|
44349
45058
|
const mainSurface = {
|
|
44350
45059
|
h,
|
|
44351
45060
|
components: { Box, Text },
|
|
@@ -45108,6 +45817,7 @@ var prs = {
|
|
|
45108
45817
|
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
45109
45818
|
builder: builder$4,
|
|
45110
45819
|
handler: commandExecutor(handler$3),
|
|
45820
|
+
options: options$4,
|
|
45111
45821
|
};
|
|
45112
45822
|
|
|
45113
45823
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -45187,8 +45897,7 @@ const handler$2 = async (argv, logger) => {
|
|
|
45187
45897
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
45188
45898
|
const model = recapService.model;
|
|
45189
45899
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45190
|
-
logger
|
|
45191
|
-
commandExit(1);
|
|
45900
|
+
handleMissingApiKey(logger, config, { command: 'recap' });
|
|
45192
45901
|
}
|
|
45193
45902
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45194
45903
|
const llm = getLlm(provider, model, { ...config, service: recapService });
|
|
@@ -45776,8 +46485,7 @@ const handler$1 = async (argv, logger) => {
|
|
|
45776
46485
|
const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
|
|
45777
46486
|
const model = reviewService.model;
|
|
45778
46487
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45779
|
-
logger
|
|
45780
|
-
commandExit(1);
|
|
46488
|
+
handleMissingApiKey(logger, config, { command: 'review' });
|
|
45781
46489
|
}
|
|
45782
46490
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45783
46491
|
const llm = getLlm(provider, model, { ...config, service: reviewService });
|