detached 92b87ec

Show Telegram typing indicator for bridged turns

commit
92b87ec05b96d07fa852076951384190cd3a16c8
author
patriceckhart · · 2026-07-12 10:26 UTC
parent
3362bee
signature
unsigned
refs
tag: v0.2.75
1 file · +61 -6
M packages/agent/modes/telegram/bridge.go +61 -6
@@ -44,12 +44,13 @@ type Bridge struct {
4444 Save func(Config) error
4545 Host Host
4646
47- mu sync.Mutex
48- running bool
49- cancel context.CancelFunc
50- me *User
51- chatID int64 // populated after first DM from the paired user
52- replyBuf strings.Builder
47+ mu sync.Mutex
48+ running bool
49+ cancel context.CancelFunc
50+ me *User
51+ chatID int64 // populated after first DM from the paired user
52+ replyBuf strings.Builder
53+ workingCancel context.CancelFunc
5354
5455 // nextReplyFromTelegram is set when the next assistant reply
5556 // should be sent bare (no "zot: " prefix) because the turn was
@@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error {
140141 func (b *Bridge) Stop() {
141142 b.mu.Lock()
142143 cancel := b.cancel
144+ workingCancel := b.workingCancel
143145 b.running = false
144146 b.cancel = nil
147+ b.workingCancel = nil
145148 b.mu.Unlock()
149+ if workingCancel != nil {
150+ workingCancel()
151+ }
146152 if cancel != nil {
147153 cancel()
148154 }
@@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) {
160166 b.mu.Lock()
161167 // prefix := "zot: "
162168 prefix := ""
169+ workingCancel := b.workingCancel
170+ b.workingCancel = nil
163171 if b.nextReplyFromTelegram {
164172 prefix = ""
165173 b.nextReplyFromTelegram = false
166174 }
167175 b.mu.Unlock()
176+ if workingCancel != nil {
177+ workingCancel()
178+ }
168179 b.sendToPaired(text, prefix)
169180 }
170181
@@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) {
178189 b.sendToPaired(text, "you: ")
179190 }
180191
192+// indicateWorking keeps Telegram's typing indicator visible while a turn
193+// submitted from Telegram is waiting or running. Telegram clears chat actions
194+// after a few seconds, so refresh it until the reply is ready or the bridge
195+// stops.
196+func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) {
197+ workingCtx, cancel := context.WithCancel(ctx)
198+
199+ b.mu.Lock()
200+ previous := b.workingCancel
201+ b.workingCancel = cancel
202+ b.mu.Unlock()
203+ if previous != nil {
204+ previous()
205+ }
206+
207+ go func() {
208+ ticker := time.NewTicker(4 * time.Second)
209+ defer ticker.Stop()
210+ for {
211+ _ = b.Client.SendChatAction(workingCtx, chatID, "typing")
212+ select {
213+ case <-workingCtx.Done():
214+ return
215+ case <-ticker.C:
216+ }
217+ }
218+ }()
219+}
220+
221+// stopWorking clears the local typing-indicator refresh loop. Telegram removes
222+// the visible indicator itself when the next message arrives or it expires.
223+func (b *Bridge) stopWorking() {
224+ b.mu.Lock()
225+ cancel := b.workingCancel
226+ b.workingCancel = nil
227+ b.mu.Unlock()
228+ if cancel != nil {
229+ cancel()
230+ }
231+}
232+
181233 // sendToPaired writes text (with an optional prefix, chunked to
182234 // Telegram's 4096-char cap) to the paired chat. No-op when the
183235 // bridge is stopped or before the paired chat id is known.
@@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
342394 return
343395 case "/stop":
344396 b.Host.CancelTurn()
397+ b.stopWorking()
345398 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
346399 "cancelled the current turn.", msg.MessageID)
347400 return
348401 }
349402 if isStopCommand(text) {
350403 b.Host.CancelTurn()
404+ b.stopWorking()
351405 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
352406 "cancelled the current turn.", msg.MessageID)
353407 return
@@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
382436 b.mu.Lock()
383437 b.nextReplyFromTelegram = true
384438 b.mu.Unlock()
439+ b.indicateWorking(ctx, msg.Chat.ID)
385440 b.Host.SubmitOrQueue(prompt, images)
386441 }
387442
@@ -44,12 +44,13 @@ type Bridge struct {
44 Save func(Config) error44 Save func(Config) error
45 Host Host45 Host Host
46 46
47- mu sync.Mutex47+ mu sync.Mutex
48- running bool48+ running bool
49- cancel context.CancelFunc49+ cancel context.CancelFunc
50- me *User50+ me *User
51- chatID int64 // populated after first DM from the paired user51+ chatID int64 // populated after first DM from the paired user
52- replyBuf strings.Builder52+ replyBuf strings.Builder
53+ workingCancel context.CancelFunc
53 54
54 // nextReplyFromTelegram is set when the next assistant reply55 // nextReplyFromTelegram is set when the next assistant reply
55 // should be sent bare (no "zot: " prefix) because the turn was56 // should be sent bare (no "zot: " prefix) because the turn was
@@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error {
140 func (b *Bridge) Stop() {141 func (b *Bridge) Stop() {
141 b.mu.Lock()142 b.mu.Lock()
142 cancel := b.cancel143 cancel := b.cancel
144+ workingCancel := b.workingCancel
143 b.running = false145 b.running = false
144 b.cancel = nil146 b.cancel = nil
147+ b.workingCancel = nil
145 b.mu.Unlock()148 b.mu.Unlock()
149+ if workingCancel != nil {
150+ workingCancel()
151+ }
146 if cancel != nil {152 if cancel != nil {
147 cancel()153 cancel()
148 }154 }
@@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) {
160 b.mu.Lock()166 b.mu.Lock()
161 // prefix := "zot: "167 // prefix := "zot: "
162 prefix := ""168 prefix := ""
169+ workingCancel := b.workingCancel
170+ b.workingCancel = nil
163 if b.nextReplyFromTelegram {171 if b.nextReplyFromTelegram {
164 prefix = ""172 prefix = ""
165 b.nextReplyFromTelegram = false173 b.nextReplyFromTelegram = false
166 }174 }
167 b.mu.Unlock()175 b.mu.Unlock()
176+ if workingCancel != nil {
177+ workingCancel()
178+ }
168 b.sendToPaired(text, prefix)179 b.sendToPaired(text, prefix)
169 }180 }
170 181
@@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) {
178 b.sendToPaired(text, "you: ")189 b.sendToPaired(text, "you: ")
179 }190 }
180 191
192+// indicateWorking keeps Telegram's typing indicator visible while a turn
193+// submitted from Telegram is waiting or running. Telegram clears chat actions
194+// after a few seconds, so refresh it until the reply is ready or the bridge
195+// stops.
196+func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) {
197+ workingCtx, cancel := context.WithCancel(ctx)
198+
199+ b.mu.Lock()
200+ previous := b.workingCancel
201+ b.workingCancel = cancel
202+ b.mu.Unlock()
203+ if previous != nil {
204+ previous()
205+ }
206+
207+ go func() {
208+ ticker := time.NewTicker(4 * time.Second)
209+ defer ticker.Stop()
210+ for {
211+ _ = b.Client.SendChatAction(workingCtx, chatID, "typing")
212+ select {
213+ case <-workingCtx.Done():
214+ return
215+ case <-ticker.C:
216+ }
217+ }
218+ }()
219+}
220+
221+// stopWorking clears the local typing-indicator refresh loop. Telegram removes
222+// the visible indicator itself when the next message arrives or it expires.
223+func (b *Bridge) stopWorking() {
224+ b.mu.Lock()
225+ cancel := b.workingCancel
226+ b.workingCancel = nil
227+ b.mu.Unlock()
228+ if cancel != nil {
229+ cancel()
230+ }
231+}
232+
181 // sendToPaired writes text (with an optional prefix, chunked to233 // sendToPaired writes text (with an optional prefix, chunked to
182 // Telegram's 4096-char cap) to the paired chat. No-op when the234 // Telegram's 4096-char cap) to the paired chat. No-op when the
183 // bridge is stopped or before the paired chat id is known.235 // bridge is stopped or before the paired chat id is known.
@@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
342 return394 return
343 case "/stop":395 case "/stop":
344 b.Host.CancelTurn()396 b.Host.CancelTurn()
397+ b.stopWorking()
345 _ = b.Client.SendMessage(ctx, msg.Chat.ID,398 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
346 "cancelled the current turn.", msg.MessageID)399 "cancelled the current turn.", msg.MessageID)
347 return400 return
348 }401 }
349 if isStopCommand(text) {402 if isStopCommand(text) {
350 b.Host.CancelTurn()403 b.Host.CancelTurn()
404+ b.stopWorking()
351 _ = b.Client.SendMessage(ctx, msg.Chat.ID,405 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
352 "cancelled the current turn.", msg.MessageID)406 "cancelled the current turn.", msg.MessageID)
353 return407 return
@@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
382 b.mu.Lock()436 b.mu.Lock()
383 b.nextReplyFromTelegram = true437 b.nextReplyFromTelegram = true
384 b.mu.Unlock()438 b.mu.Unlock()
439+ b.indicateWorking(ctx, msg.Chat.ID)
385 b.Host.SubmitOrQueue(prompt, images)440 b.Host.SubmitOrQueue(prompt, images)
386 }441 }
387 442

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