detached 63694af

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 {
362362 }
363363 }
364364
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,
370374 Save: func(c telegram.Config) error {
371375 return telegram.SaveConfig(ZotHome(), c)
372376 },
@@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error {
381385 }
382386 agent.Client = next.NewClient()
383387 agent.Model = next.Model
388+ b.Provider = next.Provider
389+ b.AuthMethod = next.AuthMethod
390+ b.CWD = next.CWD
384391 return nil
385392 },
386393 }
@@ -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.Model387 agent.Model = next.Model
388+ b.Provider = next.Provider
389+ b.AuthMethod = next.AuthMethod
390+ b.CWD = next.CWD
384 return nil391 return nil
385 },392 },
386 }393 }
M internal/agent/modes/interactive.go +30 -0
@@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() {
21312131 i.mu.Unlock()
21322132 if cancel != nil {
21332133 cancel()
2134+ i.confirmDialog.CancelAll("turn cancelled")
21342135 }
21352136 }
21362137
@@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock
38563857
38573858 func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
38583859
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+
38593889 func (h *telegramHost) Notify(level, message string) {
38603890 h.iv.mu.Lock()
38613891 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 (
1515 // the agent. It is a long-running goroutine; Run blocks until ctx
1616 // cancels.
1717 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
2225 // Save persists cfg to bot.json. Called whenever the bot pairs
2326 // with a new allowed user or advances LastUpdateID.
2427 Save func(Config) error
@@ -28,10 +31,11 @@ type Bot struct {
2831 // agent.ResolveCredentialFull which auto-refreshes expired tokens.
2932 RefreshCreds func() error
3033
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
3539 }
3640
3741 // queuedTurn is an inbound DM waiting to become a prompt.
@@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
144148 switch text {
145149 case "/start", "/help":
146150 _ = 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.",
148152 msg.MessageID)
149153 return nil
150154 case "/status":
151155 return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
152156 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)
162162 return nil
163163 }
164164
@@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
247247 switch e := ev.(type) {
248248 case core.EvTextDelta:
249249 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()
250256 case core.EvAssistantMessage:
251257 var sb strings.Builder
252258 for _, c := range e.Message.Content {
@@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
308314 return cancel
309315 }
310316
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+
311329 // sendStatus describes agent state to the Telegram user.
312330 func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
313331 b.mu.Lock()
314332 busy := b.busy
315333 queued := len(b.queue)
334+ ctxUsed := b.lastCtxInput
335+ providerName := b.Provider
336+ authMethod := b.AuthMethod
337+ cwd := b.CWD
316338 b.mu.Unlock()
317339
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)
331356 }
332357
333358 // 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 ctx15 // 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 *Client18+ Client *Client
19- Agent *core.Agent19+ Agent *core.Agent
20- Config Config20+ Config Config
21- ZotHome string21+ ZotHome string
22+ Provider string
23+ AuthMethod string
24+ CWD string
22 // Save persists cfg to bot.json. Called whenever the bot pairs25 // 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) error27 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() error32 RefreshCreds func() error
30 33
31- mu sync.Mutex34+ mu sync.Mutex
32- busy bool35+ busy bool
33- activeCtx context.CancelFunc36+ activeCtx context.CancelFunc
34- queue []queuedTurn37+ 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 nil153 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.activeCtx158+ 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 nil162 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.Builder257 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 cancel314 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.busy332 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 {
1919 SubmitOrQueue(prompt string, images []provider.ImageBlock)
2020
2121 // 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".
2323 CancelTurn()
2424
25+ // Status returns the current model, usage, context, and cwd summary
26+ // shown when the paired Telegram user sends /status.
27+ Status() string
28+
2529 // Notify pushes a one-shot status line into the chat. Used to
2630 // surface bridge events ("connected as @bot", "paired with
2731 // user X", etc.) in the user's local transcript.
@@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) {
272276
273277 // handleUpdate applies pairing, gates on the allowed user, decodes
274278 // 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.
278282 func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
279283 msg := u.Message
280284 if msg == nil {
@@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
329333 switch text {
330334 case "/start", "/help":
331335 _ = 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.",
333337 msg.MessageID)
334338 return
335339 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)
339341 return
340342 case "/stop":
341343 b.Host.CancelTurn()
@@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
343345 "cancelled the current turn.", msg.MessageID)
344346 return
345347 }
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+ }
346354
347355 // Build the prompt: text + caption; download image attachments.
348356 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 the21 // 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 to29 // Notify pushes a one-shot status line into the chat. Used to
26 // surface bridge events ("connected as @bot", "paired with30 // 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, decodes277 // handleUpdate applies pairing, gates on the allowed user, decodes
274 // the interesting bits (text, caption, image attachments), and278 // 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 the280+// /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.Message283 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 return338 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 return341 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 return346 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close