diff --git a/changelog.d/unreleased/1672.fixed.md b/changelog.d/unreleased/1672.fixed.md new file mode 100644 index 0000000000..db06c681ef --- /dev/null +++ b/changelog.d/unreleased/1672.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1672 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs +--- + +## English + +- **Help output wraps long interactive lines to the terminal width (#1672)** — `cdidx --help` now wraps long usage and option descriptions for interactive terminals while preserving the existing fixed output for redirected logs and pipes. + +## 日本語 + +- **対話端末で長い help 行を端末幅に合わせて折り返すようになりました (#1672)** — `cdidx --help` は対話端末では長い usage と option description を折り返し、リダイレクトや pipe では従来の固定出力を維持します。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index bb7e9d89b4..0bb2ccc746 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -613,10 +613,23 @@ public static void PrintUsage(bool showBanner = true) PrintBanner(); } + var helpWidth = ShouldUseInteractiveConsole() ? Math.Min(GetWindowWidth(), 120) : 0; + void WriteHelpLine(string line = "") + { + if (helpWidth <= 0) + { + Console.WriteLine(line); + return; + } + + foreach (var wrapped in WrapHelpLine(line, helpWidth)) + Console.WriteLine(wrapped); + } + Console.WriteLine("Usage:"); Console.WriteLine(" cdidx "); foreach (var (_, usage) in CommandUsageLines) - Console.WriteLine($" {usage}"); + WriteHelpLine($" {usage}"); Console.WriteLine(); Console.WriteLine("Commands:"); Console.WriteLine(" index Build or update the index for a project"); @@ -660,20 +673,20 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" --json Output results as JSON (for AI/machine use)"); Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)"); Console.WriteLine(" --duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms"); - Console.WriteLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); - Console.WriteLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)"); - Console.WriteLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); - Console.WriteLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); + WriteHelpLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); + WriteHelpLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)"); + WriteHelpLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); + WriteHelpLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); Console.WriteLine(" --commits [id ...] Update only files changed in the specified git commits (preferred after commits)"); Console.WriteLine(" --changed-between "); Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); - Console.WriteLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); + WriteHelpLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); Console.WriteLine(" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: 500)"); Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); - Console.WriteLine(" --color Color output: `auto` (default), `always`, or `never`; flag wins over `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR` env vars, which win over TTY auto-detect"); - Console.WriteLine(" --palette ANSI palette: `basic` (8-color, default fallback), `256`, or `truecolor`; flag wins over `CDIDX_COLOR_PALETTE` env var, which wins over `COLORTERM` / `TERM` auto-detect"); - Console.WriteLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)"); + WriteHelpLine(" --color Color output: `auto` (default), `always`, or `never`; flag wins over `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR` env vars, which win over TTY auto-detect"); + WriteHelpLine(" --palette ANSI palette: `basic` (8-color, default fallback), `256`, or `truecolor`; flag wins over `CDIDX_COLOR_PALETTE` env var, which wins over `COLORTERM` / `TERM` auto-detect"); + WriteHelpLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)"); Console.WriteLine(" --metrics Append one JSONL record per CLI command / MCP tool call to (also honors CDIDX_METRICS=)"); Console.WriteLine(" --help, -h Show this help message"); Console.WriteLine(" --version, -V Show version information"); @@ -688,25 +701,25 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(); Console.WriteLine("Query options:"); Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); - Console.WriteLine(" --json Output as JSON (search streams ndjson by default; use search --json=array for one array)"); - Console.WriteLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); - Console.WriteLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); - Console.WriteLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); - Console.WriteLine(" --slow-query-ms Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); + WriteHelpLine(" --json Output as JSON (search streams ndjson by default; use search --json=array for one array)"); + WriteHelpLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); + WriteHelpLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); + WriteHelpLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); + WriteHelpLine(" --slow-query-ms Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); Console.WriteLine(" --limit , --top Max results to return (default: 20)"); Console.WriteLine(" --lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); Console.WriteLine(" --path Restrict matches to glob-style path patterns (* and ?)"); - Console.WriteLine($" --query Pass a query literal, useful when the query starts with '-' (`search` max {QueryLimits.MaxQueryLength} chars)"); + WriteHelpLine($" --query Pass a query literal, useful when the query starts with '-' (`search` max {QueryLimits.MaxQueryLength} chars)"); Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); Console.WriteLine(" --exclude-tests Exclude likely test files"); Console.WriteLine(" --include-generated Include generated files in query results"); Console.WriteLine(" --snippet-lines Search snippet length (1-20, default: 8)"); Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); - Console.WriteLine($" --max-line-width search/references/find/excerpt/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); + WriteHelpLine($" --max-line-width search/references/find/excerpt/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); Console.WriteLine(" --focus-line excerpt: line whose focused column should stay visible (requires --focus-column)"); Console.WriteLine(" --focus-column excerpt: column to keep centered when clamping (must be within the focused line)"); Console.WriteLine(" --focus-length excerpt: width of the focused span (default: 1, requires --focus-column)"); - Console.WriteLine($" --fts Use raw FTS5 query syntax for search (search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); + WriteHelpLine($" --fts Use raw FTS5 query syntax for search (search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); Console.WriteLine(" --exact Backward-compatible shorthand."); Console.WriteLine(" Prefer --exact-substring for search,"); Console.WriteLine(" --exact for find,"); @@ -719,19 +732,19 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" Uses NFKC + Unicode CaseFold when ready."); Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); - Console.WriteLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); + WriteHelpLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); - Console.WriteLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); - Console.WriteLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); + WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); + WriteHelpLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); Console.WriteLine(" --bytes Show raw byte counts in human output for files/map instead of binary units; JSON always keeps raw integer bytes"); - Console.WriteLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); + WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); Console.WriteLine(" --depth Deprecated alias for --max-hops"); Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); Console.WriteLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files into one line"); - Console.WriteLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); - Console.WriteLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); - Console.WriteLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); + WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); + WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); + WriteHelpLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); Console.WriteLine(); Console.WriteLine("Examples:"); Console.WriteLine(" cdidx ./myproject Index a project"); @@ -785,6 +798,59 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" cdidx license Show licensing and commercial-use terms"); } + internal static IReadOnlyList WrapHelpLine(string line, int maxWidth) + { + if (maxWidth <= 0 || line.Length <= maxWidth) + return [line]; + + var continuationIndent = GetHelpContinuationIndent(line); + return WrapLineByWords(line, maxWidth, continuationIndent); + } + + private static string GetHelpContinuationIndent(string line) + { + var leading = 0; + while (leading < line.Length && line[leading] == ' ') + leading++; + + for (var i = leading + 1; i < line.Length - 1; i++) + { + if (line[i] == ' ' && line[i + 1] == ' ') + { + while (i < line.Length && line[i] == ' ') + i++; + if (i < line.Length) + return new string(' ', i); + break; + } + } + + return new string(' ', Math.Min(leading + 2, 8)); + } + + private static IReadOnlyList WrapLineByWords(string line, int maxWidth, string continuationIndent) + { + maxWidth = Math.Max(1, maxWidth); + if (continuationIndent.Length >= maxWidth) + continuationIndent = new string(' ', Math.Max(0, Math.Min(2, maxWidth - 1))); + + var lines = new List(); + var current = line; + while (current.Length > maxWidth) + { + var breakAt = current.LastIndexOf(' ', Math.Min(maxWidth, current.Length - 1)); + if (breakAt <= 0 || current[..breakAt].Trim().Length == 0) + breakAt = maxWidth; + + lines.Add(current[..breakAt].TrimEnd()); + var nextStart = breakAt < current.Length && current[breakAt] == ' ' ? breakAt + 1 : breakAt; + current = continuationIndent + current[nextStart..].TrimStart(); + } + + lines.Add(current); + return lines; + } + public static void PrintLicenseSummary() { Console.WriteLine("cdidx / CodeIndex license"); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index c9189549e7..a0d5b4cb6b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -175,6 +175,43 @@ public void PrintUsage_ShowsCommitUpdateWorkflowClearly() Assert.Contains("cdidx index ./myproject --changed-between main feature", output); } + [Fact] + public void WrapHelpLine_LongOptionDescription_PreservesOptionColumn() + { + const string line = " --max-line-width search/references/find/excerpt/inspect only: clamp very long single-line snippet/context/excerpt payloads"; + + var lines = ConsoleUi.WrapHelpLine(line, maxWidth: 80); + + Assert.All(lines, wrapped => Assert.True(wrapped.Length <= 80, wrapped)); + Assert.StartsWith(" --max-line-width search/references/find/excerpt/inspect", lines[0], StringComparison.Ordinal); + Assert.StartsWith(" very long", lines[1], StringComparison.Ordinal); + } + + [Fact] + public void WrapHelpLine_LongUsageLine_UsesDetectedWidth() + { + const string line = " cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--verbose] [--limit ]"; + + var lines = ConsoleUi.WrapHelpLine(line, maxWidth: 72); + + Assert.True(lines.Count > 1); + Assert.All(lines, wrapped => Assert.True(wrapped.Length <= 72, wrapped)); + } + + [Fact] + public void WrapHelpLine_VeryNarrowWidth_DoesNotEmitEmptyOrOverwideLines() + { + const string line = " --include-symbol-kind [,] Keep only matching symbol kinds during indexing"; + + var lines = ConsoleUi.WrapHelpLine(line, maxWidth: 12); + + Assert.All(lines, wrapped => + { + Assert.NotEmpty(wrapped); + Assert.True(wrapped.Length <= 12, wrapped); + }); + } + [Fact] public void PrintUsage_QueryLinesMatchImplementedOptions() {