diff --git a/internal/agent/botcmd.go b/internal/agent/botcmd.go index 2eea2c0..5e4f88d 100644 --- a/internal/agent/botcmd.go +++ b/internal/agent/botcmd.go @@ -362,11 +362,15 @@ func botRun(rawTail []string, version string) error { } } - b := &telegram.Bot{ - Client: telegram.NewClient(cfg.BotToken), - Agent: agent, - Config: cfg, - ZotHome: ZotHome(), + var b *telegram.Bot + b = &telegram.Bot{ + Client: telegram.NewClient(cfg.BotToken), + Agent: agent, + Config: cfg, + ZotHome: ZotHome(), + Provider: resolved.Provider, + AuthMethod: resolved.AuthMethod, + CWD: args.CWD, Save: func(c telegram.Config) error { return telegram.SaveConfig(ZotHome(), c) }, @@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error { } agent.Client = next.NewClient() agent.Model = next.Model + b.Provider = next.Provider + b.AuthMethod = next.AuthMethod + b.CWD = next.CWD return nil }, } diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index d1758f2..10df643 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() { i.mu.Unlock() if cancel != nil { cancel() + i.confirmDialog.CancelAll("turn cancelled") } } @@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } +func (h *telegramHost) Status() string { + h.iv.mu.Lock() + providerName := h.iv.cfg.Provider + model := h.iv.cfg.Model + cwd := h.iv.cfg.CWD + usage := h.iv.cumUsage + subscription := h.iv.cfg.AuthMethod == "oauth" + ctxUsed := h.iv.lastCtxInput + busy := h.iv.busy + queued := len(h.iv.queued) + h.iv.mu.Unlock() + + ctxMax := 0 + if m, err := provider.FindModel(providerName, model); err == nil { + ctxMax = m.ContextWindow + } + return telegram.FormatStatus(telegram.StatusSnapshot{ + Provider: providerName, + Model: model, + CWD: cwd, + Usage: usage, + Subscription: subscription, + ContextUsed: ctxUsed, + ContextMax: ctxMax, + Busy: busy, + Queued: queued, + }) +} + func (h *telegramHost) Notify(level, message string) { h.iv.mu.Lock() switch level { diff --git a/internal/agent/modes/telegram/bot.go b/internal/agent/modes/telegram/bot.go index 7393e8a..7f7b8dc 100644 --- a/internal/agent/modes/telegram/bot.go +++ b/internal/agent/modes/telegram/bot.go @@ -15,10 +15,13 @@ import ( // the agent. It is a long-running goroutine; Run blocks until ctx // cancels. type Bot struct { - Client *Client - Agent *core.Agent - Config Config - ZotHome string + Client *Client + Agent *core.Agent + Config Config + ZotHome string + Provider string + AuthMethod string + CWD string // Save persists cfg to bot.json. Called whenever the bot pairs // with a new allowed user or advances LastUpdateID. Save func(Config) error @@ -28,10 +31,11 @@ type Bot struct { // agent.ResolveCredentialFull which auto-refreshes expired tokens. RefreshCreds func() error - mu sync.Mutex - busy bool - activeCtx context.CancelFunc - queue []queuedTurn + mu sync.Mutex + busy bool + activeCtx context.CancelFunc + queue []queuedTurn + lastCtxInput int } // queuedTurn is an inbound DM waiting to become a prompt. @@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error { switch text { case "/start", "/help": _ = b.Client.SendMessage(ctx, msg.Chat.ID, - "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.", + "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.", msg.MessageID) return nil case "/status": return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) case "/stop": - b.mu.Lock() - cancel := b.activeCtx - b.mu.Unlock() - if cancel != nil { - cancel() - _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID) - } else { - _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID) - } + b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) + return nil + } + if isStopCommand(text) { + b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) return nil } @@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) { switch e := ev.(type) { case core.EvTextDelta: replyBuilder.WriteString(e.Delta) + case core.EvUsage: + b.mu.Lock() + if e.Usage.InputTokens > 0 { + b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens + } + b.mu.Unlock() case core.EvAssistantMessage: var sb strings.Builder for _, c := range e.Message.Content { @@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() { return cancel } +func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) { + b.mu.Lock() + cancel := b.activeCtx + b.mu.Unlock() + if cancel != nil { + cancel() + _ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo) + } else { + _ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo) + } +} + // sendStatus describes agent state to the Telegram user. func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { b.mu.Lock() busy := b.busy queued := len(b.queue) + ctxUsed := b.lastCtxInput + providerName := b.Provider + authMethod := b.AuthMethod + cwd := b.CWD b.mu.Unlock() - state := "idle" - if busy { - state = "working" + model := b.Agent.Model + ctxMax := 0 + if m, err := provider.FindModel(providerName, model); err == nil { + ctxMax = m.ContextWindow } - lines := []string{ - fmt.Sprintf("state: %s", state), - fmt.Sprintf("queued: %d", queued), - fmt.Sprintf("model: %s", b.Agent.Model), - } - cost := b.Agent.Cost() - lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)", - cost.CostUSD, cost.InputTokens, cost.OutputTokens)) - return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo) + return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{ + Provider: providerName, + Model: model, + CWD: cwd, + Usage: b.Agent.Cost(), + Subscription: authMethod == "oauth", + ContextUsed: ctxUsed, + ContextMax: ctxMax, + Busy: busy, + Queued: queued, + }), replyTo) } // download fetches a file from Telegram and returns bytes + mime. diff --git a/internal/agent/modes/telegram/bridge.go b/internal/agent/modes/telegram/bridge.go index 2e00ce2..8214df6 100644 --- a/internal/agent/modes/telegram/bridge.go +++ b/internal/agent/modes/telegram/bridge.go @@ -19,9 +19,13 @@ type Host interface { SubmitOrQueue(prompt string, images []provider.ImageBlock) // CancelTurn aborts the active turn (if any). Called when the - // paired Telegram user sends /stop. + // paired Telegram user sends /stop or plain "stop". CancelTurn() + // Status returns the current model, usage, context, and cwd summary + // shown when the paired Telegram user sends /status. + Status() string + // Notify pushes a one-shot status line into the chat. Used to // surface bridge events ("connected as @bot", "paired with // user X", etc.) in the user's local transcript. @@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) { // handleUpdate applies pairing, gates on the allowed user, decodes // the interesting bits (text, caption, image attachments), and -// forwards them to the Host. Built-in slash commands (/start, -// /help, /status, /stop) are handled inline without touching the -// agent. +// forwards them to the Host. Built-in commands (/start, /help, +// /status, /stop, and plain "stop") are handled inline without +// touching the agent. func (b *Bridge) handleUpdate(ctx context.Context, u Update) { msg := u.Message if msg == nil { @@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { switch text { case "/start", "/help": _ = b.Client.SendMessage(ctx, msg.Chat.ID, - "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.", + "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", msg.MessageID) return case "/status": - _ = b.Client.SendMessage(ctx, msg.Chat.ID, - fmt.Sprintf("mirror active. paired user: %d.", paired), - msg.MessageID) + _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) return case "/stop": b.Host.CancelTurn() @@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { "cancelled the current turn.", msg.MessageID) return } + if isStopCommand(text) { + b.Host.CancelTurn() + _ = b.Client.SendMessage(ctx, msg.Chat.ID, + "cancelled the current turn.", msg.MessageID) + return + } // Build the prompt: text + caption; download image attachments. prompt := strings.TrimSpace(msg.Text) diff --git a/internal/agent/modes/telegram/commands.go b/internal/agent/modes/telegram/commands.go new file mode 100644 index 0000000..c1af6bb --- /dev/null +++ b/internal/agent/modes/telegram/commands.go @@ -0,0 +1,11 @@ +package telegram + +import "strings" + +// isStopCommand reports whether text should abort the active turn. +// Telegram users often type plain "stop" rather than bot-style +// "/stop"; keep this intentionally narrow so normal prompts like +// "stop doing X" still go to the agent. +func isStopCommand(text string) bool { + return strings.EqualFold(strings.TrimSpace(text), "stop") +} diff --git a/internal/agent/modes/telegram/commands_test.go b/internal/agent/modes/telegram/commands_test.go new file mode 100644 index 0000000..eff7745 --- /dev/null +++ b/internal/agent/modes/telegram/commands_test.go @@ -0,0 +1,23 @@ +package telegram + +import "testing" + +func TestIsStopCommand(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {"stop", true}, + {" STOP ", true}, + {"Stop", true}, + {"/stop", false}, // handled by the slash-command switch + {"stop please", false}, + {"please stop", false}, + {"", false}, + } + for _, tt := range tests { + if got := isStopCommand(tt.text); got != tt.want { + t.Fatalf("isStopCommand(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} diff --git a/internal/agent/modes/telegram/status.go b/internal/agent/modes/telegram/status.go new file mode 100644 index 0000000..ef2b3b5 --- /dev/null +++ b/internal/agent/modes/telegram/status.go @@ -0,0 +1,120 @@ +package telegram + +import ( + "fmt" + "os" + "strings" + + "github.com/patriceckhart/zot/internal/provider" +) + +// StatusSnapshot is the small cross-host state bundle rendered for +// Telegram /status replies. +type StatusSnapshot struct { + Provider string + Model string + CWD string + Usage provider.Usage + Subscription bool + ContextUsed int + ContextMax int + Busy bool + Queued int +} + +// FormatStatus renders the same compact model/usage/cost/context +// information shown in the TUI status bar, plus the current directory. +func FormatStatus(s StatusSnapshot) string { + providerName := strings.TrimSpace(s.Provider) + model := strings.TrimSpace(s.Model) + if providerName == "" { + providerName = "unknown" + } + if model == "" { + model = "unknown" + } + + var stats []string + if s.Usage.InputTokens > 0 { + stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens))) + } + if s.Usage.OutputTokens > 0 { + stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens))) + } + if s.Usage.CacheReadTokens > 0 { + stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens))) + } + if s.Usage.CacheWriteTokens > 0 { + stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens))) + } + if s.Usage.CostUSD > 0 || s.Subscription { + cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD) + if s.Subscription { + cost += " (sub)" + } + stats = append(stats, cost) + } + if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" { + stats = append(stats, ctx) + } + + line := fmt.Sprintf("(%s) %s", providerName, model) + if len(stats) > 0 { + line += " " + strings.Join(stats, " ") + } + + state := "idle" + if s.Busy { + state = "working" + } + lines := []string{line, "state: " + state} + if s.Queued > 0 { + lines = append(lines, fmt.Sprintf("queued: %d", s.Queued)) + } + if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" { + lines = append(lines, "cwd: "+cwd) + } + return strings.Join(lines, "\n") +} + +func contextUsage(used, max int) string { + if max <= 0 { + if used <= 0 { + return "" + } + return formatTokens(used) + } + pct := float64(used) / float64(max) * 100 + return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max)) +} + +func formatTokens(n int) string { + switch { + case n < 0: + return "0" + case n < 1000: + return fmt.Sprintf("%d", n) + case n < 10000: + return fmt.Sprintf("%.1fk", float64(n)/1000) + case n < 1_000_000: + return fmt.Sprintf("%dk", (n+500)/1000) + case n < 10_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + default: + return fmt.Sprintf("%dM", (n+500_000)/1_000_000) + } +} + +func shortenHome(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if strings.HasPrefix(path, home+string(os.PathSeparator)) { + return "~" + strings.TrimPrefix(path, home) + } + return path +} diff --git a/internal/agent/modes/telegram/status_test.go b/internal/agent/modes/telegram/status_test.go new file mode 100644 index 0000000..824c8c3 --- /dev/null +++ b/internal/agent/modes/telegram/status_test.go @@ -0,0 +1,39 @@ +package telegram + +import ( + "strings" + "testing" + + "github.com/patriceckhart/zot/internal/provider" +) + +func TestFormatStatusIncludesModelUsageContextAndCWD(t *testing.T) { + got := FormatStatus(StatusSnapshot{ + Provider: "openai", + Model: "gpt-5.5", + CWD: "/tmp/zot", + Usage: provider.Usage{InputTokens: 961_000, OutputTokens: 10_000, CacheReadTokens: 770_000, CostUSD: 2.749}, + Subscription: true, + ContextUsed: 44_800, + ContextMax: 400_000, + Busy: true, + Queued: 2, + }) + + wants := []string{ + "(openai) gpt-5.5", + "↑961k", + "↓10k", + "R770k", + "$2.749 (sub)", + "11.2%/400k", + "state: working", + "queued: 2", + "cwd: /tmp/zot", + } + for _, want := range wants { + if !strings.Contains(got, want) { + t.Fatalf("FormatStatus missing %q in:\n%s", want, got) + } + } +}