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/.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/.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/.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/.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/.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/.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 244b0d4..e6f2fd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,10 @@ +# Release workflow: versions via changesets and publishes to npm. +# +# 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: @@ -16,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 @@ -40,17 +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: - # 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 }} + + # 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 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 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", 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(); + }); +} 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/index.ts b/src/cli/commands/commit/index.ts index 33fe6ba..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,34 +400,70 @@ 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); - - // 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}`); - }); - } + // Step 4: Display staged files verification with edit-files loop + let previousSelections = [...alreadyStagedFiles, ...newlyStagedFiles]; + let fileAction: "continue" | "edit-files"; - if (requiredCheck.missing.length > 0) { - console.log(); - console.log("Missing required fields:"); - requiredCheck.missing.forEach((field) => { - console.log(` • ${field}`); - }); - console.log(); + 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. + 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(); + } } // Use same flow as interactive, but skip provided fields diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 2871906..3880cb4 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, @@ -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"; } /** @@ -1017,18 +1015,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 +1070,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" 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 */ 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`, }; /** diff --git a/src/cli/ui/prompts.ts b/src/cli/ui/prompts.ts index 68df2a3..8674bd1 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,13 @@ export async function select( readline.emitKeypressEvents(process.stdin); cursor.hide(); - // Track how many lines we've written (for clearing) - let renderedLines = 0; + // 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 - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -94,15 +95,20 @@ export async function select( const output = lines.join("\n"); process.stdout.write(output); - renderedLines = lines.length; + lastPhysicalLines = countPhysicalLines(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 totalClear = renderedLines + (config.prefixLineCount ?? 0); + const totalClear = lastPhysicalLines + (config.prefixLineCount ?? 0); if (totalClear > 0) { line.clearLines(totalClear); } @@ -195,11 +201,11 @@ export async function text( let value = config.initialValue ?? ""; let cursorPos = value.length; let error: string | undefined; - let renderedLines = 0; + let lastPhysicalLines = 0; const render = () => { - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -225,16 +231,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); + lastPhysicalLines = countPhysicalLines(output); }; + // Re-render on terminal resize + 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 (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } if (result === CANCEL_SYMBOL) { @@ -394,11 +406,11 @@ export async function multiselect( readline.emitKeypressEvents(process.stdin); cursor.hide(); - let renderedLines = 0; + let lastPhysicalLines = 0; const render = () => { - if (renderedLines > 0) { - line.clearLines(renderedLines); + if (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } const lines: string[] = []; @@ -419,16 +431,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); + lastPhysicalLines = countPhysicalLines(output); }; + // Re-render on terminal resize + 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 (lastPhysicalLines > 0) { + line.clearLines(lastPhysicalLines); } 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); +} 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