Improve Telegram status and stop commands
- commit
- 63694afce8827410cae8f5b8a890ac4b149d0516
- author
- patriceckhart · · 2026-05-07 17:05 UTC
- parent
- caac491
- signature
- unsigned
- refs
- tag: v0.1.65
8 files · +307 -44
M internal/agent/botcmd.go +12 -5
| @@ -362,11 +362,15 @@ func botRun(rawTail []string, version string) error { | |||
| 362 | 362 | } | |
| 363 | 363 | } | |
| 364 | 364 | ||
| 365 | - | b := &telegram.Bot{ | |
| 366 | - | Client: telegram.NewClient(cfg.BotToken), | |
| 367 | - | Agent: agent, | |
| 368 | - | Config: cfg, | |
| 369 | - | ZotHome: ZotHome(), | |
| 365 | + | var b *telegram.Bot | |
| 366 | + | b = &telegram.Bot{ | |
| 367 | + | Client: telegram.NewClient(cfg.BotToken), | |
| 368 | + | Agent: agent, | |
| 369 | + | Config: cfg, | |
| 370 | + | ZotHome: ZotHome(), | |
| 371 | + | Provider: resolved.Provider, | |
| 372 | + | AuthMethod: resolved.AuthMethod, | |
| 373 | + | CWD: args.CWD, | |
| 370 | 374 | Save: func(c telegram.Config) error { | |
| 371 | 375 | return telegram.SaveConfig(ZotHome(), c) | |
| 372 | 376 | }, | |
| @@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error { | |||
| 381 | 385 | } | |
| 382 | 386 | agent.Client = next.NewClient() | |
| 383 | 387 | agent.Model = next.Model | |
| 388 | + | b.Provider = next.Provider | |
| 389 | + | b.AuthMethod = next.AuthMethod | |
| 390 | + | b.CWD = next.CWD | |
| 384 | 391 | return nil | |
| 385 | 392 | }, | |
| 386 | 393 | } | |
| @@ -362,11 +362,15 @@ func botRun(rawTail []string, version string) error { | |||||
| 362 | } | 362 | } | ||
| 363 | } | 363 | } | ||
| 364 | 364 | ||||
| 365 | - | b := &telegram.Bot{ | 365 | + | var b *telegram.Bot |
| 366 | - | Client: telegram.NewClient(cfg.BotToken), | 366 | + | b = &telegram.Bot{ |
| 367 | - | Agent: agent, | 367 | + | Client: telegram.NewClient(cfg.BotToken), |
| 368 | - | Config: cfg, | 368 | + | Agent: agent, |
| 369 | - | ZotHome: ZotHome(), | 369 | + | Config: cfg, |
| 370 | + | ZotHome: ZotHome(), | |||
| 371 | + | Provider: resolved.Provider, | |||
| 372 | + | AuthMethod: resolved.AuthMethod, | |||
| 373 | + | CWD: args.CWD, | |||
| 370 | Save: func(c telegram.Config) error { | 374 | Save: func(c telegram.Config) error { | ||
| 371 | return telegram.SaveConfig(ZotHome(), c) | 375 | return telegram.SaveConfig(ZotHome(), c) | ||
| 372 | }, | 376 | }, | ||
| @@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error { | |||||
| 381 | } | 385 | } | ||
| 382 | agent.Client = next.NewClient() | 386 | agent.Client = next.NewClient() | ||
| 383 | agent.Model = next.Model | 387 | agent.Model = next.Model | ||
| 388 | + | b.Provider = next.Provider | |||
| 389 | + | b.AuthMethod = next.AuthMethod | |||
| 390 | + | b.CWD = next.CWD | |||
| 384 | return nil | 391 | return nil | ||
| 385 | }, | 392 | }, | ||
| 386 | } | 393 | } | ||
M internal/agent/modes/interactive.go +30 -0
| @@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() { | |||
| 2131 | 2131 | i.mu.Unlock() | |
| 2132 | 2132 | if cancel != nil { | |
| 2133 | 2133 | cancel() | |
| 2134 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 2134 | 2135 | } | |
| 2135 | 2136 | } | |
| 2136 | 2137 | ||
| @@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock | |||
| 3856 | 3857 | ||
| 3857 | 3858 | func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } | |
| 3858 | 3859 | ||
| 3860 | + | func (h *telegramHost) Status() string { | |
| 3861 | + | h.iv.mu.Lock() | |
| 3862 | + | providerName := h.iv.cfg.Provider | |
| 3863 | + | model := h.iv.cfg.Model | |
| 3864 | + | cwd := h.iv.cfg.CWD | |
| 3865 | + | usage := h.iv.cumUsage | |
| 3866 | + | subscription := h.iv.cfg.AuthMethod == "oauth" | |
| 3867 | + | ctxUsed := h.iv.lastCtxInput | |
| 3868 | + | busy := h.iv.busy | |
| 3869 | + | queued := len(h.iv.queued) | |
| 3870 | + | h.iv.mu.Unlock() | |
| 3871 | + | ||
| 3872 | + | ctxMax := 0 | |
| 3873 | + | if m, err := provider.FindModel(providerName, model); err == nil { | |
| 3874 | + | ctxMax = m.ContextWindow | |
| 3875 | + | } | |
| 3876 | + | return telegram.FormatStatus(telegram.StatusSnapshot{ | |
| 3877 | + | Provider: providerName, | |
| 3878 | + | Model: model, | |
| 3879 | + | CWD: cwd, | |
| 3880 | + | Usage: usage, | |
| 3881 | + | Subscription: subscription, | |
| 3882 | + | ContextUsed: ctxUsed, | |
| 3883 | + | ContextMax: ctxMax, | |
| 3884 | + | Busy: busy, | |
| 3885 | + | Queued: queued, | |
| 3886 | + | }) | |
| 3887 | + | } | |
| 3888 | + | ||
| 3859 | 3889 | func (h *telegramHost) Notify(level, message string) { | |
| 3860 | 3890 | h.iv.mu.Lock() | |
| 3861 | 3891 | switch level { | |
| @@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() { | |||||
| 2131 | i.mu.Unlock() | 2131 | i.mu.Unlock() | ||
| 2132 | if cancel != nil { | 2132 | if cancel != nil { | ||
| 2133 | cancel() | 2133 | cancel() | ||
| 2134 | + | i.confirmDialog.CancelAll("turn cancelled") | |||
| 2134 | } | 2135 | } | ||
| 2135 | } | 2136 | } | ||
| 2136 | 2137 | ||||
| @@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock | |||||
| 3856 | 3857 | ||||
| 3857 | func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } | 3858 | func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() } | ||
| 3858 | 3859 | ||||
| 3860 | + | func (h *telegramHost) Status() string { | |||
| 3861 | + | h.iv.mu.Lock() | |||
| 3862 | + | providerName := h.iv.cfg.Provider | |||
| 3863 | + | model := h.iv.cfg.Model | |||
| 3864 | + | cwd := h.iv.cfg.CWD | |||
| 3865 | + | usage := h.iv.cumUsage | |||
| 3866 | + | subscription := h.iv.cfg.AuthMethod == "oauth" | |||
| 3867 | + | ctxUsed := h.iv.lastCtxInput | |||
| 3868 | + | busy := h.iv.busy | |||
| 3869 | + | queued := len(h.iv.queued) | |||
| 3870 | + | h.iv.mu.Unlock() | |||
| 3871 | + | ||||
| 3872 | + | ctxMax := 0 | |||
| 3873 | + | if m, err := provider.FindModel(providerName, model); err == nil { | |||
| 3874 | + | ctxMax = m.ContextWindow | |||
| 3875 | + | } | |||
| 3876 | + | return telegram.FormatStatus(telegram.StatusSnapshot{ | |||
| 3877 | + | Provider: providerName, | |||
| 3878 | + | Model: model, | |||
| 3879 | + | CWD: cwd, | |||
| 3880 | + | Usage: usage, | |||
| 3881 | + | Subscription: subscription, | |||
| 3882 | + | ContextUsed: ctxUsed, | |||
| 3883 | + | ContextMax: ctxMax, | |||
| 3884 | + | Busy: busy, | |||
| 3885 | + | Queued: queued, | |||
| 3886 | + | }) | |||
| 3887 | + | } | |||
| 3888 | + | ||||
| 3859 | func (h *telegramHost) Notify(level, message string) { | 3889 | func (h *telegramHost) Notify(level, message string) { | ||
| 3860 | h.iv.mu.Lock() | 3890 | h.iv.mu.Lock() | ||
| 3861 | switch level { | 3891 | switch level { | ||
M internal/agent/modes/telegram/bot.go +56 -31
| @@ -15,10 +15,13 @@ import ( | |||
| 15 | 15 | // the agent. It is a long-running goroutine; Run blocks until ctx | |
| 16 | 16 | // cancels. | |
| 17 | 17 | type Bot struct { | |
| 18 | - | Client *Client | |
| 19 | - | Agent *core.Agent | |
| 20 | - | Config Config | |
| 21 | - | ZotHome string | |
| 18 | + | Client *Client | |
| 19 | + | Agent *core.Agent | |
| 20 | + | Config Config | |
| 21 | + | ZotHome string | |
| 22 | + | Provider string | |
| 23 | + | AuthMethod string | |
| 24 | + | CWD string | |
| 22 | 25 | // Save persists cfg to bot.json. Called whenever the bot pairs | |
| 23 | 26 | // with a new allowed user or advances LastUpdateID. | |
| 24 | 27 | Save func(Config) error | |
| @@ -28,10 +31,11 @@ type Bot struct { | |||
| 28 | 31 | // agent.ResolveCredentialFull which auto-refreshes expired tokens. | |
| 29 | 32 | RefreshCreds func() error | |
| 30 | 33 | ||
| 31 | - | mu sync.Mutex | |
| 32 | - | busy bool | |
| 33 | - | activeCtx context.CancelFunc | |
| 34 | - | queue []queuedTurn | |
| 34 | + | mu sync.Mutex | |
| 35 | + | busy bool | |
| 36 | + | activeCtx context.CancelFunc | |
| 37 | + | queue []queuedTurn | |
| 38 | + | lastCtxInput int | |
| 35 | 39 | } | |
| 36 | 40 | ||
| 37 | 41 | // queuedTurn is an inbound DM waiting to become a prompt. | |
| @@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error { | |||
| 144 | 148 | switch text { | |
| 145 | 149 | case "/start", "/help": | |
| 146 | 150 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 147 | - | "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.", | |
| 151 | + | "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.", | |
| 148 | 152 | msg.MessageID) | |
| 149 | 153 | return nil | |
| 150 | 154 | case "/status": | |
| 151 | 155 | return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) | |
| 152 | 156 | case "/stop": | |
| 153 | - | b.mu.Lock() | |
| 154 | - | cancel := b.activeCtx | |
| 155 | - | b.mu.Unlock() | |
| 156 | - | if cancel != nil { | |
| 157 | - | cancel() | |
| 158 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID) | |
| 159 | - | } else { | |
| 160 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID) | |
| 161 | - | } | |
| 157 | + | b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) | |
| 158 | + | return nil | |
| 159 | + | } | |
| 160 | + | if isStopCommand(text) { | |
| 161 | + | b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) | |
| 162 | 162 | return nil | |
| 163 | 163 | } | |
| 164 | 164 | ||
| @@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) { | |||
| 247 | 247 | switch e := ev.(type) { | |
| 248 | 248 | case core.EvTextDelta: | |
| 249 | 249 | replyBuilder.WriteString(e.Delta) | |
| 250 | + | case core.EvUsage: | |
| 251 | + | b.mu.Lock() | |
| 252 | + | if e.Usage.InputTokens > 0 { | |
| 253 | + | b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | |
| 254 | + | } | |
| 255 | + | b.mu.Unlock() | |
| 250 | 256 | case core.EvAssistantMessage: | |
| 251 | 257 | var sb strings.Builder | |
| 252 | 258 | for _, c := range e.Message.Content { | |
| @@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() { | |||
| 308 | 314 | return cancel | |
| 309 | 315 | } | |
| 310 | 316 | ||
| 317 | + | func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) { | |
| 318 | + | b.mu.Lock() | |
| 319 | + | cancel := b.activeCtx | |
| 320 | + | b.mu.Unlock() | |
| 321 | + | if cancel != nil { | |
| 322 | + | cancel() | |
| 323 | + | _ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo) | |
| 324 | + | } else { | |
| 325 | + | _ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 311 | 329 | // sendStatus describes agent state to the Telegram user. | |
| 312 | 330 | func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { | |
| 313 | 331 | b.mu.Lock() | |
| 314 | 332 | busy := b.busy | |
| 315 | 333 | queued := len(b.queue) | |
| 334 | + | ctxUsed := b.lastCtxInput | |
| 335 | + | providerName := b.Provider | |
| 336 | + | authMethod := b.AuthMethod | |
| 337 | + | cwd := b.CWD | |
| 316 | 338 | b.mu.Unlock() | |
| 317 | 339 | ||
| 318 | - | state := "idle" | |
| 319 | - | if busy { | |
| 320 | - | state = "working" | |
| 321 | - | } | |
| 322 | - | lines := []string{ | |
| 323 | - | fmt.Sprintf("state: %s", state), | |
| 324 | - | fmt.Sprintf("queued: %d", queued), | |
| 325 | - | fmt.Sprintf("model: %s", b.Agent.Model), | |
| 326 | - | } | |
| 327 | - | cost := b.Agent.Cost() | |
| 328 | - | lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)", | |
| 329 | - | cost.CostUSD, cost.InputTokens, cost.OutputTokens)) | |
| 330 | - | return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo) | |
| 340 | + | model := b.Agent.Model | |
| 341 | + | ctxMax := 0 | |
| 342 | + | if m, err := provider.FindModel(providerName, model); err == nil { | |
| 343 | + | ctxMax = m.ContextWindow | |
| 344 | + | } | |
| 345 | + | return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{ | |
| 346 | + | Provider: providerName, | |
| 347 | + | Model: model, | |
| 348 | + | CWD: cwd, | |
| 349 | + | Usage: b.Agent.Cost(), | |
| 350 | + | Subscription: authMethod == "oauth", | |
| 351 | + | ContextUsed: ctxUsed, | |
| 352 | + | ContextMax: ctxMax, | |
| 353 | + | Busy: busy, | |
| 354 | + | Queued: queued, | |
| 355 | + | }), replyTo) | |
| 331 | 356 | } | |
| 332 | 357 | ||
| 333 | 358 | // download fetches a file from Telegram and returns bytes + mime. | |
| @@ -15,10 +15,13 @@ import ( | |||||
| 15 | // the agent. It is a long-running goroutine; Run blocks until ctx | 15 | // the agent. It is a long-running goroutine; Run blocks until ctx | ||
| 16 | // cancels. | 16 | // cancels. | ||
| 17 | type Bot struct { | 17 | type Bot struct { | ||
| 18 | - | Client *Client | 18 | + | Client *Client |
| 19 | - | Agent *core.Agent | 19 | + | Agent *core.Agent |
| 20 | - | Config Config | 20 | + | Config Config |
| 21 | - | ZotHome string | 21 | + | ZotHome string |
| 22 | + | Provider string | |||
| 23 | + | AuthMethod string | |||
| 24 | + | CWD string | |||
| 22 | // Save persists cfg to bot.json. Called whenever the bot pairs | 25 | // Save persists cfg to bot.json. Called whenever the bot pairs | ||
| 23 | // with a new allowed user or advances LastUpdateID. | 26 | // with a new allowed user or advances LastUpdateID. | ||
| 24 | Save func(Config) error | 27 | Save func(Config) error | ||
| @@ -28,10 +31,11 @@ type Bot struct { | |||||
| 28 | // agent.ResolveCredentialFull which auto-refreshes expired tokens. | 31 | // agent.ResolveCredentialFull which auto-refreshes expired tokens. | ||
| 29 | RefreshCreds func() error | 32 | RefreshCreds func() error | ||
| 30 | 33 | ||||
| 31 | - | mu sync.Mutex | 34 | + | mu sync.Mutex |
| 32 | - | busy bool | 35 | + | busy bool |
| 33 | - | activeCtx context.CancelFunc | 36 | + | activeCtx context.CancelFunc |
| 34 | - | queue []queuedTurn | 37 | + | queue []queuedTurn |
| 38 | + | lastCtxInput int | |||
| 35 | } | 39 | } | ||
| 36 | 40 | ||||
| 37 | // queuedTurn is an inbound DM waiting to become a prompt. | 41 | // queuedTurn is an inbound DM waiting to become a prompt. | ||
| @@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error { | |||||
| 144 | switch text { | 148 | switch text { | ||
| 145 | case "/start", "/help": | 149 | case "/start", "/help": | ||
| 146 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 150 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | ||
| 147 | - | "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.", | 151 | + | "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.", |
| 148 | msg.MessageID) | 152 | msg.MessageID) | ||
| 149 | return nil | 153 | return nil | ||
| 150 | case "/status": | 154 | case "/status": | ||
| 151 | return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) | 155 | return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID) | ||
| 152 | case "/stop": | 156 | case "/stop": | ||
| 153 | - | b.mu.Lock() | 157 | + | b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) |
| 154 | - | cancel := b.activeCtx | 158 | + | return nil |
| 155 | - | b.mu.Unlock() | 159 | + | } |
| 156 | - | if cancel != nil { | 160 | + | if isStopCommand(text) { |
| 157 | - | cancel() | 161 | + | b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID) |
| 158 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID) | |||
| 159 | - | } else { | |||
| 160 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID) | |||
| 161 | - | } | |||
| 162 | return nil | 162 | return nil | ||
| 163 | } | 163 | } | ||
| 164 | 164 | ||||
| @@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) { | |||||
| 247 | switch e := ev.(type) { | 247 | switch e := ev.(type) { | ||
| 248 | case core.EvTextDelta: | 248 | case core.EvTextDelta: | ||
| 249 | replyBuilder.WriteString(e.Delta) | 249 | replyBuilder.WriteString(e.Delta) | ||
| 250 | + | case core.EvUsage: | |||
| 251 | + | b.mu.Lock() | |||
| 252 | + | if e.Usage.InputTokens > 0 { | |||
| 253 | + | b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | |||
| 254 | + | } | |||
| 255 | + | b.mu.Unlock() | |||
| 250 | case core.EvAssistantMessage: | 256 | case core.EvAssistantMessage: | ||
| 251 | var sb strings.Builder | 257 | var sb strings.Builder | ||
| 252 | for _, c := range e.Message.Content { | 258 | for _, c := range e.Message.Content { | ||
| @@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() { | |||||
| 308 | return cancel | 314 | return cancel | ||
| 309 | } | 315 | } | ||
| 310 | 316 | ||||
| 317 | + | func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) { | |||
| 318 | + | b.mu.Lock() | |||
| 319 | + | cancel := b.activeCtx | |||
| 320 | + | b.mu.Unlock() | |||
| 321 | + | if cancel != nil { | |||
| 322 | + | cancel() | |||
| 323 | + | _ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo) | |||
| 324 | + | } else { | |||
| 325 | + | _ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo) | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 311 | // sendStatus describes agent state to the Telegram user. | 329 | // sendStatus describes agent state to the Telegram user. | ||
| 312 | func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { | 330 | func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error { | ||
| 313 | b.mu.Lock() | 331 | b.mu.Lock() | ||
| 314 | busy := b.busy | 332 | busy := b.busy | ||
| 315 | queued := len(b.queue) | 333 | queued := len(b.queue) | ||
| 334 | + | ctxUsed := b.lastCtxInput | |||
| 335 | + | providerName := b.Provider | |||
| 336 | + | authMethod := b.AuthMethod | |||
| 337 | + | cwd := b.CWD | |||
| 316 | b.mu.Unlock() | 338 | b.mu.Unlock() | ||
| 317 | 339 | ||||
| 318 | - | state := "idle" | 340 | + | model := b.Agent.Model |
| 319 | - | if busy { | 341 | + | ctxMax := 0 |
| 320 | - | state = "working" | 342 | + | if m, err := provider.FindModel(providerName, model); err == nil { |
| 321 | - | } | 343 | + | ctxMax = m.ContextWindow |
| 322 | - | lines := []string{ | 344 | + | } |
| 323 | - | fmt.Sprintf("state: %s", state), | 345 | + | return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{ |
| 324 | - | fmt.Sprintf("queued: %d", queued), | 346 | + | Provider: providerName, |
| 325 | - | fmt.Sprintf("model: %s", b.Agent.Model), | 347 | + | Model: model, |
| 326 | - | } | 348 | + | CWD: cwd, |
| 327 | - | cost := b.Agent.Cost() | 349 | + | Usage: b.Agent.Cost(), |
| 328 | - | lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)", | 350 | + | Subscription: authMethod == "oauth", |
| 329 | - | cost.CostUSD, cost.InputTokens, cost.OutputTokens)) | 351 | + | ContextUsed: ctxUsed, |
| 330 | - | return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo) | 352 | + | ContextMax: ctxMax, |
| 353 | + | Busy: busy, | |||
| 354 | + | Queued: queued, | |||
| 355 | + | }), replyTo) | |||
| 331 | } | 356 | } | ||
| 332 | 357 | ||||
| 333 | // download fetches a file from Telegram and returns bytes + mime. | 358 | // download fetches a file from Telegram and returns bytes + mime. | ||
M internal/agent/modes/telegram/bridge.go +16 -8
| @@ -19,9 +19,13 @@ type Host interface { | |||
| 19 | 19 | SubmitOrQueue(prompt string, images []provider.ImageBlock) | |
| 20 | 20 | ||
| 21 | 21 | // CancelTurn aborts the active turn (if any). Called when the | |
| 22 | - | // paired Telegram user sends /stop. | |
| 22 | + | // paired Telegram user sends /stop or plain "stop". | |
| 23 | 23 | CancelTurn() | |
| 24 | 24 | ||
| 25 | + | // Status returns the current model, usage, context, and cwd summary | |
| 26 | + | // shown when the paired Telegram user sends /status. | |
| 27 | + | Status() string | |
| 28 | + | ||
| 25 | 29 | // Notify pushes a one-shot status line into the chat. Used to | |
| 26 | 30 | // surface bridge events ("connected as @bot", "paired with | |
| 27 | 31 | // user X", etc.) in the user's local transcript. | |
| @@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) { | |||
| 272 | 276 | ||
| 273 | 277 | // handleUpdate applies pairing, gates on the allowed user, decodes | |
| 274 | 278 | // the interesting bits (text, caption, image attachments), and | |
| 275 | - | // forwards them to the Host. Built-in slash commands (/start, | |
| 276 | - | // /help, /status, /stop) are handled inline without touching the | |
| 277 | - | // agent. | |
| 279 | + | // forwards them to the Host. Built-in commands (/start, /help, | |
| 280 | + | // /status, /stop, and plain "stop") are handled inline without | |
| 281 | + | // touching the agent. | |
| 278 | 282 | func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |
| 279 | 283 | msg := u.Message | |
| 280 | 284 | if msg == nil { | |
| @@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 329 | 333 | switch text { | |
| 330 | 334 | case "/start", "/help": | |
| 331 | 335 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 332 | - | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.", | |
| 336 | + | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", | |
| 333 | 337 | msg.MessageID) | |
| 334 | 338 | return | |
| 335 | 339 | case "/status": | |
| 336 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 337 | - | fmt.Sprintf("mirror active. paired user: %d.", paired), | |
| 338 | - | msg.MessageID) | |
| 340 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) | |
| 339 | 341 | return | |
| 340 | 342 | case "/stop": | |
| 341 | 343 | b.Host.CancelTurn() | |
| @@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 343 | 345 | "cancelled the current turn.", msg.MessageID) | |
| 344 | 346 | return | |
| 345 | 347 | } | |
| 348 | + | if isStopCommand(text) { | |
| 349 | + | b.Host.CancelTurn() | |
| 350 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 351 | + | "cancelled the current turn.", msg.MessageID) | |
| 352 | + | return | |
| 353 | + | } | |
| 346 | 354 | ||
| 347 | 355 | // Build the prompt: text + caption; download image attachments. | |
| 348 | 356 | prompt := strings.TrimSpace(msg.Text) | |
| @@ -19,9 +19,13 @@ type Host interface { | |||||
| 19 | SubmitOrQueue(prompt string, images []provider.ImageBlock) | 19 | SubmitOrQueue(prompt string, images []provider.ImageBlock) | ||
| 20 | 20 | ||||
| 21 | // CancelTurn aborts the active turn (if any). Called when the | 21 | // CancelTurn aborts the active turn (if any). Called when the | ||
| 22 | - | // paired Telegram user sends /stop. | 22 | + | // paired Telegram user sends /stop or plain "stop". |
| 23 | CancelTurn() | 23 | CancelTurn() | ||
| 24 | 24 | ||||
| 25 | + | // Status returns the current model, usage, context, and cwd summary | |||
| 26 | + | // shown when the paired Telegram user sends /status. | |||
| 27 | + | Status() string | |||
| 28 | + | ||||
| 25 | // Notify pushes a one-shot status line into the chat. Used to | 29 | // Notify pushes a one-shot status line into the chat. Used to | ||
| 26 | // surface bridge events ("connected as @bot", "paired with | 30 | // surface bridge events ("connected as @bot", "paired with | ||
| 27 | // user X", etc.) in the user's local transcript. | 31 | // user X", etc.) in the user's local transcript. | ||
| @@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) { | |||||
| 272 | 276 | ||||
| 273 | // handleUpdate applies pairing, gates on the allowed user, decodes | 277 | // handleUpdate applies pairing, gates on the allowed user, decodes | ||
| 274 | // the interesting bits (text, caption, image attachments), and | 278 | // the interesting bits (text, caption, image attachments), and | ||
| 275 | - | // forwards them to the Host. Built-in slash commands (/start, | 279 | + | // forwards them to the Host. Built-in commands (/start, /help, |
| 276 | - | // /help, /status, /stop) are handled inline without touching the | 280 | + | // /status, /stop, and plain "stop") are handled inline without |
| 277 | - | // agent. | 281 | + | // touching the agent. |
| 278 | func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | 282 | func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | ||
| 279 | msg := u.Message | 283 | msg := u.Message | ||
| 280 | if msg == nil { | 284 | if msg == nil { | ||
| @@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 329 | switch text { | 333 | switch text { | ||
| 330 | case "/start", "/help": | 334 | case "/start", "/help": | ||
| 331 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 335 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | ||
| 332 | - | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.", | 336 | + | "mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.", |
| 333 | msg.MessageID) | 337 | msg.MessageID) | ||
| 334 | return | 338 | return | ||
| 335 | case "/status": | 339 | case "/status": | ||
| 336 | - | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 340 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID) |
| 337 | - | fmt.Sprintf("mirror active. paired user: %d.", paired), | |||
| 338 | - | msg.MessageID) | |||
| 339 | return | 341 | return | ||
| 340 | case "/stop": | 342 | case "/stop": | ||
| 341 | b.Host.CancelTurn() | 343 | b.Host.CancelTurn() | ||
| @@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 343 | "cancelled the current turn.", msg.MessageID) | 345 | "cancelled the current turn.", msg.MessageID) | ||
| 344 | return | 346 | return | ||
| 345 | } | 347 | } | ||
| 348 | + | if isStopCommand(text) { | |||
| 349 | + | b.Host.CancelTurn() | |||
| 350 | + | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |||
| 351 | + | "cancelled the current turn.", msg.MessageID) | |||
| 352 | + | return | |||
| 353 | + | } | |||
| 346 | 354 | ||||
| 347 | // Build the prompt: text + caption; download image attachments. | 355 | // Build the prompt: text + caption; download image attachments. | ||
| 348 | prompt := strings.TrimSpace(msg.Text) | 356 | prompt := strings.TrimSpace(msg.Text) | ||
A internal/agent/modes/telegram/commands.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // isStopCommand reports whether text should abort the active turn. | |
| 6 | + | // Telegram users often type plain "stop" rather than bot-style | |
| 7 | + | // "/stop"; keep this intentionally narrow so normal prompts like | |
| 8 | + | // "stop doing X" still go to the agent. | |
| 9 | + | func isStopCommand(text string) bool { | |
| 10 | + | return strings.EqualFold(strings.TrimSpace(text), "stop") | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // isStopCommand reports whether text should abort the active turn. | |||
| 6 | + | // Telegram users often type plain "stop" rather than bot-style | |||
| 7 | + | // "/stop"; keep this intentionally narrow so normal prompts like | |||
| 8 | + | // "stop doing X" still go to the agent. | |||
| 9 | + | func isStopCommand(text string) bool { | |||
| 10 | + | return strings.EqualFold(strings.TrimSpace(text), "stop") | |||
| 11 | + | } | |||
A internal/agent/modes/telegram/commands_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestIsStopCommand(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | text string | |
| 8 | + | want bool | |
| 9 | + | }{ | |
| 10 | + | {"stop", true}, | |
| 11 | + | {" STOP ", true}, | |
| 12 | + | {"Stop", true}, | |
| 13 | + | {"/stop", false}, // handled by the slash-command switch | |
| 14 | + | {"stop please", false}, | |
| 15 | + | {"please stop", false}, | |
| 16 | + | {"", false}, | |
| 17 | + | } | |
| 18 | + | for _, tt := range tests { | |
| 19 | + | if got := isStopCommand(tt.text); got != tt.want { | |
| 20 | + | t.Fatalf("isStopCommand(%q) = %v, want %v", tt.text, got, tt.want) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestIsStopCommand(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | text string | |||
| 8 | + | want bool | |||
| 9 | + | }{ | |||
| 10 | + | {"stop", true}, | |||
| 11 | + | {" STOP ", true}, | |||
| 12 | + | {"Stop", true}, | |||
| 13 | + | {"/stop", false}, // handled by the slash-command switch | |||
| 14 | + | {"stop please", false}, | |||
| 15 | + | {"please stop", false}, | |||
| 16 | + | {"", false}, | |||
| 17 | + | } | |||
| 18 | + | for _, tt := range tests { | |||
| 19 | + | if got := isStopCommand(tt.text); got != tt.want { | |||
| 20 | + | t.Fatalf("isStopCommand(%q) = %v, want %v", tt.text, got, tt.want) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | } | |||
A internal/agent/modes/telegram/status.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // StatusSnapshot is the small cross-host state bundle rendered for | |
| 12 | + | // Telegram /status replies. | |
| 13 | + | type StatusSnapshot struct { | |
| 14 | + | Provider string | |
| 15 | + | Model string | |
| 16 | + | CWD string | |
| 17 | + | Usage provider.Usage | |
| 18 | + | Subscription bool | |
| 19 | + | ContextUsed int | |
| 20 | + | ContextMax int | |
| 21 | + | Busy bool | |
| 22 | + | Queued int | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // FormatStatus renders the same compact model/usage/cost/context | |
| 26 | + | // information shown in the TUI status bar, plus the current directory. | |
| 27 | + | func FormatStatus(s StatusSnapshot) string { | |
| 28 | + | providerName := strings.TrimSpace(s.Provider) | |
| 29 | + | model := strings.TrimSpace(s.Model) | |
| 30 | + | if providerName == "" { | |
| 31 | + | providerName = "unknown" | |
| 32 | + | } | |
| 33 | + | if model == "" { | |
| 34 | + | model = "unknown" | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | var stats []string | |
| 38 | + | if s.Usage.InputTokens > 0 { | |
| 39 | + | stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens))) | |
| 40 | + | } | |
| 41 | + | if s.Usage.OutputTokens > 0 { | |
| 42 | + | stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens))) | |
| 43 | + | } | |
| 44 | + | if s.Usage.CacheReadTokens > 0 { | |
| 45 | + | stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens))) | |
| 46 | + | } | |
| 47 | + | if s.Usage.CacheWriteTokens > 0 { | |
| 48 | + | stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens))) | |
| 49 | + | } | |
| 50 | + | if s.Usage.CostUSD > 0 || s.Subscription { | |
| 51 | + | cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD) | |
| 52 | + | if s.Subscription { | |
| 53 | + | cost += " (sub)" | |
| 54 | + | } | |
| 55 | + | stats = append(stats, cost) | |
| 56 | + | } | |
| 57 | + | if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" { | |
| 58 | + | stats = append(stats, ctx) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | line := fmt.Sprintf("(%s) %s", providerName, model) | |
| 62 | + | if len(stats) > 0 { | |
| 63 | + | line += " " + strings.Join(stats, " ") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | state := "idle" | |
| 67 | + | if s.Busy { | |
| 68 | + | state = "working" | |
| 69 | + | } | |
| 70 | + | lines := []string{line, "state: " + state} | |
| 71 | + | if s.Queued > 0 { | |
| 72 | + | lines = append(lines, fmt.Sprintf("queued: %d", s.Queued)) | |
| 73 | + | } | |
| 74 | + | if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" { | |
| 75 | + | lines = append(lines, "cwd: "+cwd) | |
| 76 | + | } | |
| 77 | + | return strings.Join(lines, "\n") | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func contextUsage(used, max int) string { | |
| 81 | + | if max <= 0 { | |
| 82 | + | if used <= 0 { | |
| 83 | + | return "" | |
| 84 | + | } | |
| 85 | + | return formatTokens(used) | |
| 86 | + | } | |
| 87 | + | pct := float64(used) / float64(max) * 100 | |
| 88 | + | return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max)) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func formatTokens(n int) string { | |
| 92 | + | switch { | |
| 93 | + | case n < 0: | |
| 94 | + | return "0" | |
| 95 | + | case n < 1000: | |
| 96 | + | return fmt.Sprintf("%d", n) | |
| 97 | + | case n < 10000: | |
| 98 | + | return fmt.Sprintf("%.1fk", float64(n)/1000) | |
| 99 | + | case n < 1_000_000: | |
| 100 | + | return fmt.Sprintf("%dk", (n+500)/1000) | |
| 101 | + | case n < 10_000_000: | |
| 102 | + | return fmt.Sprintf("%.1fM", float64(n)/1_000_000) | |
| 103 | + | default: | |
| 104 | + | return fmt.Sprintf("%dM", (n+500_000)/1_000_000) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func shortenHome(path string) string { | |
| 109 | + | home, err := os.UserHomeDir() | |
| 110 | + | if err != nil || home == "" { | |
| 111 | + | return path | |
| 112 | + | } | |
| 113 | + | if path == home { | |
| 114 | + | return "~" | |
| 115 | + | } | |
| 116 | + | if strings.HasPrefix(path, home+string(os.PathSeparator)) { | |
| 117 | + | return "~" + strings.TrimPrefix(path, home) | |
| 118 | + | } | |
| 119 | + | return path | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // StatusSnapshot is the small cross-host state bundle rendered for | |||
| 12 | + | // Telegram /status replies. | |||
| 13 | + | type StatusSnapshot struct { | |||
| 14 | + | Provider string | |||
| 15 | + | Model string | |||
| 16 | + | CWD string | |||
| 17 | + | Usage provider.Usage | |||
| 18 | + | Subscription bool | |||
| 19 | + | ContextUsed int | |||
| 20 | + | ContextMax int | |||
| 21 | + | Busy bool | |||
| 22 | + | Queued int | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // FormatStatus renders the same compact model/usage/cost/context | |||
| 26 | + | // information shown in the TUI status bar, plus the current directory. | |||
| 27 | + | func FormatStatus(s StatusSnapshot) string { | |||
| 28 | + | providerName := strings.TrimSpace(s.Provider) | |||
| 29 | + | model := strings.TrimSpace(s.Model) | |||
| 30 | + | if providerName == "" { | |||
| 31 | + | providerName = "unknown" | |||
| 32 | + | } | |||
| 33 | + | if model == "" { | |||
| 34 | + | model = "unknown" | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | var stats []string | |||
| 38 | + | if s.Usage.InputTokens > 0 { | |||
| 39 | + | stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens))) | |||
| 40 | + | } | |||
| 41 | + | if s.Usage.OutputTokens > 0 { | |||
| 42 | + | stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens))) | |||
| 43 | + | } | |||
| 44 | + | if s.Usage.CacheReadTokens > 0 { | |||
| 45 | + | stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens))) | |||
| 46 | + | } | |||
| 47 | + | if s.Usage.CacheWriteTokens > 0 { | |||
| 48 | + | stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens))) | |||
| 49 | + | } | |||
| 50 | + | if s.Usage.CostUSD > 0 || s.Subscription { | |||
| 51 | + | cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD) | |||
| 52 | + | if s.Subscription { | |||
| 53 | + | cost += " (sub)" | |||
| 54 | + | } | |||
| 55 | + | stats = append(stats, cost) | |||
| 56 | + | } | |||
| 57 | + | if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" { | |||
| 58 | + | stats = append(stats, ctx) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | line := fmt.Sprintf("(%s) %s", providerName, model) | |||
| 62 | + | if len(stats) > 0 { | |||
| 63 | + | line += " " + strings.Join(stats, " ") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | state := "idle" | |||
| 67 | + | if s.Busy { | |||
| 68 | + | state = "working" | |||
| 69 | + | } | |||
| 70 | + | lines := []string{line, "state: " + state} | |||
| 71 | + | if s.Queued > 0 { | |||
| 72 | + | lines = append(lines, fmt.Sprintf("queued: %d", s.Queued)) | |||
| 73 | + | } | |||
| 74 | + | if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" { | |||
| 75 | + | lines = append(lines, "cwd: "+cwd) | |||
| 76 | + | } | |||
| 77 | + | return strings.Join(lines, "\n") | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func contextUsage(used, max int) string { | |||
| 81 | + | if max <= 0 { | |||
| 82 | + | if used <= 0 { | |||
| 83 | + | return "" | |||
| 84 | + | } | |||
| 85 | + | return formatTokens(used) | |||
| 86 | + | } | |||
| 87 | + | pct := float64(used) / float64(max) * 100 | |||
| 88 | + | return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max)) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func formatTokens(n int) string { | |||
| 92 | + | switch { | |||
| 93 | + | case n < 0: | |||
| 94 | + | return "0" | |||
| 95 | + | case n < 1000: | |||
| 96 | + | return fmt.Sprintf("%d", n) | |||
| 97 | + | case n < 10000: | |||
| 98 | + | return fmt.Sprintf("%.1fk", float64(n)/1000) | |||
| 99 | + | case n < 1_000_000: | |||
| 100 | + | return fmt.Sprintf("%dk", (n+500)/1000) | |||
| 101 | + | case n < 10_000_000: | |||
| 102 | + | return fmt.Sprintf("%.1fM", float64(n)/1_000_000) | |||
| 103 | + | default: | |||
| 104 | + | return fmt.Sprintf("%dM", (n+500_000)/1_000_000) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func shortenHome(path string) string { | |||
| 109 | + | home, err := os.UserHomeDir() | |||
| 110 | + | if err != nil || home == "" { | |||
| 111 | + | return path | |||
| 112 | + | } | |||
| 113 | + | if path == home { | |||
| 114 | + | return "~" | |||
| 115 | + | } | |||
| 116 | + | if strings.HasPrefix(path, home+string(os.PathSeparator)) { | |||
| 117 | + | return "~" + strings.TrimPrefix(path, home) | |||
| 118 | + | } | |||
| 119 | + | return path | |||
| 120 | + | } | |||
A internal/agent/modes/telegram/status_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFormatStatusIncludesModelUsageContextAndCWD(t *testing.T) { | |
| 11 | + | got := FormatStatus(StatusSnapshot{ | |
| 12 | + | Provider: "openai", | |
| 13 | + | Model: "gpt-5.5", | |
| 14 | + | CWD: "/tmp/zot", | |
| 15 | + | Usage: provider.Usage{InputTokens: 961_000, OutputTokens: 10_000, CacheReadTokens: 770_000, CostUSD: 2.749}, | |
| 16 | + | Subscription: true, | |
| 17 | + | ContextUsed: 44_800, | |
| 18 | + | ContextMax: 400_000, | |
| 19 | + | Busy: true, | |
| 20 | + | Queued: 2, | |
| 21 | + | }) | |
| 22 | + | ||
| 23 | + | wants := []string{ | |
| 24 | + | "(openai) gpt-5.5", | |
| 25 | + | "↑961k", | |
| 26 | + | "↓10k", | |
| 27 | + | "R770k", | |
| 28 | + | "$2.749 (sub)", | |
| 29 | + | "11.2%/400k", | |
| 30 | + | "state: working", | |
| 31 | + | "queued: 2", | |
| 32 | + | "cwd: /tmp/zot", | |
| 33 | + | } | |
| 34 | + | for _, want := range wants { | |
| 35 | + | if !strings.Contains(got, want) { | |
| 36 | + | t.Fatalf("FormatStatus missing %q in:\n%s", want, got) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFormatStatusIncludesModelUsageContextAndCWD(t *testing.T) { | |||
| 11 | + | got := FormatStatus(StatusSnapshot{ | |||
| 12 | + | Provider: "openai", | |||
| 13 | + | Model: "gpt-5.5", | |||
| 14 | + | CWD: "/tmp/zot", | |||
| 15 | + | Usage: provider.Usage{InputTokens: 961_000, OutputTokens: 10_000, CacheReadTokens: 770_000, CostUSD: 2.749}, | |||
| 16 | + | Subscription: true, | |||
| 17 | + | ContextUsed: 44_800, | |||
| 18 | + | ContextMax: 400_000, | |||
| 19 | + | Busy: true, | |||
| 20 | + | Queued: 2, | |||
| 21 | + | }) | |||
| 22 | + | ||||
| 23 | + | wants := []string{ | |||
| 24 | + | "(openai) gpt-5.5", | |||
| 25 | + | "↑961k", | |||
| 26 | + | "↓10k", | |||
| 27 | + | "R770k", | |||
| 28 | + | "$2.749 (sub)", | |||
| 29 | + | "11.2%/400k", | |||
| 30 | + | "state: working", | |||
| 31 | + | "queued: 2", | |||
| 32 | + | "cwd: /tmp/zot", | |||
| 33 | + | } | |||
| 34 | + | for _, want := range wants { | |||
| 35 | + | if !strings.Contains(got, want) { | |||
| 36 | + | t.Fatalf("FormatStatus missing %q in:\n%s", want, got) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | } | |||