Skip to content
Open
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
81 changes: 81 additions & 0 deletions docs/tui-ai-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: "Console AI Chat"
sidebar:
order: 6
---

Press `[a]` from anywhere in the `datumctl console` to open the AI chat pane. Ask natural-language questions about your resources without leaving the console.

## Prerequisites

An API key from one of the supported LLM providers must be configured:

```
datumctl ai config set anthropic_api_key sk-ant-...
```

See the [AI Assistant](./ai.md) guide for all supported providers and configuration options.

## Layout

The chat pane replaces the main area with a message history sidebar on the left and a conversation panel on the right. The header and status bar remain visible.

```
┌──────────────────────────────────────────────────────────────┐
│ Header (org / project / user / spinner) │
├──────────────────┬───────────────────────────────────────────┤
│ Chat History │ AI Chat │
│ ──────────────── │ ───────────────────────────────────────── │
│ │ │
│ ▸ list dns zon… │ You │
│ show project… │ list dns zones │
│ │ │
│ │ Assistant │
│ │ Here are your DNS zones in web-infra: │
│ │ • example.com (A) active │
│ │ • api.io (CNAME) active │
│ │ │
│ │ ───────────────────────────────────────── │
│ │ ▸ _ │
├──────────────────┴───────────────────────────────────────────┤
│ NORMAL │ [Enter] send [PgUp/Dn] scroll [Tab] history nav … │
└──────────────────────────────────────────────────────────────┘
```

## Key bindings

| Key | Action |
|-----|--------|
| `a` | Open / close chat pane |
| `Enter` | Send message |
| `Tab` | Toggle focus between input and history sidebar |
| `j` / `k` | Navigate history sidebar (when sidebar is focused) |
| `PgUp` / `PgDn` | Scroll conversation viewport |
| `n` | New conversation |
| `e` | Export conversation to markdown |
| `y` / `n` | Approve or cancel a pending write operation |
| `Esc` | Return to previous pane |

## Context

The chat agent automatically uses the active console context — the same org and project that the resource table is showing. Switch context with `[c]` as usual and the next chat message will use the updated scope.

## Write operation confirmations

When the assistant proposes a change (creating, updating, or deleting a resource), it pauses and shows an inline confirmation prompt before applying anything:

```
Assistant
I'd like to delete DNS zone 'example.com'.

⚠ Confirm delete dns_zone 'example.com'?
Type [y] to approve or [n] to cancel.

▸ _
```

Type `y` to proceed or `n` to cancel. The assistant is informed of the decision either way.

## Conversations

