diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e486388045..bb2573ebb9 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -633,6 +633,7 @@ cdidx ./myproject cdidx ./myproject --rebuild # full rebuild from scratch cdidx ./myproject --verbose # show per-file details cdidx ./myproject --duration-format seconds # show elapsed time as seconds +cdidx ./myproject --notify=osc9 # terminal notification after long runs cdidx ./myproject --watch # stay running and reindex on file changes cdidx ./myproject --watch --debounce 200 # coalesce bursts within a 200 ms window ``` @@ -678,7 +679,9 @@ Done. During long-running indexing on an interactive terminal, `Indexing...` stays live as a spinner instead of dropping to a fixed line until the next 50-file progress update. Warnings still print immediately, but the spinner resumes right after each warning so the run does not look frozen. When stdout is redirected (for example `cdidx . > out.txt`), cdidx prints a single `Indexing...` line to stdout, keeps warnings on stderr, and emits only line-based progress updates to stdout. -Human output formats elapsed index time with unit labels by default: milliseconds under 1 second, seconds under 1 minute, minutes/seconds under 1 hour, and hours/minutes/seconds after that. Use `--duration-format seconds` for decimal seconds or `--duration-format hms` for the legacy `HH:MM:SS` display. JSON output continues to expose raw `elapsed_ms` for machine consumers. +Human output uses invariant numeric formatting (`.` decimal separator and `,` thousands separators) regardless of the process locale, matching JSON's culture-independent contract. Elapsed index time uses unit labels by default: milliseconds under 1 second, seconds under 1 minute, minutes/seconds under 1 hour, and hours/minutes/seconds after that. Use `--duration-format seconds` for decimal seconds or `--duration-format hms` for the legacy `HH:MM:SS` display. JSON output continues to expose raw `elapsed_ms` for machine consumers. + +For index runs that take at least five seconds, `--notify=` controls a completion signal on stderr. `auto` rings the terminal bell only for interactive terminals and stays silent for redirected output; `desktop` currently maps to OSC 9 terminal notification text for terminals that support it. `CDIDX_NOTIFY` sets the same default, and `--quiet` suppresses completion notifications. Machine-readable output also reports the post-run readiness bits directly: @@ -2323,6 +2326,7 @@ name-based tools では `exactName` を使い、`exact` は後方互換 client | Search snippet lines | `8`(`--snippet-lines`、最大 `20`) | CLI help と search runner | | Max line width | `512`(`--max-line-width`、`0` で無効) | `LineWidthFormatter.DefaultMaxLineWidth` | | Index max file size | `CDIDX_MAX_FILE_BYTES` 未設定時は `4MiB` | index runner help | +| Index completion notification | `auto`(interactive terminal は bell、redirected output は none)。`--notify` / `CDIDX_NOTIFY` で上書き | index runner help | | Watch debounce | `500` ms(`--debounce`) | index watch runner | | Status stale-after hint | `24h`。`--stale-after` / `CDIDX_STALE_AFTER` / `.cdidxrc.json` で上書き | status runner | | Color mode | `auto`。`--color` / `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR=0` で上書き | `ConsoleUi` | diff --git a/changelog.d/unreleased/1629.fixed.md b/changelog.d/unreleased/1629.fixed.md new file mode 100644 index 0000000000..355b05645d --- /dev/null +++ b/changelog.d/unreleased/1629.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 1629 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs +--- + +## English + +- **Console width detection no longer hides unexpected failures (#1629)** — width probing now catches only documented console exceptions, records fallback use, honors `COLUMNS` after failed probing, and emits a one-time verbose trace. + +## 日本語 + +- **console width 検出が想定外の失敗を隠さないようになりました (#1629)** — 幅取得は既知の console 例外だけを捕捉し、fallback 使用を記録し、失敗時に `COLUMNS` を優先し、verbose で一度だけ trace を出します。 diff --git a/changelog.d/unreleased/1662.fixed.md b/changelog.d/unreleased/1662.fixed.md new file mode 100644 index 0000000000..ff4226e099 --- /dev/null +++ b/changelog.d/unreleased/1662.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1662 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.cs + - USER_GUIDE.md +--- + +## English + +- **Human CLI index output now uses invariant numeric formatting (#1662)** — progress and index summaries consistently use culture-independent decimal and thousands separators to match JSON-facing expectations. + +## 日本語 + +- **人間向け CLI index 出力が invariant な数値形式を使うようになりました (#1662)** — progress と index summary は JSON の期待と揃うよう、culture に依存しない小数点と桁区切りを一貫して使います。 diff --git a/changelog.d/unreleased/1835.added.md b/changelog.d/unreleased/1835.added.md new file mode 100644 index 0000000000..84edde698b --- /dev/null +++ b/changelog.d/unreleased/1835.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 1835 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - USER_GUIDE.md +--- + +## English + +- **Added long index completion notifications (#1835)** — `cdidx index` now supports `--notify=` plus `CDIDX_NOTIFY`, with quiet/json-safe suppression and a five-second threshold for human runs. + +## 日本語 + +- **長い index 完了通知を追加しました (#1835)** — `cdidx index` は `--notify=` と `CDIDX_NOTIFY` に対応し、人間向け実行では5秒以上の run だけ通知し、quiet/json では抑制します。 diff --git a/changelog.d/unreleased/1963.added.md b/changelog.d/unreleased/1963.added.md new file mode 100644 index 0000000000..106390fa6b --- /dev/null +++ b/changelog.d/unreleased/1963.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1963 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - USER_GUIDE.md +--- + +## English + +- **Added post-index next-step guidance (#1963)** — successful human full-scan output now prints concise search, definition, MCP, database, exclusion, and language-summary next steps while preserving JSON and quiet output. + +## 日本語 + +- **index 後の next-step guidance を追加しました (#1963)** — 成功した人間向け full-scan 出力に、search / definition / MCP / database / 除外設定 / language summary の簡潔な案内を出し、JSON と quiet 出力は維持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index cfe4514e3b..fc61347e7f 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -210,6 +210,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) }, new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", Commands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) }, + new() { Name = "--notify", ValuePlaceholder = "", Description = "Signal long index completion; desktop currently emits OSC 9 terminal notification", Commands = Set("index") }, new() { Name = "--slow-query-ms", ValuePlaceholder = "", Description = "Log profiled SQL statements at or above this millisecond threshold", Commands = Set(ProfileCommands) }, new() { Name = "--trace", ValuePlaceholder = "", Description = "Emit one structured JSON query trace line to stderr or a daily log file", Commands = Set(TraceCommands) }, new() { Name = "--limit", ValuePlaceholder = "", Description = "Max results", Commands = Set(LimitCapableCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a3b445bdeb..b739596c8c 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -50,6 +50,14 @@ public enum DurationOutputFormat Hms = 2, } +public enum CompletionNotificationMode +{ + Auto = 0, + None = 1, + Bell = 2, + Osc9 = 3, +} + /// /// Console UI helpers: spinner, progress bar, banner, and easter egg messages. /// コンソールUIヘルパー: スピナー、プログレスバー、バナー、イースターエッグメッセージ。 @@ -60,7 +68,7 @@ public static class ConsoleUi private static readonly (string Command, string Usage)[] CommandUsageLines = [ - ("index", "cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--follow-symlinks ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]] [--watch [--debounce ]]"), + ("index", "cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--memory-trace] [--duration-format ] [--notify ] [--max-file-bytes ] [--follow-symlinks ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]] [--watch [--debounce ]]"), ("hooks", "cdidx hooks [--project ] [--force] [--json]"), ("backfill-fold", "cdidx backfill-fold [--db ] [--dry-run] [--json]"), ("optimize", "cdidx optimize [--db ] [--json]"), @@ -130,6 +138,12 @@ internal static string Counted(int count, string singular, string? plural = null return $"{formatted} {(count == 1 ? singular : plural ?? singular + "s")}"; } + internal static string FormatNumber(long value, string format = "N0") + => value.ToString(format, CultureInfo.InvariantCulture); + + internal static string FormatNumber(int value, string format = "N0") + => value.ToString(format, CultureInfo.InvariantCulture); + internal static string FoundSummary(int count, string singular, string? plural = null) { plural ??= singular + "s"; @@ -367,6 +381,9 @@ public static string[] GetSpinnerFrames(string? easterEgg) // Track last progress line length for clearing / クリア用に最後のプログレス行の長さを記録 private static int _lastProgressLineLength; private static bool _asciiOutputForced; + private static bool _widthDetectionFailed; + private static bool _widthDetectionTraceWritten; + private static bool _traceWidthDetectionFailures; /// /// Set progress bar spinner theme (reuses GetSpinnerFrames). @@ -424,7 +441,9 @@ internal static string FormatProgressLine(int current, int total, int windowWidt { const int barWidth = 32; var pct = (double)current / total; - var percentAndCounts = $"{pct * 100,5:F1}% [{current:N0}/{total:N0}]"; + var percentAndCounts = string.Create( + CultureInfo.InvariantCulture, + $"{pct * 100,5:F1}% [{current:N0}/{total:N0}]"); if (useUnicodeGlyphs && windowWidth < 40) return percentAndCounts; @@ -507,6 +526,55 @@ public static void PrintBanner() Console.WriteLine(banner); } + public static void PrintIndexCompleteSummary( + string projectRoot, + string resolvedDbPath, + bool incremental, + int filesScanned, + IReadOnlyDictionary languageCounts) + { + Console.WriteLine(incremental ? "Next steps (incremental):" : "Next steps:"); + Console.WriteLine(" - Search code: cdidx search \"authenticate\" --path src/"); + Console.WriteLine(" - Find a definition: cdidx definition SymbolName"); + Console.WriteLine($" - Start MCP: cdidx mcp --db {QuoteForDisplay(resolvedDbPath)}"); + Console.WriteLine($" - Database: {resolvedDbPath}"); + Console.WriteLine(" - Exclude paths with .gitignore or .cdidxignore, then rerun cdidx index ."); + Console.WriteLine($" - Scanned {Counted(filesScanned, "file", format: "N0")} under {projectRoot}"); + if (languageCounts.Count > 0) + { + var summary = string.Join( + ", ", + languageCounts + .OrderByDescending(static pair => pair.Value) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) + .Take(6) + .Select(static pair => $"{pair.Key} {pair.Value.ToString("N0", CultureInfo.InvariantCulture)}")); + Console.WriteLine($" - Languages: {summary}"); + } + Console.WriteLine(); + } + + public static void EmitCompletionNotification(CompletionNotificationMode mode, string message) + { + var resolved = mode == CompletionNotificationMode.Auto + ? ShouldUseInteractiveConsole() ? CompletionNotificationMode.Bell : CompletionNotificationMode.None + : mode; + if (resolved == CompletionNotificationMode.None) + return; + + var safeMessage = message.Replace('\r', ' ').Replace('\n', ' '); + if (resolved == CompletionNotificationMode.Osc9) + Console.Error.Write($"\u001b]9;{safeMessage}\a"); + else + Console.Error.Write('\a'); + Console.Error.Flush(); + } + + private static string QuoteForDisplay(string value) + => value.IndexOfAny([' ', '\t', '"']) < 0 + ? value + : $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + // --- Easter eggs / イースターエッグ --- /// @@ -799,6 +867,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --memory-trace Include phase memory samples in index JSON output"); 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"); + WriteHelpLine(" --notify Long index completion signal: auto, bell, osc9, desktop, or none (also honors CDIDX_NOTIFY; quiet/json suppress it)"); 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(" --follow-symlinks Directory symlink policy: none (default), internal, or all"); @@ -2027,6 +2096,10 @@ private static bool IsUnicodeLocale(string locale) internal static bool IsAsciiOutputForced() => _asciiOutputForced; + internal static bool WidthDetectionFailed => _widthDetectionFailed; + + internal static void SetWidthDetectionTracing(bool enabled) => _traceWidthDetectionFailures = enabled; + /// /// Get console window width safely (some environments throw IOException). /// コンソール幅を安全に取得する(一部環境ではIOExceptionが発生する)。 @@ -2039,12 +2112,32 @@ internal static int GetWindowWidth() try { var w = Console.WindowWidth; - return w > 0 ? w : 80; + if (w > 0) + return w; + } + catch (IOException ex) + { + return GetFallbackWindowWidth(ex); } - catch + catch (NotSupportedException ex) { - return 80; + return GetFallbackWindowWidth(ex); } + + return GetFallbackWindowWidth(null); + } + + private static int GetFallbackWindowWidth(Exception? exception) + { + _widthDetectionFailed = true; + if (_traceWidthDetectionFailures && !_widthDetectionTraceWritten) + { + var suffix = exception == null ? string.Empty : $" ({exception.GetType().Name}: {exception.Message})"; + Console.Error.WriteLine($"cdidx: console width detection failed; using COLUMNS or 80 columns{suffix}"); + _widthDetectionTraceWritten = true; + } + + return TryGetColumnsEnvironmentWidth(out var columnsWidth) ? columnsWidth : 80; } private static bool TryGetColumnsEnvironmentWidth(out int width) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 1d981dc726..2000e45cbc 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -373,6 +373,7 @@ private static int RunFullScan( string? currentHeadCommit, string? priorSymbolKindFilterSignature, string? initialCwd, + bool showNextSteps, CancellationToken cancellationToken) { var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); @@ -1313,6 +1314,11 @@ void StopJsonHeartbeat() } warnings += AddPostExtractionHookWarnings(postExtractionHooks, warningList); var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); + var languageCounts = files + .Select(static file => FileIndexer.TryDetectLanguage(file)) + .Where(static detection => detection.Status == FileIndexer.FileProbeStatus.Supported && detection.Language != null) + .GroupBy(static detection => detection.Language!, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal); var signalReader = new DbReader(writer.Connection); var sqlGraphContractSignalAfter = signalReader.GetSqlGraphContractSignal(lang: null); var hotspotFamilySignalAfter = signalReader.GetHotspotFamilySignal(lang: null); @@ -1390,23 +1396,23 @@ void StopJsonHeartbeat() Console.WriteLine(); Console.WriteLine("Done."); Console.WriteLine(); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{totalFiles:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", $"{totalChunks:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", $"{totalSymbols:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Refs", $"{totalReferences:N0}", indent: " ")); - if (skipped > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{skipped:N0} (unchanged)", indent: " ")); - if (scanResult.DanglingSymlinks.Count > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Dangling symlinks", $"{scanResult.DanglingSymlinks.Count:N0} skipped", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Files", ConsoleUi.FormatNumber(totalFiles), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); + if (skipped > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{ConsoleUi.FormatNumber(skipped)} (unchanged)", indent: " ")); + if (scanResult.DanglingSymlinks.Count > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Dangling symlinks", $"{ConsoleUi.FormatNumber(scanResult.DanglingSymlinks.Count)} skipped", indent: " ")); if (options.Verbose && scanResult.UnknownExtensionFiles.Count > 0) { - Console.WriteLine($" Unknown extension files: {scanResult.UnknownExtensionFiles.Count:N0}"); + Console.WriteLine($" Unknown extension files: {ConsoleUi.FormatNumber(scanResult.UnknownExtensionFiles.Count)}"); foreach (var relPath in scanResult.UnknownExtensionFiles.Take(5)) Console.WriteLine($" {relPath}"); if (scanResult.UnknownExtensionFiles.Count > 5) - Console.WriteLine($" ... {scanResult.UnknownExtensionFiles.Count - 5:N0} more"); + Console.WriteLine($" ... {ConsoleUi.FormatNumber(scanResult.UnknownExtensionFiles.Count - 5)} more"); } - if (warnings > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", $"{warnings:N0}", indent: " ")); - if (errors > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Errors", $"{errors:N0}", indent: " ")); - if (symbolsDroppedByKindFilter > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", $"{symbolsDroppedByKindFilter:N0}", indent: " ")); + if (warnings > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(warnings), indent: " ")); + if (errors > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(errors), indent: " ")); + if (symbolsDroppedByKindFilter > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(symbolsDroppedByKindFilter), indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Graph", graphTableAvailableAfter ? "ready" : "degraded", indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Issues", issuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("SQL graph", sqlGraphContractReadyAfter ? "ready" : "degraded", indent: " ")); @@ -1422,8 +1428,15 @@ void StopJsonHeartbeat() ConsoleUi.PrintWarning(GetIndexReadinessWarning(graphTableAvailableAfter, issuesTableAvailableAfter, sqlGraphContractReadyAfter, hotspotFamilyReadyAfter, csharpSymbolNameReadyAfter, csharpMetadataTargetReadyAfter, foldReadyAfter, foldReadyReasonAfter, projectRoot, resolvedDbPath)); if (cwdDriftDetected) ConsoleUi.PrintWarning(cwdDriftNotice!); + if (errors == 0 && showNextSteps) + ConsoleUi.PrintIndexCompleteSummary(projectRoot, resolvedDbPath, incremental: !options.Rebuild, files.Count, languageCounts); } + if (!options.Json && !options.Quiet && stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) + ConsoleUi.EmitCompletionNotification( + options.NotifyMode, + $"cdidx index complete ({ConsoleUi.Counted(files.Count, "file", format: "N0")})"); + return CommandExitCodes.Success; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 097365fcc4..4b96f65ade 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -12,8 +12,9 @@ public static partial class IndexCommandRunner // easter egg や random-spinner は意図的に未公開なので除外する。 private static readonly string[] AcceptedIndexFlags = [ - "--db", "--data-dir", "--rebuild", "--verbose", "--json", "--dry-run", "--force", + "--db", "--data-dir", "--rebuild", "--verbose", "--json", "--quiet", "--dry-run", "--force", "--yes", "--watch", "--debounce", "--duration-format", "--max-file-bytes", + "--notify", "--parallelism", "--memory-trace", "--follow-symlinks", "--commits", "--changed-between", "--files", "--solution", "--project", "--include-symbol-kind", "--exclude-symbol-kind", "--optimize", "--help", @@ -40,6 +41,7 @@ public static IndexCommandOptions ParseArgs(string[] args) bool memoryTrace = false; int? watchDebounceMs = null; var durationFormat = DurationOutputFormat.Auto; + var notifyMode = ReadCompletionNotificationModeFromEnvironment(); long? maxFileSizeBytes = ReadMaxFileSizeBytesFromEnvironment(); var parallelism = ReadIndexParallelismFromEnvironment(); var symlinkPolicy = FileIndexer.SymlinkPolicy.None; @@ -137,6 +139,12 @@ public static IndexCommandOptions ParseArgs(string[] args) case var option when option.StartsWith("--duration-format=", StringComparison.Ordinal): durationFormat = ParseDurationFormat(option["--duration-format=".Length..], durationFormat); break; + case "--notify" when i + 1 < args.Length: + notifyMode = ParseCompletionNotificationMode(args[++i], notifyMode, ref parseError); + break; + case var option when option.StartsWith("--notify=", StringComparison.Ordinal): + notifyMode = ParseCompletionNotificationMode(option["--notify=".Length..], notifyMode, ref parseError); + break; case "--max-file-bytes" when i + 1 < args.Length: maxFileSizeBytes = ParseMaxFileBytes(args[++i], maxFileSizeBytes); break; @@ -299,6 +307,7 @@ public static IndexCommandOptions ParseArgs(string[] args) MemoryTrace = memoryTrace, WatchDebounceMs = watchDebounceMs, DurationFormat = durationFormat, + NotifyMode = notifyMode, MaxFileSizeBytes = maxFileSizeBytes, Parallelism = parallelism, SymlinkPolicy = symlinkPolicy, @@ -415,6 +424,35 @@ private static DurationOutputFormat WarnInvalidDurationFormat(string value, Dura return fallback; } + private static CompletionNotificationMode ReadCompletionNotificationModeFromEnvironment() + { + var value = Environment.GetEnvironmentVariable("CDIDX_NOTIFY"); + if (string.IsNullOrWhiteSpace(value)) + return CompletionNotificationMode.Auto; + + string? ignored = null; + return ParseCompletionNotificationMode(value, CompletionNotificationMode.Auto, ref ignored); + } + + private static CompletionNotificationMode ParseCompletionNotificationMode(string value, CompletionNotificationMode fallback, ref string? parseError) + { + return value.Trim().ToLowerInvariant() switch + { + "auto" => CompletionNotificationMode.Auto, + "none" => CompletionNotificationMode.None, + "bell" => CompletionNotificationMode.Bell, + "osc9" => CompletionNotificationMode.Osc9, + "desktop" => CompletionNotificationMode.Osc9, + _ => WarnInvalidCompletionNotificationMode(value, fallback, ref parseError), + }; + } + + private static CompletionNotificationMode WarnInvalidCompletionNotificationMode(string value, CompletionNotificationMode fallback, ref string? parseError) + { + parseError ??= $"invalid --notify value '{value}': expected auto, bell, osc9, desktop, or none"; + return fallback; + } + private static string? AbsolutizePathOption(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 2af6cd689a..95631c05c9 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -181,6 +181,7 @@ private static int RunUpdateMode( currentHeadCommit, priorSymbolKindFilterSignature, initialCwd, + showNextSteps: false, cancellationToken); } @@ -1178,16 +1179,16 @@ void ThrowIfUpdateCancelled() Console.WriteLine(); Console.WriteLine("Done."); Console.WriteLine(); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{totalFiles:N0} (total in DB)", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", $"{totalChunks:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", $"{totalSymbols:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Refs", $"{totalReferences:N0}", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("Updated", $"{updated:N0}", indent: " ")); - if (removed > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Removed", $"{removed:N0}", indent: " ")); - if (skipped > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{skipped:N0}", indent: " ")); - if (warnings > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", $"{warnings:N0}", indent: " ")); - if (errors > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Errors", $"{errors:N0}", indent: " ")); - if (symbolsDroppedByKindFilter > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", $"{symbolsDroppedByKindFilter:N0}", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{ConsoleUi.FormatNumber(totalFiles)} (total in DB)", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("Updated", ConsoleUi.FormatNumber(updated), indent: " ")); + if (removed > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Removed", ConsoleUi.FormatNumber(removed), indent: " ")); + if (skipped > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", ConsoleUi.FormatNumber(skipped), indent: " ")); + if (warnings > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(warnings), indent: " ")); + if (errors > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(errors), indent: " ")); + if (symbolsDroppedByKindFilter > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(symbolsDroppedByKindFilter), indent: " ")); if (ftsOptimizeRan) Console.WriteLine(ConsoleUi.FormatSummaryLine("FTS optimize", "completed", indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Graph", graphTableAvailableAfter ? "ready" : "degraded", indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Issues", issuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); @@ -1206,6 +1207,11 @@ void ThrowIfUpdateCancelled() ConsoleUi.PrintWarning(cwdDriftNotice!); } + if (!options.Json && !options.Quiet && stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) + ConsoleUi.EmitCompletionNotification( + options.NotifyMode, + $"cdidx index update complete ({ConsoleUi.Counted(updated + removed + skipped, "file", format: "N0")})"); + return CommandExitCodes.Success; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 78f638c8d9..d173205ee6 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -43,6 +43,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C { RuntimeSafety.Configure(); var options = ParseArgs(indexArgs); + ConsoleUi.SetWidthDetectionTracing(options.Verbose && !options.Json && !options.Quiet); var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); using var ownedCancellation = cancellationForTesting == null ? new CancellationTokenSource() : null; var indexCancellation = cancellationForTesting ?? ownedCancellation!; @@ -246,7 +247,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C initialExitCode = isUpdateMode ? RunUpdateMode(writer, indexer, projectRoot, resolvedDbPath, options, stopwatch, runStartedAtUtc, spinnerFrames, jsonOptions, priorReadiness, priorFoldVersion, priorFoldFingerprint, priorSymbolExtractorVersionsMatchCurrent, priorCSharpSymbolNameContractVersion, priorMetadataTargetCsharp, priorSqlGraphContractVersion, priorHotspotFamilyVersions, priorHotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints, priorIndexedProjectRoot, priorIndexedHeadCommit, currentHeadCommit, priorSymbolKindFilterSignature, initialCwd, indexCancellation.Token) - : RunFullScan(writer, indexer, projectRoot, resolvedDbPath, options, stopwatch, runStartedAtUtc, spinnerFrames, jsonOptions, priorFoldVersion, priorFoldFingerprint, priorSymbolExtractorVersionsMatchCurrent, priorCSharpSymbolNameContractVersion, priorMetadataTargetCsharp, priorSqlGraphContractVersion, priorHotspotFamilyVersions, priorHotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints, priorIndexedProjectRoot, priorIndexedHeadCommit, currentHeadCommit, priorSymbolKindFilterSignature, initialCwd, indexCancellation.Token); + : RunFullScan(writer, indexer, projectRoot, resolvedDbPath, options, stopwatch, runStartedAtUtc, spinnerFrames, jsonOptions, priorFoldVersion, priorFoldFingerprint, priorSymbolExtractorVersionsMatchCurrent, priorCSharpSymbolNameContractVersion, priorMetadataTargetCsharp, priorSqlGraphContractVersion, priorHotspotFamilyVersions, priorHotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints, priorIndexedProjectRoot, priorIndexedHeadCommit, currentHeadCommit, priorSymbolKindFilterSignature, initialCwd, showNextSteps: !databaseExistedBeforeIndex, indexCancellation.Token); if (initialExitCode == CommandExitCodes.Success) db.RunPlannerStatisticsMaintenance(forceAnalyze: !databaseExistedBeforeIndex); } @@ -1267,6 +1268,7 @@ public sealed class IndexCommandOptions public bool OptimizeOnly { get; init; } public int? WatchDebounceMs { get; init; } public DurationOutputFormat DurationFormat { get; init; } = DurationOutputFormat.Auto; + public CompletionNotificationMode NotifyMode { get; init; } = CompletionNotificationMode.Auto; public long? MaxFileSizeBytes { get; init; } public int Parallelism { get; init; } = IndexCommandRunner.DefaultIndexParallelism(); public bool MemoryTrace { get; init; } diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 352583424c..3832cf3f2b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Reflection.Emit; +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using CodeIndex.Cli; @@ -102,7 +103,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.DoesNotContain("██████╗", output); Assert.Contains("Usage:", output); - Assert.Contains("cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--follow-symlinks ]", output); + Assert.Contains("cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--memory-trace] [--duration-format ] [--notify ] [--max-file-bytes ] [--follow-symlinks ]", output); Assert.Contains("cdidx hooks [--project ] [--force] [--json]", output); Assert.Contains("cdidx index --commits [id ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ]", output); Assert.Contains("cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ]", output); @@ -290,6 +291,25 @@ public void Counted_AllowsIrregularPluralAndNumberFormat() Assert.Equal("1,234 files", ConsoleUi.Counted(1234, "file", format: "N0")); } + [Fact] + public void FormatProgressLine_UsesInvariantNumberFormatting() + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + + var line = ConsoleUi.FormatProgressLine(1234, 2000, windowWidth: 80, useUnicodeGlyphs: false); + + Assert.Contains("61.7%", line); + Assert.Contains("[1,234/2,000]", line); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + [Fact] public void GetSpinnerFrames_Default_UsesRotatingBrailleSequence() { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index b8ffb125df..c875bc4b11 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -749,6 +749,15 @@ public void Run_FullScanAfterHeadChange_ParallelizesExtraction() } } + [Fact] + public void ParseArgs_NotifyFlag_ParsesCompletionNotificationMode() + { + var options = IndexCommandRunner.ParseArgs([".", "--notify=osc9"]); + + Assert.Equal(CompletionNotificationMode.Osc9, options.NotifyMode); + Assert.Null(options.ParseError); + } + [Fact] public void Run_FullScanAfterHeadChange_WithPostExtractionHooksKeepsSequentialReferences() {