From 78000f17fccbbf53f993b6426db31926c59af4db Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 17:15:04 -0600 Subject: [PATCH 01/13] fix(ui): resolve terminal ghost lines from wrapped prompt content Use physical line counting instead of logical line counting when clearing prompt renders. Stores the full output string and recalculates physical lines at current terminal width on every clear, preventing stale counts after resize. Adds resize listener to all interactive prompts for immediate re-render on width change. --- .changeset/fix-prompt-ghost-lines.md | 5 +++ src/cli/ui/prompts.ts | 63 +++++++++++++++++++--------- src/cli/ui/renderer.ts | 22 ++++++++++ 3 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-prompt-ghost-lines.md diff --git a/.changeset/fix-prompt-ghost-lines.md b/.changeset/fix-prompt-ghost-lines.md new file mode 100644 index 0000000..87b8925 --- /dev/null +++ b/.changeset/fix-prompt-ghost-lines.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": patch +--- + +Fix terminal ghost lines when prompt content wraps at terminal width diff --git a/src/cli/ui/prompts.ts b/src/cli/ui/prompts.ts index 68df2a3..b7bacb5 100644 --- a/src/cli/ui/prompts.ts +++ b/src/cli/ui/prompts.ts @@ -21,6 +21,7 @@ import { enterRawMode, dim, brightCyan, + countPhysicalLines, } from "./renderer.js"; import { textColors } from "../commands/init/colors.js"; import { matchShortcut } from "../../lib/shortcuts/index.js"; @@ -68,13 +69,15 @@ export async function select( readline.emitKeypressEvents(process.stdin); cursor.hide(); - // Track how many lines we've written (for clearing) - let renderedLines = 0; + // Store last output string so we can recalculate physical lines + // at the CURRENT terminal width (critical for resize handling). + let lastOutput = ""; const render = () => { - // Clear previous render - if (renderedLines > 0) { - line.clearLines(renderedLines); + // Clear previous render — recalculate at current width in case + // the terminal was resized since the last render + if (lastOutput) { + line.clearLines(countPhysicalLines(lastOutput)); } const lines: string[] = []; @@ -94,14 +97,22 @@ export async function select( const output = lines.join("\n"); process.stdout.write(output); - renderedLines = lines.length; + lastOutput = output; }; + // Re-render on terminal resize to recalculate physical line counts + const onResize = () => render(); + process.stdout.on("resize", onResize); + const finish = (value: T | typeof CANCEL_SYMBOL) => { process.stdin.removeListener("keypress", onKeypress); + process.stdout.removeListener("resize", onResize); rawCleanup(); // Clear the active render plus any externally-written prefix lines + const renderedLines = lastOutput + ? countPhysicalLines(lastOutput) + : 0; const totalClear = renderedLines + (config.prefixLineCount ?? 0); if (totalClear > 0) { line.clearLines(totalClear); @@ -195,11 +206,11 @@ export async function text( let value = config.initialValue ?? ""; let cursorPos = value.length; let error: string | undefined; - let renderedLines = 0; + let lastOutput = ""; const render = () => { - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastOutput) { + line.clearLines(countPhysicalLines(lastOutput)); } const lines: string[] = []; @@ -225,16 +236,22 @@ export async function text( lines.push(`${indent}${textColors.gitDeleted(error)}`); } - process.stdout.write(lines.join("\n")); - renderedLines = lines.length; + const output = lines.join("\n"); + process.stdout.write(output); + lastOutput = output; }; + // Re-render on terminal resize to recalculate physical line counts + const onResize = () => render(); + process.stdout.on("resize", onResize); + const finish = (result: string | typeof CANCEL_SYMBOL) => { process.stdin.removeListener("keypress", onKeypress); + process.stdout.removeListener("resize", onResize); rawCleanup(); - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastOutput) { + line.clearLines(countPhysicalLines(lastOutput)); } if (result === CANCEL_SYMBOL) { @@ -394,11 +411,11 @@ export async function multiselect( readline.emitKeypressEvents(process.stdin); cursor.hide(); - let renderedLines = 0; + let lastOutput = ""; const render = () => { - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastOutput) { + line.clearLines(countPhysicalLines(lastOutput)); } const lines: string[] = []; @@ -419,16 +436,22 @@ export async function multiselect( // Instruction lines.push(`${indent} ${dim("Space to toggle, Enter to submit")}`); - process.stdout.write(lines.join("\n")); - renderedLines = lines.length; + const output = lines.join("\n"); + process.stdout.write(output); + lastOutput = output; }; + // Re-render on terminal resize to recalculate physical line counts + const onResize = () => render(); + process.stdout.on("resize", onResize); + const finish = (result: ReadonlyArray | typeof CANCEL_SYMBOL) => { process.stdin.removeListener("keypress", onKeypress); + process.stdout.removeListener("resize", onResize); rawCleanup(); - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastOutput) { + line.clearLines(countPhysicalLines(lastOutput)); } if (result === CANCEL_SYMBOL) { diff --git a/src/cli/ui/renderer.ts b/src/cli/ui/renderer.ts index 01594ec..238a492 100644 --- a/src/cli/ui/renderer.ts +++ b/src/cli/ui/renderer.ts @@ -5,6 +5,8 @@ * and ANSI escape helpers. Built on Node.js readline (no dependencies). */ +import { stripVTControlCharacters } from "node:util"; + /** * ANSI cursor control sequences */ @@ -107,3 +109,23 @@ export function enterRawMode(): { cleanup: () => void } { return { cleanup }; } + +/** + * Count the number of physical terminal lines a string occupies, + * accounting for line wrapping at terminal width. + * + * A logical line that exceeds the terminal width wraps onto additional + * physical lines. This function computes the true physical count by + * measuring each logical line's visible width (stripping ANSI codes) + * and dividing by the terminal column count. + */ +export function countPhysicalLines( + content: string, + columns?: number, +): number { + const cols = columns ?? (process.stdout.columns || 80); + return content.split("\n").reduce((total, logicalLine) => { + const visibleLength = stripVTControlCharacters(logicalLine).length; + return total + Math.max(1, Math.ceil(visibleLength / cols)); + }, 0); +} From 8e26ad42ccd69df8bbb09bed7e991a453c085b31 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 17:15:23 -0600 Subject: [PATCH 02/13] style(ui): update color palette to cool-toned theme Replace warm/neon label backgrounds with deep cool tones (indigo, teal, navy, slate-blue, jade) for better white text contrast. Update git status colors to cool variants (sage green, steel blue, muted rose, cool aqua, cool violet) for improved readability on dark terminal backgrounds. --- .changeset/cool-color-palette.md | 5 +++ src/cli/commands/init/colors.ts | 68 ++++++++++++++++++-------------- 2 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 .changeset/cool-color-palette.md diff --git a/.changeset/cool-color-palette.md b/.changeset/cool-color-palette.md new file mode 100644 index 0000000..0e0cbf9 --- /dev/null +++ b/.changeset/cool-color-palette.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": patch +--- + +Update UI color palette to cool-toned theme with improved contrast diff --git a/src/cli/commands/init/colors.ts b/src/cli/commands/init/colors.ts index c25b7e7..e0cd75a 100644 --- a/src/cli/commands/init/colors.ts +++ b/src/cli/commands/init/colors.ts @@ -1,35 +1,35 @@ /** * Custom Color Palette * - * Provides bright, energetic colors using ANSI 256-color codes - * for a modern, high-contrast CLI experience. + * Provides cool-toned, high-contrast colors using ANSI 256-color codes + * for a modern, professional CLI experience. * * Design Philosophy: - * - Vibrant but readable - * - Positive energy with warm, inviting tones - * - High contrast for easy scanning - * - Consistent visual hierarchy + * - Cool-toned palette (indigo, teal, navy, slate, jade) + * - Deep, saturated backgrounds for excellent white text contrast + * - WCAG AA+ contrast ratios with bright white foreground + * - Visually distinct labels for easy scanning */ /** * Distinct background colors for step labels * Each label uses a unique ANSI 256-color for visual distinction. - * Text is bright white (97m) for readability on deeper backgrounds. + * Text is bright white (97m) for readability on deep backgrounds. * - * | Name | ANSI 256 | Hex | Used by | - * |---------|----------|---------|----------| - * | magenta | 134 | #af5fd7 | preset | - * | cyan | 37 | #00afaf | emoji | - * | blue | 33 | #0087ff | signing | - * | yellow | 172 | #d78700 | stage | - * | green | 35 | #00af5f | body | + * | Name | ANSI 256 | Hex | Description | + * |---------|----------|---------|----------------| + * | magenta | 55 | #5f00af | Deep Indigo | + * | cyan | 30 | #008787 | Deep Teal | + * | blue | 25 | #005faf | Deep Navy | + * | yellow | 61 | #5f5faf | Slate Blue | + * | green | 29 | #00875f | Deep Jade | */ export const labelColors = { - bgBrightMagenta: (text: string) => `\x1b[48;5;134m\x1b[97m${text}\x1b[0m`, - bgBrightCyan: (text: string) => `\x1b[48;5;37m\x1b[97m${text}\x1b[0m`, - bgBrightBlue: (text: string) => `\x1b[48;5;33m\x1b[97m${text}\x1b[0m`, - bgBrightYellow: (text: string) => `\x1b[48;5;172m\x1b[97m${text}\x1b[0m`, - bgBrightGreen: (text: string) => `\x1b[48;5;35m\x1b[97m${text}\x1b[0m`, + bgBrightMagenta: (text: string) => `\x1b[48;5;55m\x1b[97m${text}\x1b[0m`, + bgBrightCyan: (text: string) => `\x1b[48;5;30m\x1b[97m${text}\x1b[0m`, + bgBrightBlue: (text: string) => `\x1b[48;5;25m\x1b[97m${text}\x1b[0m`, + bgBrightYellow: (text: string) => `\x1b[48;5;61m\x1b[97m${text}\x1b[0m`, + bgBrightGreen: (text: string) => `\x1b[48;5;29m\x1b[97m${text}\x1b[0m`, }; /** @@ -98,22 +98,30 @@ export const textColors = { tuxGreen: (text: string) => `\x1b[38;5;120m${text}\x1b[0m`, /** - * Git Status Colors - Match git's default color scheme + * Git Status Colors — Cool-toned, readable on dark backgrounds + * + * | Status | ANSI 256 | Hex | Description | + * |----------|----------|---------|------------------| + * | Added | 114 | #87d787 | Cool sage green | + * | Modified | 110 | #87afd7 | Steel blue | + * | Deleted | 174 | #d78787 | Muted rose | + * | Renamed | 80 | #5fd7d7 | Cool aqua | + * | Copied | 141 | #af87ff | Cool violet | */ - // Added (A) - Green (success, positive) - gitAdded: (text: string) => `\x1b[38;5;46m${text}\x1b[0m`, // Bright green + // Added (A) - Cool sage green + gitAdded: (text: string) => `\x1b[38;5;114m${text}\x1b[0m`, - // Modified (M) - Yellow (caution, change) - gitModified: (text: string) => `\x1b[38;5;226m${text}\x1b[0m`, // Bright yellow + // Modified (M) - Steel blue + gitModified: (text: string) => `\x1b[38;5;110m${text}\x1b[0m`, - // Deleted (D) - Red (danger, removal) - gitDeleted: (text: string) => `\x1b[38;5;196m${text}\x1b[0m`, // Bright red + // Deleted (D) - Muted rose + gitDeleted: (text: string) => `\x1b[38;5;174m${text}\x1b[0m`, - // Renamed (R) - Cyan (transformation) - gitRenamed: (text: string) => `\x1b[38;5;51m${text}\x1b[0m`, // Bright cyan + // Renamed (R) - Cool aqua + gitRenamed: (text: string) => `\x1b[38;5;80m${text}\x1b[0m`, - // Copied (C) - Magenta (duplication) - gitCopied: (text: string) => `\x1b[38;5;201m${text}\x1b[0m`, // Bright magenta + // Copied (C) - Cool violet + gitCopied: (text: string) => `\x1b[38;5;141m${text}\x1b[0m`, }; /** From 90d4c7f702773fe54d62e7bcfbfed8cce3e46cb2 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 17:15:32 -0600 Subject: [PATCH 03/13] fix(commit): fix double label, preview spacing, and trailing blank Rename body input-method select label from 'body' to 'input' to avoid duplicate labels in the commit flow. Remove excess blank lines in preview display for tighter layout. Add trailing blank after action prompt for visual separation. --- .changeset/fix-commit-ux-polish.md | 5 +++++ src/cli/commands/commit/prompts.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-commit-ux-polish.md diff --git a/.changeset/fix-commit-ux-polish.md b/.changeset/fix-commit-ux-polish.md new file mode 100644 index 0000000..1f54f99 --- /dev/null +++ b/.changeset/fix-commit-ux-polish.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": patch +--- + +Fix double body label, reduce preview spacing, and add trailing blank after action prompt diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 2871906..0750791 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -460,7 +460,7 @@ export async function promptBody( }); const inputMethod = await ui.select({ - label: "body", + label: "input", labelColor: "yellow", message: "Enter commit body (optional):", options, @@ -550,7 +550,7 @@ export async function promptBody( }); const inputMethod = await ui.select({ - label: "body", + label: "input", labelColor: "yellow", message: `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, options, @@ -1017,18 +1017,15 @@ export async function displayPreview( const displayBody = body; ui.section("preview", "green", "Commit message preview:"); - ui.blank(); ui.indented(textColors.brightCyan(displayMessage)); if (displayBody) { - ui.blank(); const bodyLines = displayBody.split("\n"); for (const bodyLine of bodyLines) { ui.indented(textColors.white(bodyLine)); } } - ui.blank(); ui.divider(); // Process shortcuts for preview prompt @@ -1075,6 +1072,10 @@ export async function displayPreview( console.log("\nCommit cancelled."); process.exit(0); } + + // Trailing spacing after the action prompt for visual separation + ui.blank(); + return action as | "commit" | "edit-type" From be0a56fa2c89cbedb55615a7167049ff203c99a1 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 17:15:38 -0600 Subject: [PATCH 04/13] fix(commit): only show missing fields when CLI params are provided Suppress the 'Missing required fields' diagnostic message in pure interactive mode where no CLI parameters are given. The message now only appears when partial parameters are provided, matching the expected UX where interactive prompts guide the user without noise. --- .changeset/fix-missing-fields-display.md | 5 +++ src/cli/commands/commit/index.ts | 55 ++++++++++++++---------- 2 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-missing-fields-display.md diff --git a/.changeset/fix-missing-fields-display.md b/.changeset/fix-missing-fields-display.md new file mode 100644 index 0000000..6d55f76 --- /dev/null +++ b/.changeset/fix-missing-fields-display.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": patch +--- + +Only show missing required fields message when partial CLI parameters are provided diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index 33fe6ba..1af76bf 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -417,30 +417,39 @@ export async function commitAction(options: { const gitStatus = getGitStatus(alreadyStagedFiles); await displayStagedFiles(gitStatus); - // Show what's provided vs missing (helpful context) - if (requiredCheck.provided.length > 0) { - console.log(); - console.log("Provided:"); - requiredCheck.provided.forEach((field) => { - const value = - field === "type" - ? options.type - : field === "scope" - ? options.scope - : field === "subject" - ? options.message - : ""; - console.log(` • ${field}: ${value}`); - }); - } + // Show provided/missing context ONLY when partial CLI params were given. + // Pure interactive mode (no params) skips this — the prompts speak for themselves. + const hasAnyCliParam = + options.type !== undefined || + options.scope !== undefined || + options.message !== undefined || + options.body !== undefined; + + if (hasAnyCliParam) { + if (requiredCheck.provided.length > 0) { + console.log(); + console.log("Provided:"); + requiredCheck.provided.forEach((field) => { + const value = + field === "type" + ? options.type + : field === "scope" + ? options.scope + : field === "subject" + ? options.message + : ""; + console.log(` • ${field}: ${value}`); + }); + } - if (requiredCheck.missing.length > 0) { - console.log(); - console.log("Missing required fields:"); - requiredCheck.missing.forEach((field) => { - console.log(` • ${field}`); - }); - console.log(); + if (requiredCheck.missing.length > 0) { + console.log(); + console.log("Missing required fields:"); + requiredCheck.missing.forEach((field) => { + console.log(` • ${field}`); + }); + console.log(); + } } // Use same flow as interactive, but skip provided fields From d87cea562c31da13e9113207d8794f5a014b2c14 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 17:15:44 -0600 Subject: [PATCH 05/13] docs(ci): add npm publish troubleshooting notes Add guidance for resolving E404 and expired token errors during npm publish in both the release workflow comments and the README publishing section. --- .github/workflows/release.yml | 8 ++++++-- README.md | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 244b0d4..a39b889 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,9 @@ +# Release workflow: versions via changesets and publishes to npm. +# +# If publish fails with E404 or "Access token expired or revoked": +# 1. Ensure npm organization "labcatr" exists (npmjs.com) and the publishing user can publish there. +# 2. Create a new npm token (Profile → Access Tokens) and set GitHub secret NPM_AUTH_TOKEN to it. +# Disable "2FA for package publishing" on npm if using a classic token for CI. name: Release on: @@ -50,7 +56,5 @@ jobs: commit: "[ci] release" title: "[ci] release" env: - # Uses built-in GITHUB_TOKEN (automatically available, no secret needed) GITHUB_TOKEN: ${{ secrets.LAB_ACTIONS_TOKEN }} - # Needs access to publish to npm NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/README.md b/README.md index 4ec55e1..379c43d 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,13 @@ pnpm run dev:cli test status See [`TESTING.md`](TESTING.md) for complete testing documentation. +### Publishing (maintainers) + +Releases run on push to `main` via `.github/workflows/release.yml` and publish to npm. If publish fails with **E404** or **"Access token expired or revoked"**: + +1. **npm organization** – The scope `@labcatr` must exist on npm. Create an organization named `labcatr` at [npmjs.com](https://www.npmjs.com/) and ensure the publishing user can publish there. +2. **GitHub secret** – Create a new npm token (npm Profile → Access Tokens). In the repo set **Settings → Secrets and variables → Actions** → `NPM_AUTH_TOKEN` to that token. If 2FA is required for publishing, use a token type that supports it or temporarily disable 2FA for package publish. + --- ## Contributing From ead549a15fc95642a279c11cb3ad5819160508f5 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:10 -0600 Subject: [PATCH 06/13] feat(commit): add git helpers for file picker Add ChangedFileInfo type and new git operations: - stageFiles() for staging specific files - getChangedFiles() for listing all unstaged/untracked files - getFileDiff() for per-file diff output - Export getStagedFiles, getUnstagedTrackedFiles, hasUntrackedFiles These form the data layer for the interactive file picker. --- src/cli/commands/commit/git.ts | 131 +++++++++++++++++++++++++++++-- src/cli/commands/commit/types.ts | 16 ++++ 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/commit/git.ts b/src/cli/commands/commit/git.ts index 3d45a95..30ac95c 100644 --- a/src/cli/commands/commit/git.ts +++ b/src/cli/commands/commit/git.ts @@ -11,7 +11,7 @@ import { execSync, spawnSync } from "child_process"; import { Logger } from "../../../lib/logger.js"; -import type { StagedFileInfo, GitStatus } from "./types.js"; +import type { StagedFileInfo, GitStatus, ChangedFileInfo } from "./types.js"; /** * Execute git command and return stdout @@ -58,9 +58,9 @@ export function isGitRepository(): boolean { } /** - * Get staged files (files already staged before auto-stage) + * Get staged file paths (names only, no status info) */ -function getStagedFiles(): string[] { +export function getStagedFiles(): string[] { try { const output = execGit(["diff", "--cached", "--name-only"]); return output ? output.split("\n").filter((f) => f.trim()) : []; @@ -72,7 +72,7 @@ function getStagedFiles(): string[] { /** * Get unstaged tracked files (modified/deleted) */ -function getUnstagedTrackedFiles(): string[] { +export function getUnstagedTrackedFiles(): string[] { try { const output = execGit(["diff", "--name-only"]); return output ? output.split("\n").filter((f) => f.trim()) : []; @@ -84,7 +84,7 @@ function getUnstagedTrackedFiles(): string[] { /** * Check if there are untracked files */ -function hasUntrackedFiles(): boolean { +export function hasUntrackedFiles(): boolean { try { const output = execGit(["ls-files", "--others", "--exclude-standard"]); return output.trim().length > 0; @@ -292,3 +292,124 @@ export function unstageFiles(files: string[]): void { if (files.length === 0) return; execGit(["reset", "HEAD", "--", ...files]); } + +/** + * Stage specific files + */ +export function stageFiles(files: ReadonlyArray): void { + if (files.length === 0) return; + execGit(["add", "--", ...files]); +} + +/** + * Get all changed files (unstaged tracked + untracked) + * Returns file info suitable for the interactive file picker. + */ +export function getChangedFiles(): ChangedFileInfo[] { + const files: ChangedFileInfo[] = []; + + // 1. Unstaged tracked files (modified, deleted, renamed, copied) + try { + const statusOutput = execGit(["diff", "--name-status"]); + const statsOutput = execGit(["diff", "--numstat", "--format="]); + + const statsMap = new Map(); + if (statsOutput) { + for (const line of statsOutput.split("\n").filter((l) => l.trim())) { + const parts = line.split(/\s+/); + if (parts.length >= 3) { + const additions = parseInt(parts[0], 10) || 0; + const deletions = parseInt(parts[1], 10) || 0; + const path = parts.slice(2).join(" "); + statsMap.set(path, { additions, deletions }); + } + } + } + + if (statusOutput) { + for (const line of statusOutput.split("\n").filter((l) => l.trim())) { + let match = line.match(/^([MAD])\s+(.+)$/); + let statusCode: string; + let path: string; + + if (match) { + [, statusCode, path] = match; + } else { + match = line.match(/^([RC])(?:\d+)?\s+(.+)\t(.+)$/); + if (match) { + [, statusCode, , path] = match; + } else { + continue; + } + } + + const stats = statsMap.get(path); + let status: ChangedFileInfo["status"] = "M"; + if (statusCode === "A") status = "A"; + else if (statusCode === "D") status = "D"; + else if (statusCode === "R") status = "R"; + else if (statusCode === "C") status = "C"; + + files.push({ + path, + status, + additions: stats?.additions, + deletions: stats?.deletions, + isUntracked: false, + }); + } + } + } catch { + // Continue with whatever we have + } + + // 2. Untracked files + try { + const untrackedOutput = execGit([ + "ls-files", + "--others", + "--exclude-standard", + ]); + if (untrackedOutput) { + for (const path of untrackedOutput.split("\n").filter((l) => l.trim())) { + files.push({ + path, + status: "A", + isUntracked: true, + }); + } + } + } catch { + // Continue with whatever we have + } + + // Sort by path for consistent display + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Get diff output for a single file. + * For tracked files: working tree vs index. + * For untracked files: shows entire file as additions. + * + * Note: Uses /dev/null for untracked diffs (Unix/macOS only). + * Windows is not a supported platform for this CLI. + */ +export function getFileDiff(filePath: string, isUntracked: boolean): string { + try { + if (isUntracked) { + // Untracked file: diff against empty (exit code 1 is expected) + const result = spawnSync( + "git", + ["diff", "--no-index", "--color=always", "--", "/dev/null", filePath], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ); + // --no-index returns exit code 1 when files differ (expected) + return result.stdout?.toString().trim() || "(empty file)"; + } + + return execGit(["diff", "--color=always", "--", filePath]) || "(no changes)"; + } catch { + return "(unable to generate diff)"; + } +} diff --git a/src/cli/commands/commit/types.ts b/src/cli/commands/commit/types.ts index 48f5d17..ea93507 100644 --- a/src/cli/commands/commit/types.ts +++ b/src/cli/commands/commit/types.ts @@ -38,6 +38,22 @@ export interface GitStatus { hasUntracked: boolean; } +/** + * Changed (unstaged/untracked) file information for the file picker + */ +export interface ChangedFileInfo { + /** File path relative to repo root */ + path: string; + /** Git status code: M (modified), A (added), D (deleted), R (renamed), C (copied) */ + status: "M" | "A" | "D" | "R" | "C"; + /** Lines added (undefined if unknown) */ + additions?: number; + /** Lines deleted (undefined if unknown) */ + deletions?: number; + /** Whether this file is untracked (not yet known to git) */ + isUntracked: boolean; +} + /** * Commit state throughout the workflow */ From cc5ed3d1bdd4a9e9a879bb6c9ae6bbb734a31c9f Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:27 -0600 Subject: [PATCH 07/13] feat(shortcuts): extend prompt name union for file picker Widen processShortcuts parameter to accept "files" prompt name and add files mapping to ShortcutsConfigInput interface. --- src/lib/shortcuts/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/shortcuts/index.ts b/src/lib/shortcuts/index.ts index 062442e..47ae622 100644 --- a/src/lib/shortcuts/index.ts +++ b/src/lib/shortcuts/index.ts @@ -25,6 +25,9 @@ export interface ShortcutsConfigInput { body?: { mapping?: Record; }; + files?: { + mapping?: Record; + }; }; } @@ -38,7 +41,7 @@ export interface ShortcutsConfigInput { */ export function processShortcuts( config: ShortcutsConfigInput | undefined, - promptName: "type" | "preview" | "body", + promptName: "type" | "preview" | "body" | "files", options: Array<{ value: string; label: string }>, ): ShortcutMapping | null { // Shortcuts disabled or not configured From b689fd1c4c21f891ede0cf25ae9ad5aefbe236d8 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:35 -0600 Subject: [PATCH 08/13] feat(commit): add interactive file picker with pagination and search Custom file selection prompt for choosing which files to commit: - Sequential hotkeys (a-z, A-Z) for fast navigation - Pagination with n/p keys (10 items per page) - Fuzzy search with / key to filter files - Full-screen diff viewer with d key - Select-all toggle with * key - Cool-toned cyan color scheme for hotkeys and footer - Cursor safety guard via process exit handler --- .changeset/add-file-picker.md | 5 + src/cli/commands/commit/file-picker.ts | 790 +++++++++++++++++++++++++ 2 files changed, 795 insertions(+) create mode 100644 .changeset/add-file-picker.md create mode 100644 src/cli/commands/commit/file-picker.ts diff --git a/.changeset/add-file-picker.md b/.changeset/add-file-picker.md new file mode 100644 index 0000000..3ec2757 --- /dev/null +++ b/.changeset/add-file-picker.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": minor +--- + +Add interactive file picker for selecting files to commit directly from the CLI diff --git a/src/cli/commands/commit/file-picker.ts b/src/cli/commands/commit/file-picker.ts new file mode 100644 index 0000000..b0aaf8a --- /dev/null +++ b/src/cli/commands/commit/file-picker.ts @@ -0,0 +1,790 @@ +/** + * File Picker + * + * Interactive file selection prompt for the commit command. + * Allows users to select which changed files to stage for commit, + * view per-file diffs, and toggle selections with keyboard shortcuts. + * + * Built on the same raw-mode pattern as the core UI prompts, + * with domain-specific keybindings for diff viewing and bulk actions. + * + * Key features: + * - Sequential hotkeys (a-z, A-Z) for fast navigation + * - Pagination with n/p navigation (10 items per page) + * - Fuzzy search with `/` key + * - Diff viewing with `d` key (full-screen, clears on return) + * - Select-all toggle with `*` key + */ + +import readline from "readline"; +import { + cursor, + line, + isTTY, + enterRawMode, + dim, + brightCyan, + countPhysicalLines, +} from "../../ui/renderer.js"; +import { label as renderLabel, spacing, symbols } from "../../ui/theme.js"; +import { textColors } from "../init/colors.js"; +import { CANCEL_SYMBOL } from "../../ui/types.js"; +import { getChangedFiles, getFileDiff } from "./git.js"; +import type { LabcommitrConfig } from "../../../lib/config/types.js"; +import type { ChangedFileInfo } from "./types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum files the picker supports (a-z = 26 + A-Z = 26 = 52) */ +const MAX_FILES = 52; + +/** Items displayed per page */ +const PAGE_SIZE = 10; + +/** Characters for sequential hotkey assignment: a-z then A-Z */ +const HOTKEY_CHARS = [ + ..."abcdefghijklmnopqrstuvwxyz", + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", +]; + +/** Keys reserved for actions (cannot be used as file hotkeys). + * d/D = diff view, n/p = page navigation, j/k = up/down navigation. */ +const RESERVED_KEYS = new Set(["d", "D", "n", "p", "j", "k"]); + +// --------------------------------------------------------------------------- +// Hotkey assignment +// --------------------------------------------------------------------------- + +/** + * Assign sequential hotkeys to files. + * + * Uses a-z (lowercase) then A-Z (uppercase) in order, + * skipping any reserved action keys. + * + * @returns Map from file index → hotkey character + */ +function assignSequentialHotkeys( + fileCount: number, +): ReadonlyMap { + const map = new Map(); + let hotkeyIndex = 0; + + for (let fileIndex = 0; fileIndex < fileCount; fileIndex++) { + // Skip reserved keys + while ( + hotkeyIndex < HOTKEY_CHARS.length && + RESERVED_KEYS.has(HOTKEY_CHARS[hotkeyIndex]) + ) { + hotkeyIndex++; + } + if (hotkeyIndex >= HOTKEY_CHARS.length) break; + map.set(fileIndex, HOTKEY_CHARS[hotkeyIndex]); + hotkeyIndex++; + } + + return map; +} + +/** + * Build reverse mapping: hotkey character → file index. + */ +function buildHotkeyLookup( + hotkeys: ReadonlyMap, +): ReadonlyMap { + const lookup = new Map(); + for (const [index, key] of hotkeys) { + lookup.set(key, index); + } + return lookup; +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/** + * Clear terminal screen + */ +function clearTerminal(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1B[2J"); + process.stdout.write("\x1B[H"); + } +} + +/** + * Colorize a git status code for display + */ +function colorStatusCode(status: string, isUntracked: boolean): string { + if (isUntracked) return textColors.gitAdded("?"); + switch (status) { + case "A": + return textColors.gitAdded(status); + case "M": + return textColors.gitModified(status); + case "D": + return textColors.gitDeleted(status); + case "R": + return textColors.gitRenamed(status); + case "C": + return textColors.gitCopied(status); + default: + return status; + } +} + +/** + * Format line stats for display + */ +function formatStats(additions?: number, deletions?: number): string { + if (additions === undefined && deletions === undefined) return ""; + const parts: string[] = []; + if (additions !== undefined && additions > 0) parts.push(`+${additions}`); + if (deletions !== undefined && deletions > 0) parts.push(`-${deletions}`); + if (parts.length === 0) return ""; + return dim(` (${parts.join(" ")} lines)`); +} + +/** + * Simple fuzzy match: checks if all characters of the pattern + * appear in order within the target string (case-insensitive). + */ +function fuzzyMatch(pattern: string, target: string): boolean { + const lowerPattern = pattern.toLowerCase(); + const lowerTarget = target.toLowerCase(); + let patternIdx = 0; + + for ( + let targetIdx = 0; + targetIdx < lowerTarget.length && patternIdx < lowerPattern.length; + targetIdx++ + ) { + if (lowerTarget[targetIdx] === lowerPattern[patternIdx]) { + patternIdx++; + } + } + + return patternIdx === lowerPattern.length; +} + +// --------------------------------------------------------------------------- +// Diff viewer +// --------------------------------------------------------------------------- + +/** + * View diff for a single file in full-screen mode. + * Clears terminal, shows diff, waits for any keypress to return. + */ +async function viewFileDiff(file: ChangedFileInfo): Promise { + clearTerminal(); + + const statusDisplay = colorStatusCode(file.status, file.isUntracked); + console.log( + `\n${textColors.brightWhite("Diff for:")} ${statusDisplay} ${textColors.brightCyan(file.path)}\n`, + ); + + const diff = getFileDiff(file.path, file.isUntracked); + console.log(diff); + + console.log(`\n${textColors.white("Press any key to go back...")}`); + + await new Promise((resolve) => { + const { cleanup } = enterRawMode(); + process.stdin.once("data", () => { + cleanup(); + resolve(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Main file picker prompt +// --------------------------------------------------------------------------- + +/** + * Interactive file picker prompt. + * + * Displays all changed files with multiselect, diff viewing, + * sequential hotkey navigation, pagination, and fuzzy search. + * + * @param _config - Labcommitr configuration (reserved for future use) + * @param previousSelections - File paths to pre-select (for edit-files flow) + * @returns Selected file paths or CANCEL_SYMBOL + */ +export async function promptFilePicker( + _config: LabcommitrConfig, + previousSelections?: ReadonlyArray, +): Promise | typeof CANCEL_SYMBOL> { + // Non-TTY fallback + if (!isTTY()) { + return CANCEL_SYMBOL; + } + + const files = getChangedFiles(); + + if (files.length === 0) { + console.log( + `\n${textColors.brightYellow("No modified or untracked files found.")}`, + ); + console.log( + " All files are already committed or there are no changes.\n", + ); + return CANCEL_SYMBOL; + } + + // Enforce maximum file limit + if (files.length > MAX_FILES) { + console.log( + `\n${textColors.brightYellow(`Too many changed files (${files.length}).`)}`, + ); + console.log( + ` The file picker supports up to ${MAX_FILES} files.`, + ); + console.log( + " Please stage files manually with 'git add ' first.\n", + ); + return CANCEL_SYMBOL; + } + + // Assign sequential hotkeys + const hotkeys = assignSequentialHotkeys(files.length); + const hotkeyLookup = buildHotkeyLookup(hotkeys); + + // Build initial selection set from previous selections + const previousSet = new Set(previousSelections ?? []); + + // Mutable state shared across raw-mode sessions. + // NOTE: `selected` is intentionally mutable — it is shared by reference across + // picker ↔ diff round-trips so selections survive across sessions. + // This is a deliberate exception to the project immutability convention. + let cursorIndex = 0; + const selected = new Set(); + let error: string | undefined; + + // Pre-select files from previous selections + for (let i = 0; i < files.length; i++) { + if (previousSet.has(files[i].path)) { + selected.add(i); + } + } + + // Loop: picker → diff → picker → … until submit or cancel + // This avoids the complexity of re-entering raw mode mid-callback. + // eslint-disable-next-line no-constant-condition + while (true) { + const action = await runPickerSession( + files, + hotkeys, + hotkeyLookup, + cursorIndex, + selected, + error, + ); + + if (action.type === "cancel") { + return CANCEL_SYMBOL; + } + + if (action.type === "submit") { + return action.paths; + } + + if (action.type === "diff") { + // Update cursor position for when we return + cursorIndex = action.cursorIndex; + error = undefined; + + // Show diff in full-screen (uses its own raw mode session) + await viewFileDiff(files[action.cursorIndex]); + + // Clear terminal before re-rendering picker + clearTerminal(); + + // Loop continues → re-renders picker + continue; + } + } +} + +// --------------------------------------------------------------------------- +// Picker session types +// --------------------------------------------------------------------------- + +type PickerAction = + | { type: "cancel" } + | { type: "submit"; paths: ReadonlyArray } + | { type: "diff"; cursorIndex: number }; + +// --------------------------------------------------------------------------- +// Single picker session (one raw-mode lifecycle) +// --------------------------------------------------------------------------- + +/** + * Run one interactive session of the file picker. + * Returns when user submits, cancels, or requests a diff view. + */ +function runPickerSession( + files: ReadonlyArray, + hotkeys: ReadonlyMap, + hotkeyLookup: ReadonlyMap, + initialCursorIndex: number, + selected: Set, + initialError: string | undefined, +): Promise { + return new Promise((resolve) => { + const { cleanup: rawCleanup } = enterRawMode(); + readline.emitKeypressEvents(process.stdin); + cursor.hide(); + + // Safety: ensure cursor is restored even on unexpected exit + const exitGuard = () => cursor.show(); + process.once("exit", exitGuard); + + let cursorIndex = initialCursorIndex; + let error = initialError; + let lastPhysicalLines = 0; // Physical line count at time of render + let currentPage = Math.floor(initialCursorIndex / PAGE_SIZE); + + // Search state + let searchMode = false; + let searchQuery = ""; + let filteredIndices: number[] = []; // Indices into `files` + let filteredCursor = 0; // Cursor within filtered list + + const totalPages = Math.ceil(files.length / PAGE_SIZE); + + /** + * Get visible file indices for the current page. + * In search mode, returns filtered indices. + * In normal mode, returns the current page slice. + */ + const getVisibleIndices = (): number[] => { + if (searchMode && searchQuery.length > 0) { + return filteredIndices; + } + const start = currentPage * PAGE_SIZE; + const end = Math.min(start + PAGE_SIZE, files.length); + const indices: number[] = []; + for (let i = start; i < end; i++) { + indices.push(i); + } + return indices; + }; + + const render = () => { + // Clear previous render using the line count captured at the previous + // render's terminal width — NOT recalculated at current width. + // This prevents ghost lines when the terminal is resized between renders. + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); + } + + const lines: string[] = []; + const indent = " ".repeat(spacing.optionIndent); + + // Header + const selectedCount = selected.size; + const headerMsg = + selectedCount > 0 + ? `Select files to commit (${files.length} changed, ${selectedCount} selected):` + : `Select files to commit (${files.length} changed):`; + lines.push( + `${renderLabel("files", "green")} ${textColors.pureWhite(headerMsg)}`, + ); + + // Search bar (if active) + if (searchMode) { + lines.push( + `${indent} ${textColors.brightCyan("/")} ${textColors.pureWhite(searchQuery)}${textColors.brightCyan("█")}`, + ); + } + + const visibleIndices = getVisibleIndices(); + + if (visibleIndices.length === 0 && searchMode && searchQuery.length > 0) { + lines.push(`${indent} ${dim("No files match your search")}`); + } + + // File list + for (let vi = 0; vi < visibleIndices.length; vi++) { + const fileIndex = visibleIndices[vi]; + const file = files[fileIndex]; + + const isActive = searchMode + ? vi === filteredCursor + : fileIndex === cursorIndex; + const isSelected = selected.has(fileIndex); + const pointer = isActive ? symbols.pointer : " "; + const marker = isSelected ? symbols.bullet : symbols.circle; + + const statusChar = colorStatusCode(file.status, file.isUntracked); + const hotkey = hotkeys.get(fileIndex); + const hotkeyDisplay = hotkey + ? textColors.brightCyan(`[${hotkey}]`) + " " + : " "; + + const pathDisplay = isActive ? brightCyan(file.path) : file.path; + const stats = formatStats(file.additions, file.deletions); + + lines.push( + `${indent}${pointer} ${marker} ${statusChar} ${hotkeyDisplay}${pathDisplay}${stats}`, + ); + } + + // Pagination indicator (only in normal mode with multiple pages) + if (!searchMode && totalPages > 1) { + lines.push(""); + lines.push( + `${indent} ${dim(`Page ${currentPage + 1} of ${totalPages}`)}`, + ); + } + + // Blank line before footer + lines.push(""); + + // Footer — styled like preview command with cool-toned key colors + if (searchMode) { + const hints: string[] = []; + hints.push( + `${textColors.brightCyan("Type")} ${textColors.white("to filter")}`, + ); + hints.push( + `${textColors.brightCyan("Enter")} ${textColors.white("to confirm")}`, + ); + hints.push( + `${textColors.brightCyan("Esc")} ${textColors.white("to cancel search")}`, + ); + lines.push(`${indent} ${textColors.white("Press")} ${hints.join(", ")}`); + } else { + const hints: string[] = []; + hints.push( + `${textColors.brightCyan("Space")} ${textColors.white("to toggle")}`, + ); + hints.push( + `${textColors.brightCyan("d")} ${textColors.white("to view diff")}`, + ); + hints.push( + `${textColors.brightCyan("*")} ${textColors.white("to select all")}`, + ); + hints.push( + `${textColors.brightCyan("/")} ${textColors.white("to search")}`, + ); + if (totalPages > 1) { + hints.push( + `${textColors.brightCyan("n/p")} ${textColors.white("to page")}`, + ); + } + hints.push( + `${textColors.brightCyan("Enter")} ${textColors.white("to submit")}`, + ); + lines.push(`${indent} ${textColors.white("Press")} ${hints.join(", ")}`); + } + + // Error line + if (error) { + lines.push(`${indent} ${textColors.gitDeleted(error)}`); + } + + const output = lines.join("\n"); + process.stdout.write(output); + lastPhysicalLines = countPhysicalLines(output); + }; + + const onResize = () => render(); + process.stdout.on("resize", onResize); + + const finish = (action: PickerAction) => { + process.stdin.removeListener("keypress", onKeypress); + process.stdout.removeListener("resize", onResize); + process.removeListener("exit", exitGuard); + rawCleanup(); + + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); + } + + // Only write collapsed line for terminal actions (cancel/submit) + if (action.type === "cancel") { + const headerLine = `${renderLabel("files", "green")} ${textColors.pureWhite("Select files to commit")}`; + process.stdout.write(headerLine + "\n"); + } else if (action.type === "submit") { + const count = action.paths.length; + const summary = `${count} file${count !== 1 ? "s" : ""} selected`; + process.stdout.write( + `${renderLabel("files", "green")} ${textColors.brightCyan(summary)}\n`, + ); + } + // For "diff", don't write anything — we'll clear the screen next + + resolve(action); + }; + + /** + * Update filtered indices based on current search query. + */ + const updateSearch = () => { + if (searchQuery.length === 0) { + filteredIndices = []; + filteredCursor = 0; + return; + } + + filteredIndices = []; + for (let i = 0; i < files.length; i++) { + if (fuzzyMatch(searchQuery, files[i].path)) { + filteredIndices.push(i); + } + } + filteredCursor = Math.min(filteredCursor, Math.max(0, filteredIndices.length - 1)); + }; + + const onKeypress = (_char: string | undefined, key: readline.Key) => { + const char = _char; + + // ── Search mode input handling ── + if (searchMode) { + // Cancel search: Escape + if (key.name === "escape") { + searchMode = false; + searchQuery = ""; + filteredIndices = []; + filteredCursor = 0; + error = undefined; + render(); + return; + } + + // Ctrl+C always cancels entirely + if (key.ctrl && key.name === "c") { + searchMode = false; + finish({ type: "cancel" }); + return; + } + + // Backspace + if (key.name === "backspace") { + if (searchQuery.length > 0) { + searchQuery = searchQuery.slice(0, -1); + updateSearch(); + } + if (searchQuery.length === 0) { + // Exit search mode if query is empty + searchMode = false; + filteredIndices = []; + filteredCursor = 0; + } + render(); + return; + } + + // Navigate filtered list + if (key.name === "up") { + filteredCursor = filteredCursor <= 0 + ? Math.max(0, filteredIndices.length - 1) + : filteredCursor - 1; + render(); + return; + } + if (key.name === "down") { + filteredCursor = filteredCursor >= filteredIndices.length - 1 + ? 0 + : filteredCursor + 1; + render(); + return; + } + + // Toggle selection of highlighted item in search + if (key.name === "space" || (char === " " && !key.ctrl)) { + if (filteredIndices.length > 0) { + const fileIndex = filteredIndices[filteredCursor]; + if (selected.has(fileIndex)) { + selected.delete(fileIndex); + } else { + selected.add(fileIndex); + } + } + error = undefined; + render(); + return; + } + + // Submit from search: Enter confirms search and exits search mode + if (key.name === "return") { + if (filteredIndices.length > 0) { + // Move main cursor to the highlighted search result + cursorIndex = filteredIndices[filteredCursor]; + currentPage = Math.floor(cursorIndex / PAGE_SIZE); + } + searchMode = false; + searchQuery = ""; + filteredIndices = []; + filteredCursor = 0; + error = undefined; + render(); + return; + } + + // Type characters into search + if (char && char.length === 1 && !key.ctrl && !key.meta) { + searchQuery += char; + updateSearch(); + render(); + return; + } + + return; + } + + // ── Normal mode input handling ── + + // Cancel: Escape or Ctrl+C + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + finish({ type: "cancel" }); + return; + } + + // Navigation: Up/Down or k/j + if (key.name === "up" || key.name === "k") { + if (cursorIndex <= currentPage * PAGE_SIZE) { + // At top of page — go to previous page's last item or wrap + if (currentPage > 0) { + currentPage--; + cursorIndex = Math.min( + (currentPage + 1) * PAGE_SIZE - 1, + files.length - 1, + ); + } else { + // Wrap to last page, last item + currentPage = totalPages - 1; + cursorIndex = files.length - 1; + } + } else { + cursorIndex--; + } + error = undefined; + render(); + return; + } + + if (key.name === "down" || key.name === "j") { + const pageEnd = Math.min((currentPage + 1) * PAGE_SIZE - 1, files.length - 1); + if (cursorIndex >= pageEnd) { + // At bottom of page — go to next page or wrap + if (currentPage < totalPages - 1) { + currentPage++; + cursorIndex = currentPage * PAGE_SIZE; + } else { + // Wrap to first page, first item + currentPage = 0; + cursorIndex = 0; + } + } else { + cursorIndex++; + } + error = undefined; + render(); + return; + } + + // Toggle selection: Space + if (key.name === "space" || (char === " " && !key.ctrl)) { + if (selected.has(cursorIndex)) { + selected.delete(cursorIndex); + } else { + selected.add(cursorIndex); + } + error = undefined; + render(); + return; + } + + // View diff: d or D + if ((char === "d" || char === "D") && !key.ctrl && !key.meta) { + finish({ type: "diff", cursorIndex }); + return; + } + + // Toggle all: * (asterisk — not a letter, no conflict with hotkeys) + if (char === "*" && !key.ctrl && !key.meta) { + const allSelected = selected.size === files.length; + if (allSelected) { + selected.clear(); + } else { + for (let i = 0; i < files.length; i++) { + selected.add(i); + } + } + error = undefined; + render(); + return; + } + + // Enter search mode: / + if (char === "/" && !key.ctrl && !key.meta) { + searchMode = true; + searchQuery = ""; + filteredIndices = []; + filteredCursor = 0; + error = undefined; + render(); + return; + } + + // Page navigation: n (next page), p (previous page) + if (char === "n" && !key.ctrl && !key.meta && totalPages > 1) { + if (currentPage < totalPages - 1) { + currentPage++; + } else { + currentPage = 0; + } + cursorIndex = currentPage * PAGE_SIZE; + error = undefined; + render(); + return; + } + + if (char === "p" && !key.ctrl && !key.meta && totalPages > 1) { + if (currentPage > 0) { + currentPage--; + } else { + currentPage = totalPages - 1; + } + cursorIndex = currentPage * PAGE_SIZE; + error = undefined; + render(); + return; + } + + // Submit: Enter + if (key.name === "return") { + if (selected.size === 0) { + error = "Select at least one file"; + render(); + return; + } + const selectedPaths = Array.from(selected) + .sort((a, b) => a - b) + .map((i) => files[i].path); + finish({ type: "submit", paths: selectedPaths }); + return; + } + + // Hotkey navigation (letter key → navigate to that file) + if (char && char.length === 1 && !key.ctrl && !key.meta) { + const fileIndex = hotkeyLookup.get(char); + if (fileIndex !== undefined) { + cursorIndex = fileIndex; + currentPage = Math.floor(fileIndex / PAGE_SIZE); + error = undefined; + render(); + return; + } + } + }; + + process.stdin.on("keypress", onKeypress); + render(); + }); +} From 7a5bf68f81e24de78374a0cc650f6311761971cc Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:41 -0600 Subject: [PATCH 09/13] feat(commit): add edit-files option to staged files display Change displayStagedFiles return type to "continue" | "edit-files" so users can re-select files after reviewing the summary. Also fix double-rendering of newly-staged files when no already-staged exist. --- src/cli/commands/commit/prompts.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 0750791..3880cb4 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -834,7 +834,7 @@ export async function displayStagedFiles(status: { deletions?: number; }>; totalStaged: number; -}): Promise { +}): Promise<"continue" | "edit-files"> { ui.section( "files", "green", @@ -937,8 +937,9 @@ export async function displayStagedFiles(status: { ui.blank(); } - // Show newly staged if any - if (status.newlyStaged.length > 0) { + // Show newly staged: labeled section when both exist, flat list otherwise + if (status.newlyStaged.length > 0 && status.alreadyStaged.length > 0) { + // Labeled "Auto-staged" section when separated from "Already staged" const newlyPlural = status.newlyStaged.length !== 1 ? "s" : ""; ui.indented( textColors.brightYellow( @@ -957,17 +958,15 @@ export async function displayStagedFiles(status: { } } ui.blank(); - } - - // If no separation needed, show all together - if (status.alreadyStaged.length === 0 && status.newlyStaged.length > 0) { + } else if (status.newlyStaged.length > 0) { + // Flat list when no separation needed (only newly-staged files) const groups = groupByStatus(status.newlyStaged); for (const [statusCode, files] of Object.entries(groups)) { if (files.length > 0) { ui.indented(` ${formatStatusName(statusCode)} (${files.length}):`); for (const file of files) { ui.indented( - ` ${file.status} ${file.path}${formatStats(file.additions, file.deletions)}`, + ` ${colorStatusCode(file.status)} ${file.path}${formatStats(file.additions, file.deletions)}`, ); } } @@ -977,23 +976,22 @@ export async function displayStagedFiles(status: { ui.divider(); - // Simple keypress wait instead of single-option select hack - const confirmation = await ui.select({ + const action = await ui.select({ label: "files", labelColor: "green", - message: "Press Enter to continue, Esc to cancel", + message: "Continue or edit file selection:", options: [ - { - value: "continue", - label: "Continue", - }, + { value: "continue", label: "Continue" }, + { value: "edit-files", label: "Edit files", hint: "re-select" }, ], }); - if (ui.isCancel(confirmation)) { + if (ui.isCancel(action)) { console.log("\nCommit cancelled."); process.exit(0); } + + return action as "continue" | "edit-files"; } /** From 78da4d34366375023bd482b908477c64cb88c2d0 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:46 -0600 Subject: [PATCH 10/13] feat(commit): wire file picker into commit workflow Replace error exit on no-staged-files with interactive file picker. Add edit-files loop in staged files display for re-selection. Replace all direct execSync calls with git.ts module functions. --- src/cli/commands/commit/index.ts | 176 +++++++++++++++++-------------- 1 file changed, 95 insertions(+), 81 deletions(-) diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index 1af76bf..fceca28 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -19,10 +19,16 @@ import { isGitRepository } from "./git.js"; import { stageAllTrackedFiles, hasStagedFiles, + getStagedFiles, + getUnstagedTrackedFiles, + hasUntrackedFiles, getGitStatus, createCommit, unstageFiles, + stageFiles, } from "./git.js"; +import { promptFilePicker } from "./file-picker.js"; +import { ui } from "../../ui/index.js"; import { promptType, promptScope, @@ -211,98 +217,79 @@ export async function commitAction(options: { // Get already staged files (before we do anything) if (autoStageEnabled) { // Check what's already staged - const { execSync } = await import("child_process"); - try { - const stagedOutput = execSync("git diff --cached --name-only", { - encoding: "utf-8", - }).trim(); - alreadyStagedFiles = stagedOutput - ? stagedOutput.split("\n").filter((f) => f.trim()) - : []; - } catch { - alreadyStagedFiles = []; - } + alreadyStagedFiles = getStagedFiles(); // Check if there are unstaged tracked files - try { - const unstagedOutput = execSync("git diff --name-only", { - encoding: "utf-8", - }).trim(); - const hasUnstagedTracked = unstagedOutput.length > 0; - - if (!hasUnstagedTracked && alreadyStagedFiles.length === 0) { - // Check for untracked files - try { - const untrackedOutput = execSync( - "git ls-files --others --exclude-standard", - { encoding: "utf-8" }, - ).trim(); - const hasUntracked = untrackedOutput.length > 0; - - if (hasUntracked) { - console.error("\n⚠ No tracked files to stage"); - console.error( - "\n Only untracked files exist. Stage them manually with 'git add '\n", - ); - process.exit(1); - } else { - console.error("\n⚠ No modified files to stage"); - console.error( - "\n All files are already committed or there are no changes.", - ); - console.error(" Nothing to commit.\n"); - process.exit(1); - } - } catch { - console.error("\n⚠ No modified files to stage"); - console.error( - "\n All files are already committed or there are no changes.", - ); - console.error(" Nothing to commit.\n"); - process.exit(1); - } - return; + const unstagedFiles = getUnstagedTrackedFiles(); + const hasUnstagedTracked = unstagedFiles.length > 0; + + if (!hasUnstagedTracked && alreadyStagedFiles.length === 0) { + // Nothing tracked to stage — check for untracked files + if (hasUntrackedFiles()) { + console.error("\n⚠ No tracked files to stage"); + console.error( + "\n Only untracked files exist. Stage them manually with 'git add '\n", + ); + process.exit(1); + } else { + console.error("\n⚠ No modified files to stage"); + console.error( + "\n All files are already committed or there are no changes.", + ); + console.error(" Nothing to commit.\n"); + process.exit(1); } + return; + } - // Stage remaining files - if (hasUnstagedTracked) { - console.log("◐ Staging files..."); - if (alreadyStagedFiles.length > 0) { - console.log( - ` Found ${alreadyStagedFiles.length} file${alreadyStagedFiles.length !== 1 ? "s" : ""} already staged, ${unstagedOutput.split("\n").filter((f) => f.trim()).length} file${unstagedOutput.split("\n").filter((f) => f.trim()).length !== 1 ? "s" : ""} unstaged`, - ); - } - newlyStagedFiles = stageAllTrackedFiles(); + // Stage remaining files + if (hasUnstagedTracked) { + console.log("◐ Staging files..."); + if (alreadyStagedFiles.length > 0) { console.log( - `✓ Staged ${newlyStagedFiles.length} file${newlyStagedFiles.length !== 1 ? "s" : ""}${alreadyStagedFiles.length > 0 ? " (preserved existing staging)" : ""}`, + ` Found ${alreadyStagedFiles.length} file${alreadyStagedFiles.length !== 1 ? "s" : ""} already staged, ${unstagedFiles.length} file${unstagedFiles.length !== 1 ? "s" : ""} unstaged`, ); } - } catch { - // Error getting unstaged files, continue + newlyStagedFiles = stageAllTrackedFiles(); + console.log( + `✓ Staged ${newlyStagedFiles.length} file${newlyStagedFiles.length !== 1 ? "s" : ""}${alreadyStagedFiles.length > 0 ? " (preserved existing staging)" : ""}`, + ); } } else { // auto_stage: false - check if anything is staged if (!hasStagedFiles()) { - console.error("\n✗ Error: No files staged for commit"); - console.error("\n Nothing has been staged. Please stage files first:"); - console.error(" • Use 'git add ' to stage specific files"); - console.error(" • Use 'git add -u' to stage all modified files"); - console.error(" • Or enable auto_stage in your config\n"); - process.exit(1); + // No files staged — offer file picker or cancel + clearTerminal(); + + const noStagedAction = await ui.select({ + label: "files", + labelColor: "green", + message: "No files staged for commit", + options: [ + { value: "select", label: "Select files to commit" }, + { value: "cancel", label: "Cancel" }, + ], + }); + + if (ui.isCancel(noStagedAction) || noStagedAction === "cancel") { + console.log("\nCommit cancelled."); + process.exit(0); + } + + // Show interactive file picker + const selectedFiles = await promptFilePicker(config); + if (ui.isCancel(selectedFiles)) { + console.log("\nCommit cancelled."); + process.exit(0); + } + + // Stage selected files + stageFiles(selectedFiles as ReadonlyArray); + newlyStagedFiles = [...(selectedFiles as ReadonlyArray)]; } // Get already staged files for tracking - const { execSync } = await import("child_process"); - try { - const stagedOutput = execSync("git diff --cached --name-only", { - encoding: "utf-8", - }).trim(); - alreadyStagedFiles = stagedOutput - ? stagedOutput.split("\n").filter((f) => f.trim()) - : []; - } catch { - alreadyStagedFiles = []; - } + alreadyStagedFiles = getStagedFiles(); } // Step 4: Check if all required fields are provided @@ -413,9 +400,36 @@ export async function commitAction(options: { // Clear terminal for clean interactive prompt display clearTerminal(); - // Step 4: Display staged files verification and wait for confirmation - const gitStatus = getGitStatus(alreadyStagedFiles); - await displayStagedFiles(gitStatus); + // Step 4: Display staged files verification with edit-files loop + let previousSelections = [...alreadyStagedFiles, ...newlyStagedFiles]; + let fileAction: "continue" | "edit-files"; + + do { + const gitStatus = getGitStatus(alreadyStagedFiles); + fileAction = await displayStagedFiles(gitStatus); + + if (fileAction === "edit-files") { + // Unstage everything so the picker sees them as changed files + const allStaged = [...alreadyStagedFiles, ...newlyStagedFiles]; + unstageFiles(allStaged); + + // Re-show picker with previous selections preserved + const reselected = await promptFilePicker(config, previousSelections); + if (ui.isCancel(reselected)) { + console.log("\nCommit cancelled."); + process.exit(0); + } + + // Stage new selections + stageFiles(reselected as ReadonlyArray); + newlyStagedFiles = [...(reselected as ReadonlyArray)]; + alreadyStagedFiles = []; + previousSelections = [...(reselected as ReadonlyArray)]; + + // Clear terminal for clean re-display + clearTerminal(); + } + } while (fileAction === "edit-files"); // Show provided/missing context ONLY when partial CLI params were given. // Pure interactive mode (no params) skips this — the prompts speak for themselves. From 9eba33ca63836259775c103d2b656a5533ca8f54 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:23:52 -0600 Subject: [PATCH 11/13] fix(ui): store physical line count at render time Replace recalculate-on-clear pattern with captured line count. Previously, countPhysicalLines(lastOutput) was called at clear time using the current terminal width, but content was rendered at the previous width. This caused ghost lines on terminal resize. Fix applied to all three prompts: select, text, multiselect. --- .changeset/fix-resize-ghost-lines-v2.md | 5 +++ src/cli/ui/prompts.ts | 47 +++++++++++-------------- 2 files changed, 26 insertions(+), 26 deletions(-) create mode 100644 .changeset/fix-resize-ghost-lines-v2.md diff --git a/.changeset/fix-resize-ghost-lines-v2.md b/.changeset/fix-resize-ghost-lines-v2.md new file mode 100644 index 0000000..a9023e3 --- /dev/null +++ b/.changeset/fix-resize-ghost-lines-v2.md @@ -0,0 +1,5 @@ +--- +"@labcatr/labcommitr": patch +--- + +Fix terminal resize ghost lines by storing physical line count at render time diff --git a/src/cli/ui/prompts.ts b/src/cli/ui/prompts.ts index b7bacb5..8674bd1 100644 --- a/src/cli/ui/prompts.ts +++ b/src/cli/ui/prompts.ts @@ -69,15 +69,13 @@ export async function select( readline.emitKeypressEvents(process.stdin); cursor.hide(); - // Store last output string so we can recalculate physical lines - // at the CURRENT terminal width (critical for resize handling). - let lastOutput = ""; + // Track physical line count at render time (not recalculated at current + // width) to prevent ghost lines when the terminal is resized between renders. + let lastPhysicalLines = 0; const render = () => { - // Clear previous render — recalculate at current width in case - // the terminal was resized since the last render - if (lastOutput) { - line.clearLines(countPhysicalLines(lastOutput)); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -97,7 +95,7 @@ export async function select( const output = lines.join("\n"); process.stdout.write(output); - lastOutput = output; + lastPhysicalLines = countPhysicalLines(output); }; // Re-render on terminal resize to recalculate physical line counts @@ -110,10 +108,7 @@ export async function select( rawCleanup(); // Clear the active render plus any externally-written prefix lines - const renderedLines = lastOutput - ? countPhysicalLines(lastOutput) - : 0; - const totalClear = renderedLines + (config.prefixLineCount ?? 0); + const totalClear = lastPhysicalLines + (config.prefixLineCount ?? 0); if (totalClear > 0) { line.clearLines(totalClear); } @@ -206,11 +201,11 @@ export async function text( let value = config.initialValue ?? ""; let cursorPos = value.length; let error: string | undefined; - let lastOutput = ""; + let lastPhysicalLines = 0; const render = () => { - if (lastOutput) { - line.clearLines(countPhysicalLines(lastOutput)); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -238,10 +233,10 @@ export async function text( const output = lines.join("\n"); process.stdout.write(output); - lastOutput = output; + lastPhysicalLines = countPhysicalLines(output); }; - // Re-render on terminal resize to recalculate physical line counts + // Re-render on terminal resize const onResize = () => render(); process.stdout.on("resize", onResize); @@ -250,8 +245,8 @@ export async function text( process.stdout.removeListener("resize", onResize); rawCleanup(); - if (lastOutput) { - line.clearLines(countPhysicalLines(lastOutput)); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } if (result === CANCEL_SYMBOL) { @@ -411,11 +406,11 @@ export async function multiselect( readline.emitKeypressEvents(process.stdin); cursor.hide(); - let lastOutput = ""; + let lastPhysicalLines = 0; const render = () => { - if (lastOutput) { - line.clearLines(countPhysicalLines(lastOutput)); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -438,10 +433,10 @@ export async function multiselect( const output = lines.join("\n"); process.stdout.write(output); - lastOutput = output; + lastPhysicalLines = countPhysicalLines(output); }; - // Re-render on terminal resize to recalculate physical line counts + // Re-render on terminal resize const onResize = () => render(); process.stdout.on("resize", onResize); @@ -450,8 +445,8 @@ export async function multiselect( process.stdout.removeListener("resize", onResize); rawCleanup(); - if (lastOutput) { - line.clearLines(countPhysicalLines(lastOutput)); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } if (result === CANCEL_SYMBOL) { From 0c1ae7385dc9fa58428c73b5068f43c1c06c55e6 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:54:00 -0600 Subject: [PATCH 12/13] ci: migrate to OIDC trusted publishing and upgrade actions Replace token-based npm auth with OIDC trusted publishing in release workflow. Upgrade all GitHub Actions to v4 and Node from 18 to 22 (required for npm >= 11.5.1 OIDC support). --- .github/workflows/ci.yml | 14 ++++++------ .github/workflows/release.yml | 41 +++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d04a9a7..cce7fd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ name: Test Compilation on: pull_request: - branches: + branches: - '!main' push: - branches: + branches: - '!main' jobs: @@ -12,17 +12,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PNPM - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: - version: latest + version: latest - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a39b889..e6f2fd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ # Release workflow: versions via changesets and publishes to npm. # -# If publish fails with E404 or "Access token expired or revoked": -# 1. Ensure npm organization "labcatr" exists (npmjs.com) and the publishing user can publish there. -# 2. Create a new npm token (Profile → Access Tokens) and set GitHub secret NPM_AUTH_TOKEN to it. -# Disable "2FA for package publishing" on npm if using a classic token for CI. +# Uses OIDC trusted publishing — no long-lived npm tokens needed. +# Requires trusted publisher configured on npmjs.org: +# https://www.npmjs.com/package/@labcatr/labcommitr/access +# → Trusted Publisher → GitHub Actions +# → Organization: labcatr, Repository: labcommitr, Workflow: release.yml name: Release on: @@ -22,23 +23,23 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - actions: write contents: write pull-requests: write steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PNPM - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v4 with: - version: latest + version: latest - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: "pnpm" + registry-url: "https://registry.npmjs.org" - name: Install dependencies run: pnpm install @@ -46,15 +47,27 @@ jobs: - name: Build Packages run: pnpm run build - - name: Create Release Pull Request or Publish + # Versioning only — publish is handled separately via OIDC + - name: Create Release Pull Request id: changesets uses: changesets/action@v1 with: - # Note: pnpm install after versioning is necessary to refresh lockfile version: pnpm run version - publish: pnpm exec changeset publish commit: "[ci] release" title: "[ci] release" env: GITHUB_TOKEN: ${{ secrets.LAB_ACTIONS_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + + # Publish via OIDC trusted publishing (no npm token required) + - name: Publish to npm + if: steps.changesets.outputs.hasChangesets == 'false' + run: | + npm install -g npm@latest + CURRENT_VERSION=$(node -p "require('./package.json').version") + PUBLISHED=$(npm view @labcatr/labcommitr version 2>/dev/null || echo "") + if [ "$CURRENT_VERSION" != "$PUBLISHED" ]; then + echo "Publishing @labcatr/labcommitr@${CURRENT_VERSION}" + npm publish --provenance --access public + else + echo "Version ${CURRENT_VERSION} already published, skipping." + fi From f08c9c82936ac36894793426d2495f5c4c9c0e46 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Mon, 16 Mar 2026 18:54:06 -0600 Subject: [PATCH 13/13] chore: use object format for repository field Required for npm provenance attestation to correctly link published packages to their GitHub source repository. --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a82ec2..43964de 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "cli", "npmtool" ], - "repository": "https://github.com/labcatr/labcommitr", + "repository": { + "type": "git", + "url": "git+https://github.com/labcatr/labcommitr.git" + }, "homepage": "https://github.com/labcatr/labcommitr#readme", "author": "Trevor Fox", "license": "ISC",