detached 625c238

feat(telegram): mirror tui prompts into telegram thread

When the telegram bridge is connected, messages you type in the
zot tui now also appear in the paired chat so the telegram
transcript stays a complete record of the session. Format:

  you: <what you typed>         <- from tui editor, grey bubble
  zot: <assistant reply>        <- reply to a tui prompt
  <your telegram dm>            <- your own blue bubble
  <assistant reply, bare>       <- reply to a telegram dm, no prefix

The "zot: " prefix is only attached when the turn was initiated
from the tui side. Telegram-initiated turns reply bare so the
thread reads as a normal back-and-forth with the bot; the "you: "
bubble from the tui side would otherwise pair awkwardly with a
DM-initiated bare reply.

Implementation is small:

  bridge.go
    - OnUserTyped(text): sends with "you: " prefix. Called from
      the interactive submit path when the bridge is active.
    - OnAssistantText(text): sends with "zot: " prefix by
      default, or bare when nextReplyFromTelegram is set.
    - nextReplyFromTelegram is flipped to true inside
      handleUpdate right before calling Host.SubmitOrQueue, and
      back to false when the reply is flushed. One-slot flag,
      safe against the actual serial turn drain the agent uses.
    - On Start(), if Config.AllowedUserID is already known from
      a previous session, prepopulate chatID so the bridge can
      send immediately without waiting for a handshake DM
      (private-chat id == user id on telegram).
    - sendToPaired consolidates the chunk-and-send plumbing so
      OnUserTyped, OnAssistantText, and future tap points share
      one path.

  interactive.go
    - The editor submit path now calls telegramBridge.OnUserTyped
      on a goroutine (network write off the event loop) before
      queuing or starting the turn. No-op when the bridge is
      stopped or no chat is paired.

