diff --git a/docs/copilot.md b/docs/copilot.md index fa53853..fc3df9b 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -11,7 +11,7 @@ Ask natural language questions about all your Microsoft 365 content, including d ## Arguments - **`[message]`** (optional) -- Natural language question about your M365 content. If omitted and stdin is interactive, `a365` starts a Copilot prompt. -- **`--conversation-id`** (optional) -- Conversation ID for follow-up queries. One-shot human output prints the conversation ID to stderr so you can reuse it later. +- **`--conversation-id`** (optional) -- Conversation ID for follow-up queries. Use `--output json` when you need to inspect returned conversation IDs. ## Examples diff --git a/internal/commands/copilot/copilot.go b/internal/commands/copilot/copilot.go index 619e19e..39dd121 100644 --- a/internal/commands/copilot/copilot.go +++ b/internal/commands/copilot/copilot.go @@ -7,6 +7,8 @@ import ( "io" "os" "strings" + "sync" + "time" "github.com/sozercan/a365cli/internal/commands" "github.com/sozercan/a365cli/internal/config" @@ -43,20 +45,12 @@ func runChat(ctx *commands.Context, message, conversationID string) error { return runInteractiveLoop(ctx, os.Stdin, os.Stderr, conversationID) } - data, nextConversationID, err := callCopilot(ctx, question, conversationID) + data, _, err := callCopilot(ctx, question, conversationID) if err != nil { return err } - if err := printCopilotResponse(ctx, data); err != nil { - return err - } - - if nextConversationID != "" && ctx.Output.Format == output.FormatHuman { - fmt.Fprintf(os.Stderr, "Conversation ID: %s\n", nextConversationID) - } - - return nil + return printCopilotResponse(ctx, data) } func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io.Writer, conversationID string) error { @@ -110,6 +104,9 @@ func runInteractiveLoop(ctx *commands.Context, input io.Reader, promptWriter io. } func callCopilot(ctx *commands.Context, message, conversationID string) (map[string]any, string, error) { + stopSpinner := startCopilotSpinner(ctx) + defer stopSpinner() + client := ctx.NewMCPClient(copilotEndpoint()) if err := client.Initialize(ctx.Ctx); err != nil { return nil, "", fmt.Errorf("initialize: %w", err) @@ -151,17 +148,19 @@ func printCopilotResponse(ctx *commands.Context, data map[string]any) error { return ctx.Output.PrintItem(data) } - fmt.Fprintln(ctx.Output.Writer, "Copilot:", message) + if ctx.NoInput { + fmt.Fprintln(ctx.Output.Writer, message) + } else { + fmt.Fprintln(ctx.Output.Writer, "Copilot:", message) + } meta := cloneMap(data) if messageKey != "" { delete(meta, messageKey) } - if ctx.Output.Format == output.FormatHuman { - delete(meta, "conversationId") - delete(meta, "conversationID") - delete(meta, "conversation_id") - } + delete(meta, "conversationId") + delete(meta, "conversationID") + delete(meta, "conversation_id") delete(meta, "@odata.context") delete(meta, "createdDateTime") delete(meta, "displayName") @@ -276,3 +275,69 @@ func isExitCommand(question string) bool { return false } } + +func startCopilotSpinner(ctx *commands.Context) func() { + if ctx.Verbose || !stderrIsTerminal() { + return func() {} + } + + const ( + label = "Thinking..." + startDelay = 200 * time.Millisecond + frameDelay = 120 * time.Millisecond + ) + + stop := make(chan struct{}) + done := make(chan struct{}) + var once sync.Once + + go func() { + defer close(done) + + timer := time.NewTimer(startDelay) + defer timer.Stop() + + select { + case <-stop: + return + case <-timer.C: + } + + frames := []string{"|", "/", "-", "\\"} + ticker := time.NewTicker(frameDelay) + defer ticker.Stop() + + render := func(frame string) { + fmt.Fprintf(os.Stderr, "\r%s %s", frame, label) + } + + frameIndex := 0 + render(frames[frameIndex]) + + for { + select { + case <-stop: + fmt.Fprintf(os.Stderr, "\r%s\r", strings.Repeat(" ", len(label)+2)) + return + case <-ticker.C: + frameIndex = (frameIndex + 1) % len(frames) + render(frames[frameIndex]) + } + } + }() + + return func() { + once.Do(func() { + close(stop) + <-done + }) + } +} + +func stderrIsTerminal() bool { + fi, err := os.Stderr.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/commands/copilot/copilot_test.go b/internal/commands/copilot/copilot_test.go index 4dfc874..d5d827f 100644 --- a/internal/commands/copilot/copilot_test.go +++ b/internal/commands/copilot/copilot_test.go @@ -87,11 +87,44 @@ func TestPrintCopilotResponse_PlainUsesChatStyle(t *testing.T) { if !strings.Contains(out, "Copilot: Here is the answer") { t.Fatalf("expected Copilot-prefixed plain output, got %q", out) } - if !strings.Contains(out, "conversationId:") || !strings.Contains(out, "conv-123") { - t.Fatalf("expected conversationId to remain visible in plain output, got %q", out) + if strings.Contains(out, "conversationId") || strings.Contains(out, "conv-123") { + t.Fatalf("expected conversationId to stay hidden in plain output, got %q", out) } } +func TestPrintCopilotResponse_NoInputOmitsChatPrefix(t *testing.T) { + var buf bytes.Buffer + ctx := &commands.Context{ + Ctx: context.Background(), + NoInput: true, + Output: &output.Formatter{Format: output.FormatHuman, Writer: &buf}, + } + + err := printCopilotResponse(ctx, map[string]any{ + "message": "Here is the answer", + "references": []any{"doc-1"}, + }) + if err != nil { + t.Fatalf("printCopilotResponse() error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "Copilot:") { + t.Fatalf("expected no Copilot prefix for --no-input output, got %q", out) + } + if !strings.Contains(out, "Here is the answer") { + t.Fatalf("expected message output, got %q", out) + } + if !strings.Contains(out, "references:") || !strings.Contains(out, "doc-1") { + t.Fatalf("expected extra metadata to be preserved, got %q", out) + } +} + +func TestStartCopilotSpinner_DisabledInVerboseMode(t *testing.T) { + stop := startCopilotSpinner(&commands.Context{Verbose: true}) + stop() +} + func TestPrintCopilotResponse_ConversationPayload(t *testing.T) { var buf bytes.Buffer ctx := &commands.Context{