Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 81 additions & 16 deletions internal/commands/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"io"
"os"
"strings"
"sync"
"time"

"github.com/sozercan/a365cli/internal/commands"
"github.com/sozercan/a365cli/internal/config"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
37 changes: 35 additions & 2 deletions internal/commands/copilot/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down