No user-visible setup change: /telegram connect / disconnect /
status work the same; the two-way mirror is automatic once
connected.
commit
625c2382b790951f560d6da9f8b3275886524476
author
patriceckhart · · 2026-04-20 07:33 UTC
parent
098a797
signature
unsigned
refs
tag: v0.0.55
2 files · +58 -4
M internal/agent/modes/interactive.go +8 -0
@@ -1091,6 +1091,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
10911091 i.mu.Unlock()
10921092 return false
10931093 }
1094+ // Mirror the user's typed prompt into the paired Telegram
1095+ // chat (when the bridge is active) so the Telegram thread
1096+ // stays a complete record of the session, not just the half
1097+ // that originated on the phone. On a goroutine so the
1098+ // network write doesn't delay the local turn.
1099+ if i.telegramBridge != nil && i.telegramBridge.Active() {
1100+ go i.telegramBridge.OnUserTyped(text)
1101+ }
10941102 // If a turn is already in flight, queue this prompt instead of
10951103 // starting a second one. The drain loop at the end of startTurn
10961104 // will pick it up when the current turn finishes.
@@ -1091,6 +1091,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1091 i.mu.Unlock()1091 i.mu.Unlock()
1092 return false1092 return false
1093 }1093 }
1094+ // Mirror the user's typed prompt into the paired Telegram
1095+ // chat (when the bridge is active) so the Telegram thread
1096+ // stays a complete record of the session, not just the half
1097+ // that originated on the phone. On a goroutine so the
1098+ // network write doesn't delay the local turn.
1099+ if i.telegramBridge != nil && i.telegramBridge.Active() {
1100+ go i.telegramBridge.OnUserTyped(text)
1101+ }
1094 // If a turn is already in flight, queue this prompt instead of1102 // If a turn is already in flight, queue this prompt instead of
1095 // starting a second one. The drain loop at the end of startTurn1103 // starting a second one. The drain loop at the end of startTurn
1096 // will pick it up when the current turn finishes.1104 // will pick it up when the current turn finishes.
M internal/agent/modes/telegram/bridge.go +50 -4
@@ -45,6 +45,14 @@ type Bridge struct {
4545 me *User
4646 chatID int64 // populated after first DM from the paired user
4747 replyBuf strings.Builder
48+
49+ // nextReplyFromTelegram is set when the next assistant reply
50+ // should be sent bare (no "zot: " prefix) because the turn was
51+ // initiated by a Telegram DM. The flag clears as soon as the
52+ // reply is flushed. TUI-originated turns leave the flag false
53+ // so the reply is tagged "zot: " for clarity on the two-sided
54+ // transcript.
55+ nextReplyFromTelegram bool
4856 }
4957
5058 // State is the snapshot /telegram status reports.
@@ -106,6 +114,12 @@ func (b *Bridge) Start(parent context.Context) error {
106114 b.running = true
107115 b.cancel = cancel
108116 b.me = me
117+ // Telegram private-chat ids are the same as the user id, so if
118+ // we've already paired in a previous session we can send to the
119+ // user immediately without waiting for them to DM first.
120+ if b.Config.AllowedUserID != 0 && b.chatID == 0 {
121+ b.chatID = b.Config.AllowedUserID
122+ }
109123 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110124 b.Config.BotID = me.ID
111125 b.Config.BotUsername = me.Username
@@ -131,10 +145,37 @@ func (b *Bridge) Stop() {
131145
132146 // OnAssistantText should be called by the TUI with the assistant's
133147 // final visible text for each turn. The bridge forwards it to the
134-// paired chat in message-sized chunks. Safe to call from any
135-// goroutine; a no-op when the bridge is stopped or no chat is
136-// known yet.
148+// paired chat in message-sized chunks. Prefix depends on which
149+// side initiated the turn: TUI-originated turns get "zot: " so the
150+// two-sided transcript reads naturally ("you: ..." / "zot: ..."),
151+// while Telegram-originated turns send bare text (the user's own
152+// bubble is already on-screen, a "zot: " prefix would just add
153+// visual noise to a plain back-and-forth).
137154 func (b *Bridge) OnAssistantText(text string) {
155+ b.mu.Lock()
156+ prefix := "zot: "
157+ if b.nextReplyFromTelegram {
158+ prefix = ""
159+ b.nextReplyFromTelegram = false
160+ }
161+ b.mu.Unlock()
162+ b.sendToPaired(text, prefix)
163+}
164+
165+// OnUserTyped mirrors a message the user typed in the zot TUI into
166+// the paired Telegram chat, tagged "you:" so the Telegram thread
167+// stays a complete record of the conversation (both TUI-originated
168+// and Telegram-originated turns). Messages sent from Telegram
169+// itself aren't mirrored back (they already appear as the user's
170+// own bubble), only TUI-originated prompts flow through here.
171+func (b *Bridge) OnUserTyped(text string) {
172+ b.sendToPaired(text, "you: ")
173+}
174+
175+// sendToPaired writes text (with an optional prefix, chunked to
176+// Telegram's 4096-char cap) to the paired chat. No-op when the
177+// bridge is stopped or before the paired chat id is known.
178+func (b *Bridge) sendToPaired(text, prefix string) {
138179 b.mu.Lock()
139180 chatID := b.chatID
140181 running := b.running
@@ -146,7 +187,9 @@ func (b *Bridge) OnAssistantText(text string) {
146187 if text == "" {
147188 return
148189 }
149- // Telegram caps at 4096 chars; chunk to be safe.
190+ if prefix != "" {
191+ text = prefix + text
192+ }
150193 for _, chunk := range chunkMessage(text, 4000) {
151194 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152195 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
@@ -290,6 +333,9 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
290333 return
291334 }
292335
336+ b.mu.Lock()
337+ b.nextReplyFromTelegram = true
338+ b.mu.Unlock()
293339 b.Host.SubmitOrQueue(prompt, images)
294340 }
295341
@@ -45,6 +45,14 @@ type Bridge struct {
45 me *User45 me *User
46 chatID int64 // populated after first DM from the paired user46 chatID int64 // populated after first DM from the paired user
47 replyBuf strings.Builder47 replyBuf strings.Builder
48+
49+ // nextReplyFromTelegram is set when the next assistant reply
50+ // should be sent bare (no "zot: " prefix) because the turn was
51+ // initiated by a Telegram DM. The flag clears as soon as the
52+ // reply is flushed. TUI-originated turns leave the flag false
53+ // so the reply is tagged "zot: " for clarity on the two-sided
54+ // transcript.
55+ nextReplyFromTelegram bool
48 }56 }
49 57
50 // State is the snapshot /telegram status reports.58 // State is the snapshot /telegram status reports.
@@ -106,6 +114,12 @@ func (b *Bridge) Start(parent context.Context) error {
106 b.running = true114 b.running = true
107 b.cancel = cancel115 b.cancel = cancel
108 b.me = me116 b.me = me
117+ // Telegram private-chat ids are the same as the user id, so if
118+ // we've already paired in a previous session we can send to the
119+ // user immediately without waiting for them to DM first.
120+ if b.Config.AllowedUserID != 0 && b.chatID == 0 {
121+ b.chatID = b.Config.AllowedUserID
122+ }
109 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {123 if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username {
110 b.Config.BotID = me.ID124 b.Config.BotID = me.ID
111 b.Config.BotUsername = me.Username125 b.Config.BotUsername = me.Username
@@ -131,10 +145,37 @@ func (b *Bridge) Stop() {
131 145
132 // OnAssistantText should be called by the TUI with the assistant's146 // OnAssistantText should be called by the TUI with the assistant's
133 // final visible text for each turn. The bridge forwards it to the147 // final visible text for each turn. The bridge forwards it to the
134-// paired chat in message-sized chunks. Safe to call from any148+// paired chat in message-sized chunks. Prefix depends on which
135-// goroutine; a no-op when the bridge is stopped or no chat is149+// side initiated the turn: TUI-originated turns get "zot: " so the
136-// known yet.150+// two-sided transcript reads naturally ("you: ..." / "zot: ..."),
151+// while Telegram-originated turns send bare text (the user's own
152+// bubble is already on-screen, a "zot: " prefix would just add
153+// visual noise to a plain back-and-forth).
137 func (b *Bridge) OnAssistantText(text string) {154 func (b *Bridge) OnAssistantText(text string) {
155+ b.mu.Lock()
156+ prefix := "zot: "
157+ if b.nextReplyFromTelegram {
158+ prefix = ""
159+ b.nextReplyFromTelegram = false
160+ }
161+ b.mu.Unlock()
162+ b.sendToPaired(text, prefix)
163+}
164+
165+// OnUserTyped mirrors a message the user typed in the zot TUI into
166+// the paired Telegram chat, tagged "you:" so the Telegram thread
167+// stays a complete record of the conversation (both TUI-originated
168+// and Telegram-originated turns). Messages sent from Telegram
169+// itself aren't mirrored back (they already appear as the user's
170+// own bubble), only TUI-originated prompts flow through here.
171+func (b *Bridge) OnUserTyped(text string) {
172+ b.sendToPaired(text, "you: ")
173+}
174+
175+// sendToPaired writes text (with an optional prefix, chunked to
176+// Telegram's 4096-char cap) to the paired chat. No-op when the
177+// bridge is stopped or before the paired chat id is known.
178+func (b *Bridge) sendToPaired(text, prefix string) {
138 b.mu.Lock()179 b.mu.Lock()
139 chatID := b.chatID180 chatID := b.chatID
140 running := b.running181 running := b.running
@@ -146,7 +187,9 @@ func (b *Bridge) OnAssistantText(text string) {
146 if text == "" {187 if text == "" {
147 return188 return
148 }189 }
149- // Telegram caps at 4096 chars; chunk to be safe.190+ if prefix != "" {
191+ text = prefix + text
192+ }
150 for _, chunk := range chunkMessage(text, 4000) {193 for _, chunk := range chunkMessage(text, 4000) {
151 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {194 if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil {
152 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)195 fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err)
@@ -290,6 +333,9 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
290 return333 return
291 }334 }
292 335
336+ b.mu.Lock()
337+ b.nextReplyFromTelegram = true
338+ b.mu.Unlock()
293 b.Host.SubmitOrQueue(prompt, images)339 b.Host.SubmitOrQueue(prompt, images)
294 }340 }
295 341

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