diff --git a/Package.resolved b/Package.resolved index 0236f44..825f426 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "c16473ba5f80955980705ef4babf9fd43ead30738812ba459cba9d8456a6f12f", + "originHash" : "7196e00dc3c81b03d0a8fadcd896bc6976d082866dd6d51406f7b9b69b82b2cd", "pins" : [ + { + "identity" : "noora", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/Noora", + "state" : { + "revision" : "99d3bef655819d530ad467c017ae4ff7db27b0a3", + "version" : "0.51.3" + } + }, + { + "identity" : "path", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/path", + "state" : { + "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", + "version" : "0.3.8" + } + }, + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow", + "state" : { + "revision" : "cdf146ae671b2624917648b61c908d1244b98ca1", + "version" : "4.2.1" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -9,6 +36,15 @@ "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", "version" : "1.6.2" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index c1a2f9f..6acad90 100644 --- a/Package.swift +++ b/Package.swift @@ -12,13 +12,15 @@ let package = Package( .executable(name: "scg", targets: ["SwiftCommitGen"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0") + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/tuist/Noora", from: "0.49.0") ], targets: [ .executableTarget( name: "SwiftCommitGen", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Noora", package: "Noora") ], linkerSettings: [ .linkedFramework("FoundationModels") diff --git a/Sources/SwiftCommitGen/CommitGenTool.swift b/Sources/SwiftCommitGen/CommitGenTool.swift index 4cd6c68..5357240 100644 --- a/Sources/SwiftCommitGen/CommitGenTool.swift +++ b/Sources/SwiftCommitGen/CommitGenTool.swift @@ -1,4 +1,5 @@ import Foundation +import Noora /// Coordinates the end-to-end flow for generating, reviewing, and optionally /// applying an AI-assisted commit message. @@ -11,6 +12,7 @@ struct CommitGenTool { private let renderer: Renderer private let logger: CommitGenLogger private let consoleTheme: ConsoleTheme + private let noora: Noorable /// Creates a tool instance with configurable collaborators for easier testing. init( @@ -20,7 +22,8 @@ struct CommitGenTool { promptBuilder: PromptBuilder? = nil, llmClient: LLMClient = FoundationModelsClient(), renderer: Renderer = ConsoleRenderer(), - logger: CommitGenLogger? = nil + logger: CommitGenLogger? = nil, + noora: Noorable? = nil ) { self.options = options self.gitClient = gitClient @@ -61,6 +64,7 @@ struct CommitGenTool { self.renderer = renderer self.logger = logger ?? CommitGenLogger(isVerbose: options.isVerbose, isQuiet: options.isQuiet) self.consoleTheme = self.logger.consoleTheme + self.noora = noora ?? Noora() } /// Executes the commit generation pipeline from Git inspection to @@ -212,7 +216,14 @@ struct CommitGenTool { additionalContext.map { outcome.promptPackage.appendingUserContext($0) } ?? outcome.promptPackage logPromptDiagnostics(package.diagnostics) - let regeneration = try await llmClient.generateCommitDraft(from: package) + let regeneration = try await noora.progressStep( + message: "Regenerating commit message…", + successMessage: "Commit message regenerated", + errorMessage: "Regeneration failed", + showSpinner: true + ) { _ in + try await llmClient.generateCommitDraft(from: package) + } logPromptDiagnostics(regeneration.diagnostics) return regeneration.draft } @@ -220,7 +231,7 @@ struct CommitGenTool { draft = reviewedDraft try await handleAcceptedDraft(draft) } else { - logger.warning("Commit generation flow aborted; no changes were committed.") + noora.warning(.alert("Commit generation aborted", takeaway: "No changes were committed")) } } @@ -229,6 +240,17 @@ struct CommitGenTool { return candidate.isEmpty ? url.path : candidate } + /// Actions available when reviewing a generated draft. + private enum ReviewAction: String, CaseIterable, CustomStringConvertible { + case accept = "Accept and commit" + case edit = "Edit in $EDITOR" + case regenerate = "Regenerate" + case regenerateWithContext = "Regenerate with context" + case abort = "Abort" + + var description: String { rawValue } + } + /// Presents the interactive review loop and returns the accepted draft, if any. private func reviewDraft( initialDraft: CommitDraft, @@ -237,55 +259,38 @@ struct CommitGenTool { var currentDraft = initialDraft reviewLoop: while true { - logger.notice( - "Options: [y] accept, [e] edit in $EDITOR, [r] regenerate, [c] regenerate with context, [n] abort" + let action: ReviewAction = noora.singleChoicePrompt( + title: "Review Draft", + question: "What would you like to do?", + description: "Use arrow keys to select, Enter to confirm" ) - guard let response = promptUser("Apply commit draft? [y/e/r/c/n]: ") else { - continue - } - switch response { - case "y", "yes": + switch action { + case .accept: return currentDraft - case "e", "edit": + case .edit: if let updated = try editDraft(currentDraft) { currentDraft = updated renderer.render(currentDraft, format: .text, report: nil) } - case "r", "regen", "regenerate": - logger.info("Requesting a new commit draft from the on-device language model…") + case .regenerate: currentDraft = try await regenerate(nil) renderer.render(currentDraft, format: .text, report: nil) - case "c", "context": + case .regenerateWithContext: guard let additionalContext = promptForAdditionalContext() else { logger.warning("No additional context provided; keeping previous draft.") continue } - logger.info("Requesting a new commit draft with user context…") currentDraft = try await regenerate(additionalContext) renderer.render(currentDraft, format: .text, report: nil) - case "n", "no", "q", "quit": + case .abort: break reviewLoop - default: - logger.warning("Unrecognized option. Please respond with y, e, r, c, or n.") } } return nil } - /// Prompts the user on stderr and collects a lowercased response from stdin. - private func promptUser(_ prompt: String) -> String? { - if let data = prompt.data(using: .utf8) { - FileHandle.standardError.write(data) - } - guard let line = readLine() else { - logger.warning("Unable to read response; aborting interaction.") - return nil - } - return line.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } - /// Captures multiline user context for regeneration until a blank line or EOF. private func promptForAdditionalContext() -> String? { logger.info( @@ -370,7 +375,9 @@ struct CommitGenTool { } try await gitClient.commit(message: draft.commitMessage) - logger.notice("Git commit created successfully.") + noora.success(.alert("Commit created successfully", takeaways: [ + "Subject: \(draft.subject)" + ])) } /// Emits a human-readable summary of the staged files before prompt generation. @@ -562,8 +569,14 @@ extension CommitGenTool { let promptPackage = promptBuilder.makePrompt(summary: summary, metadata: metadata) logPromptDiagnostics(promptPackage.diagnostics) - logger.info("Requesting commit draft from the on-device language model…") - let generation = try await llmClient.generateCommitDraft(from: promptPackage) + let generation = try await noora.progressStep( + message: "Generating commit message…", + successMessage: "Commit message generated", + errorMessage: "Generation failed", + showSpinner: true + ) { _ in + try await llmClient.generateCommitDraft(from: promptPackage) + } logPromptDiagnostics(generation.diagnostics) return GenerationOutcome( @@ -662,7 +675,14 @@ extension CommitGenTool { logPromptDiagnostics(batchPackage.diagnostics) - let generation = try await llmClient.generateCommitDraft(from: batchPackage) + let generation = try await noora.progressStep( + message: "Processing batch \(batchNumber)/\(batches.count)…", + successMessage: "Batch \(batchNumber) complete", + errorMessage: "Batch \(batchNumber) failed", + showSpinner: true + ) { _ in + try await llmClient.generateCommitDraft(from: batchPackage) + } logPromptDiagnostics(generation.diagnostics) logger.debug("Batch \(batchNumber) partial subject: \(generation.draft.subject)") @@ -707,8 +727,14 @@ extension CommitGenTool { ) logPromptDiagnostics(combinationPrompt.diagnostics) - logger.info("Combining \(partialDrafts.count) partial draft(s) into a unified commit message…") - let combinationGeneration = try await llmClient.generateCommitDraft(from: combinationPrompt) + let combinationGeneration = try await noora.progressStep( + message: "Combining \(partialDrafts.count) partial drafts…", + successMessage: "Commit message generated", + errorMessage: "Combination failed", + showSpinner: true + ) { _ in + try await llmClient.generateCommitDraft(from: combinationPrompt) + } logPromptDiagnostics(combinationGeneration.diagnostics) return GenerationOutcome(