Conversations are saved locally and restored when you reopen the console. Use `[n]` to start a fresh conversation, or delete one from the history sidebar. Use `[e]` to export the current conversation to a markdown file.
60 changes: 58 additions & 2 deletions internal/ai/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ type AgentOptions struct {

// Agent runs the agentic loop.
type Agent struct {
opts AgentOptions
history []llm.Message
opts AgentOptions
history []llm.Message
toolEventCh chan<- string // nil between turns; set via SetToolEventCh
}

// NewAgent creates an Agent from the given options.
Expand Down Expand Up @@ -127,6 +128,55 @@ func (a *Agent) ClearHistory() {
a.history = nil
}

// SetHistory replaces the agent's conversation history. Used by the TUI to
// restore a saved conversation when the chat pane is re-opened.
func (a *Agent) SetHistory(msgs []llm.Message) {
a.history = msgs
}

// SetGate replaces the confirmation gate. Called before each turn so the TUI
// context (cancel channel, etc.) stays current.
func (a *Agent) SetGate(gate ConfirmGate) {
a.opts.Gate = gate
}

// SetToolEventCh sets the channel to which tool-call names are broadcast during
// a turn. Set to nil between turns.
func (a *Agent) SetToolEventCh(ch chan<- string) {
a.toolEventCh = ch
}

// RunTurnStream executes one turn and streams LLM token chunks to chunkCh as
// they arrive. Returns TurnResult when the turn is complete.
func (a *Agent) RunTurnStream(ctx context.Context, userMessage string, chunkCh chan<- string) TurnResult {
a.history = append(a.history, llm.Message{Role: llm.RoleUser, Content: userMessage})

var buf strings.Builder
savedOut := a.opts.Out
a.opts.Out = &teeWriter{buf: &buf, ch: chunkCh}
err := a.runOnce(ctx)
a.opts.Out = savedOut

return TurnResult{Response: buf.String(), Err: err}
}

// teeWriter writes to both a buffer and a string channel (for streaming).
type teeWriter struct {
buf *strings.Builder
ch chan<- string
}

func (w *teeWriter) Write(p []byte) (int, error) {
w.buf.Write(p) //nolint:errcheck
if w.ch != nil {
select {
case w.ch <- string(p):
default:
}
}
return len(p), nil
}

// runOnce executes one question→tool-calls→answer cycle, up to MaxIterations.
func (a *Agent) runOnce(ctx context.Context) error {
toolDefs := a.opts.Registry.Defs()
Expand Down Expand Up @@ -186,6 +236,12 @@ func (w *spinnerClearWriter) Write(p []byte) (int, error) {

// executeToolCall finds the tool, handles confirmation, and runs it.
func (a *Agent) executeToolCall(ctx context.Context, tc llm.ToolCall) (string, bool) {
if a.toolEventCh != nil {
select {
case a.toolEventCh <- tc.ToolName:
default:
}
}
tool, ok := a.opts.Registry.Find(tc.ToolName)
if !ok {
return fmt.Sprintf("unknown tool %q", tc.ToolName), true
Expand Down
13 changes: 10 additions & 3 deletions internal/ai/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import "fmt"

// BuildSystemPrompt constructs the system prompt for the agentic loop,
// injecting the current organization, project, namespace, and platform-wide context.
func BuildSystemPrompt(org, project, namespace string, platformWide bool) string {
// viewContext is an optional extra sentence describing what the user is currently viewing
// in the TUI (e.g. "CURRENT VIEW: The user is browsing a list of Project resources.").
func BuildSystemPrompt(org, project, namespace string, platformWide bool, viewContext ...string) string {
var contextSection, toolsSection string

switch {
Expand Down Expand Up @@ -70,6 +72,11 @@ MUTATION CONFIRMATION:
- To find their project ID: datumctl get projects --organization <org-id>`
}

viewSection := ""
if len(viewContext) > 0 && viewContext[0] != "" {
viewSection = "\n" + viewContext[0] + "\n"
}

return fmt.Sprintf(`You are Patch, an AI assistant for Datum Cloud, a connectivity infrastructure platform.
Your name is Patch. When greeting a user for the first time, introduce yourself by name.
You help users manage Datum Cloud resources from their terminal.
Expand All @@ -80,13 +87,13 @@ NO secrets, NO daemonsets, NO statefulsets, NO replicasets, NO ingresses.
Do not suggest or attempt to create any of these resource types.

%s

%s
%s

RESPONSE STYLE:
- Be concise and factual. Summarize results clearly.
- When listing resources, prefer a table or structured summary over raw YAML.
- When something fails, explain what went wrong and suggest a corrective action.
- Do not repeat full YAML blobs in your response unless the user explicitly asks.`,
contextSection, toolsSection)
contextSection, viewSection, toolsSection)
}
6 changes: 4 additions & 2 deletions internal/cmd/ctx/ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ for the current user. Use --refresh to update the context cache from the API.`,
}
} else {
cfg, err := datumconfig.LoadAuto()
if err == nil && discovery.IsCacheStale(cfg, discovery.DefaultStaleness) {
fmt.Fprintln(os.Stderr, "Hint: context cache may be stale. Run 'datumctl ctx --refresh' to update.")
if err == nil && discovery.IsCacheStale(cfg, discovery.AutoRefreshStaleness) {
if err := runRefresh(cmd); err != nil {
fmt.Fprintln(os.Stderr, "Warning: could not refresh context cache:", err)
}
}
}
return runList(cmd, args)
Expand Down
Loading