Skip to content

Commit 12b99b4

Browse files
feat: add keyboard shortcuts for faster prompt navigation
• Add keyboard shortcuts module with auto-assignment algorithm • Enable shortcuts by default in generated configurations • Generate default shortcut mappings for commit types in init workflow • Implement input interception for single-character shortcut selection • Add shortcuts support to type, preview, and body input prompts • Include shortcuts configuration in advanced section with validation • Support custom shortcut mappings with auto-assignment fallback • Display shortcut hints in prompt labels when enabled
1 parent d1f6574 commit 12b99b4

File tree

12 files changed

+871
-98
lines changed

12 files changed

+871
-98
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@labcatr/labcommitr": minor
3+
---
4+
5+
feat: add keyboard shortcuts for faster prompt navigation
6+
7+
- Add keyboard shortcuts module with auto-assignment algorithm
8+
- Enable shortcuts by default in generated configurations
9+
- Generate default shortcut mappings for commit types in init workflow
10+
- Implement input interception for single-character shortcut selection
11+
- Add shortcuts support to type, preview, and body input prompts
12+
- Include shortcuts configuration in advanced section with validation
13+
- Support custom shortcut mappings with auto-assignment fallback
14+
- Display shortcut hints in prompt labels when enabled
15+

src/cli/commands/commit/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ export async function commitAction(options: {
441441
);
442442

443443
// Show preview and get user action
444-
action = await displayPreview(formattedMessage, body);
444+
action = await displayPreview(formattedMessage, body, config);
445445

446446
// Handle edit actions
447447
if (action === "edit-type") {

src/cli/commands/commit/prompts.ts

Lines changed: 201 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import type {
1313
} from "../../../lib/config/types.js";
1414
import type { ValidationError } from "./types.js";
1515
import { editInEditor, detectEditor } from "./editor.js";
16+
import {
17+
processShortcuts,
18+
formatLabelWithShortcut,
19+
getShortcutForValue,
20+
} from "../../../lib/shortcuts/index.js";
21+
import { selectWithShortcuts } from "../../../lib/shortcuts/select-with-shortcuts.js";
1622

1723
/**
1824
* Create compact color-coded label
@@ -84,21 +90,45 @@ export async function promptType(
8490
};
8591
}
8692

93+
// Process shortcuts for this prompt
94+
const shortcutMapping = processShortcuts(
95+
config.advanced.shortcuts,
96+
"type",
97+
config.types.map((t) => ({
98+
value: t.id,
99+
label: `${t.id.padEnd(8)} ${t.description}`,
100+
})),
101+
);
102+
103+
const displayHints = config.advanced.shortcuts?.display_hints ?? true;
104+
87105
// Find initial type index if provided
88106
const initialIndex = initialType
89107
? config.types.findIndex((t) => t.id === initialType)
90108
: undefined;
91109

92-
const selected = await select({
93-
message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`,
94-
options: config.types.map((type) => ({
110+
// Build options with shortcuts
111+
const options = config.types.map((type) => {
112+
const shortcut = getShortcutForValue(type.id, shortcutMapping);
113+
const baseLabel = `${type.id.padEnd(8)} ${type.description}`;
114+
const label = formatLabelWithShortcut(baseLabel, shortcut, displayHints);
115+
116+
return {
95117
value: type.id,
96-
label: `${type.id.padEnd(8)} ${type.description}`,
118+
label,
97119
hint: type.description,
98-
})),
99-
initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined,
120+
};
100121
});
101122

123+
const selected = await selectWithShortcuts(
124+
{
125+
message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`,
126+
options,
127+
initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined,
128+
},
129+
shortcutMapping,
130+
);
131+
102132
handleCancel(selected);
103133
const typeId = selected as string;
104134
const typeConfig = config.types.find((t) => t.id === typeId)!;
@@ -436,24 +466,39 @@ export async function promptBody(
436466
if (!isRequired) {
437467
// Optional body - offer choice if editor available and preference allows
438468
if (editorAvailable && preference === "auto") {
439-
const inputMethod = await select({
440-
message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`,
441-
options: [
442-
{
443-
value: "inline",
444-
label: "Type inline (single/multi-line)",
445-
},
446-
{
447-
value: "editor",
448-
label: "Open in editor",
449-
},
450-
{
451-
value: "skip",
452-
label: "Skip (no body)",
453-
},
454-
],
469+
const bodyOptions = [
470+
{ value: "inline", label: "Type inline (single/multi-line)" },
471+
{ value: "editor", label: "Open in editor" },
472+
{ value: "skip", label: "Skip (no body)" },
473+
];
474+
475+
const shortcutMapping = processShortcuts(
476+
config.advanced.shortcuts,
477+
"body",
478+
bodyOptions,
479+
);
480+
const displayHints = config.advanced.shortcuts?.display_hints ?? true;
481+
482+
const options = bodyOptions.map((option) => {
483+
const shortcut = shortcutMapping
484+
? getShortcutForValue(option.value, shortcutMapping)
485+
: undefined;
486+
const label = formatLabelWithShortcut(option.label, shortcut, displayHints);
487+
488+
return {
489+
value: option.value,
490+
label,
491+
};
455492
});
456493

494+
const inputMethod = await selectWithShortcuts(
495+
{
496+
message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`,
497+
options,
498+
},
499+
shortcutMapping,
500+
);
501+
457502
handleCancel(inputMethod);
458503

459504
if (inputMethod === "skip") {
@@ -501,22 +546,40 @@ export async function promptBody(
501546

502547
// For required body, offer editor option if available and preference allows
503548
if (editorAvailable && (preference === "auto" || preference === "inline")) {
504-
const inputMethod = await select({
505-
message: `${label("body", "yellow")} ${textColors.pureWhite(
506-
`Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`,
507-
)}`,
508-
options: [
509-
{
510-
value: "inline",
511-
label: "Type inline",
512-
},
513-
{
514-
value: "editor",
515-
label: "Open in editor",
516-
},
517-
],
549+
const bodyOptions = [
550+
{ value: "inline", label: "Type inline" },
551+
{ value: "editor", label: "Open in editor" },
552+
];
553+
554+
const shortcutMapping = processShortcuts(
555+
config.advanced.shortcuts,
556+
"body",
557+
bodyOptions,
558+
);
559+
const displayHints = config.advanced.shortcuts?.display_hints ?? true;
560+
561+
const options = bodyOptions.map((option) => {
562+
const shortcut = shortcutMapping
563+
? getShortcutForValue(option.value, shortcutMapping)
564+
: undefined;
565+
const label = formatLabelWithShortcut(option.label, shortcut, displayHints);
566+
567+
return {
568+
value: option.value,
569+
label,
570+
};
518571
});
519572

573+
const inputMethod = await selectWithShortcuts(
574+
{
575+
message: `${label("body", "yellow")} ${textColors.pureWhite(
576+
`Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`,
577+
)}`,
578+
options,
579+
},
580+
shortcutMapping,
581+
);
582+
520583
handleCancel(inputMethod);
521584

522585
if (inputMethod === "editor") {
@@ -601,24 +664,39 @@ async function promptBodyRequiredWithEditor(
601664
const edited = await promptBodyWithEditor(config, body);
602665
if (edited === null || edited === undefined) {
603666
// Editor cancelled, ask what to do
604-
const choice = await select({
605-
message: `${label("body", "yellow")} ${textColors.pureWhite("Editor cancelled. What would you like to do?")}`,
606-
options: [
607-
{
608-
value: "retry",
609-
label: "Try editor again",
610-
},
611-
{
612-
value: "inline",
613-
label: "Switch to inline input",
614-
},
615-
{
616-
value: "cancel",
617-
label: "Cancel commit",
618-
},
619-
],
667+
const bodyRetryOptions = [
668+
{ value: "retry", label: "Try editor again" },
669+
{ value: "inline", label: "Switch to inline input" },
670+
{ value: "cancel", label: "Cancel commit" },
671+
];
672+
673+
const shortcutMapping = processShortcuts(
674+
config.advanced.shortcuts,
675+
"body",
676+
bodyRetryOptions,
677+
);
678+
const displayHints = config.advanced.shortcuts?.display_hints ?? true;
679+
680+
const options = bodyRetryOptions.map((option) => {
681+
const shortcut = shortcutMapping
682+
? getShortcutForValue(option.value, shortcutMapping)
683+
: undefined;
684+
const label = formatLabelWithShortcut(option.label, shortcut, displayHints);
685+
686+
return {
687+
value: option.value,
688+
label,
689+
};
620690
});
621691

692+
const choice = await selectWithShortcuts(
693+
{
694+
message: `${label("body", "yellow")} ${textColors.pureWhite("Editor cancelled. What would you like to do?")}`,
695+
options,
696+
},
697+
shortcutMapping,
698+
);
699+
622700
handleCancel(choice);
623701

624702
if (choice === "cancel") {
@@ -694,24 +772,39 @@ async function promptBodyWithEditor(
694772
console.log();
695773

696774
// Ask if user wants to re-edit or go back to inline
697-
const choice = await select({
698-
message: `${label("body", "yellow")} ${textColors.pureWhite("Validation failed. What would you like to do?")}`,
699-
options: [
700-
{
701-
value: "re-edit",
702-
label: "Edit again",
703-
},
704-
{
705-
value: "inline",
706-
label: "Type inline instead",
707-
},
708-
{
709-
value: "cancel",
710-
label: "Cancel commit",
711-
},
712-
],
775+
const bodyValidationOptions = [
776+
{ value: "re-edit", label: "Edit again" },
777+
{ value: "inline", label: "Type inline instead" },
778+
{ value: "cancel", label: "Cancel commit" },
779+
];
780+
781+
const shortcutMapping = processShortcuts(
782+
config.advanced.shortcuts,
783+
"body",
784+
bodyValidationOptions,
785+
);
786+
const displayHints = config.advanced.shortcuts?.display_hints ?? true;
787+
788+
const options = bodyValidationOptions.map((option) => {
789+
const shortcut = shortcutMapping
790+
? getShortcutForValue(option.value, shortcutMapping)
791+
: undefined;
792+
const label = formatLabelWithShortcut(option.label, shortcut, displayHints);
793+
794+
return {
795+
value: option.value,
796+
label,
797+
};
713798
});
714799

800+
const choice = await selectWithShortcuts(
801+
{
802+
message: `${label("body", "yellow")} ${textColors.pureWhite("Validation failed. What would you like to do?")}`,
803+
options,
804+
},
805+
shortcutMapping,
806+
);
807+
715808
handleCancel(choice);
716809

717810
if (choice === "cancel") {
@@ -951,6 +1044,7 @@ export async function displayStagedFiles(status: {
9511044
export async function displayPreview(
9521045
formattedMessage: string,
9531046
body: string | undefined,
1047+
config?: LabcommitrConfig,
9541048
): Promise<"commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"> {
9551049
// Start connector line using @clack/prompts
9561050
log.info(
@@ -976,36 +1070,47 @@ export async function displayPreview(
9761070
renderWithConnector("─────────────────────────────────────────────"),
9771071
);
9781072

979-
const action = await select({
980-
message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`,
981-
options: [
982-
{
983-
value: "commit",
984-
label: "Create commit",
985-
},
986-
{
987-
value: "edit-type",
988-
label: "Edit type",
989-
},
990-
{
991-
value: "edit-scope",
992-
label: "Edit scope",
993-
},
994-
{
995-
value: "edit-subject",
996-
label: "Edit subject",
997-
},
998-
{
999-
value: "edit-body",
1000-
label: "Edit body",
1001-
},
1002-
{
1003-
value: "cancel",
1004-
label: "Cancel",
1005-
},
1006-
],
1073+
// Process shortcuts for preview prompt
1074+
const previewOptions = [
1075+
{ value: "commit", label: "Create commit" },
1076+
{ value: "edit-type", label: "Edit type" },
1077+
{ value: "edit-scope", label: "Edit scope" },
1078+
{ value: "edit-subject", label: "Edit subject" },
1079+
{ value: "edit-body", label: "Edit body" },
1080+
{ value: "cancel", label: "Cancel" },
1081+
];
1082+
1083+
const shortcutMapping = config
1084+
? processShortcuts(
1085+
config.advanced.shortcuts,
1086+
"preview",
1087+
previewOptions,
1088+
)
1089+
: null;
1090+
1091+
const displayHints = config?.advanced.shortcuts?.display_hints ?? true;
1092+
1093+
// Build options with shortcuts
1094+
const options = previewOptions.map((option) => {
1095+
const shortcut = shortcutMapping
1096+
? getShortcutForValue(option.value, shortcutMapping)
1097+
: undefined;
1098+
const label = formatLabelWithShortcut(option.label, shortcut, displayHints);
1099+
1100+
return {
1101+
value: option.value,
1102+
label,
1103+
};
10071104
});
10081105

1106+
const action = await selectWithShortcuts(
1107+
{
1108+
message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`,
1109+
options,
1110+
},
1111+
shortcutMapping,
1112+
);
1113+
10091114
handleCancel(action);
10101115
return action as "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel";
10111116
}

0 commit comments

Comments
 (0)