detached e7dbf3d

fix(tui): cell-aware width math for dialog header rules + add /btw

frame header pad length used len(label) which counts bytes, not
on-screen cells. titles containing em-dashes, box-drawing or any
multibyte rune left a few extra bytes of "imaginary length" so
the trailing ── run came up short of the right edge -- visible on
the /btw header which starts with "btw — side chat ...". swap to
runewidth.StringWidth for both frameHeader and frameHeaderColor.

also lands the /btw side-chat overlay this commit pre-staged:
opens via /btw [optional question], snapshots system + transcript
+ provider client at open time, fires one-off model calls against
that frozen context plus the side-chat history, renders replies
through the markdown pipeline. esc cancels in-flight calls or
closes when idle. nothing is appended to the main transcript or
the session jsonl. spinner reuses the main funny-line rotation.
commit
e7dbf3d86123fe4767b12b63f3517e87e61f24f3
author
patriceckhart · · 2026-04-19 11:47 UTC
parent
4dde650
signature
unsigned
refs
tag: v0.0.22
6 files · +427 -5
M README.md +15 -1
@@ -174,6 +174,7 @@ type `/` in the tui to open the autocomplete popup. available commands:
174174 | `/model` | pick a model from a list (or `/model <id>` to set directly) |
175175 | `/sessions` | resume a previous session for this directory |
176176 | `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |
177+| `/btw` | side-chat with full context that doesn't add to the main thread |
177178 | `/compact` | summarize the transcript into one message to free up context |
178179 | `/lock` | confine tools to the current directory |
179180 | `/unlock` | allow tools to touch paths outside again |
@@ -190,6 +191,19 @@ opens a turn picker for the current session — one row per user prompt, each sh
190191
191192 jumping is non-destructive — the transcript is untouched, the viewport just scrolls so the chosen turn is at the top. a muted line at the top of the chat reads `↑ viewing turn N of M · pgdn to catch up`; scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away.
192193
194+### `/btw`
195+
196+opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.
197+
198+each question fires a one-off model call against `system + main transcript + side-chat history so far`. responses render in the overlay and stay there. when you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges — keeping the running context window lean.
199+
200+```
201+/btw # open the overlay, type questions interactively
202+/btw does PUT replace the whole resource?
203+```
204+
205+inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. side-chat exchanges never touch the transcript and aren't persisted to the session file.
206+
193207 ### `/compact`
194208
195209 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.
@@ -233,7 +247,7 @@ frames containing images are full-repainted (no differential diff) to prevent st
233247
234248 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
235249
236-slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
250+slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
237251
238252 ## keys (interactive mode)
239253
@@ -174,6 +174,7 @@ type `/` in the tui to open the autocomplete popup. available commands:
174 | `/model` | pick a model from a list (or `/model <id>` to set directly) |174 | `/model` | pick a model from a list (or `/model <id>` to set directly) |
175 | `/sessions` | resume a previous session for this directory |175 | `/sessions` | resume a previous session for this directory |
176 | `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |176 | `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |
177+| `/btw` | side-chat with full context that doesn't add to the main thread |
177 | `/compact` | summarize the transcript into one message to free up context |178 | `/compact` | summarize the transcript into one message to free up context |
178 | `/lock` | confine tools to the current directory |179 | `/lock` | confine tools to the current directory |
179 | `/unlock` | allow tools to touch paths outside again |180 | `/unlock` | allow tools to touch paths outside again |
@@ -190,6 +191,19 @@ opens a turn picker for the current session — one row per user prompt, each sh
190 191
191 jumping is non-destructive — the transcript is untouched, the viewport just scrolls so the chosen turn is at the top. a muted line at the top of the chat reads `↑ viewing turn N of M · pgdn to catch up`; scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away.192 jumping is non-destructive — the transcript is untouched, the viewport just scrolls so the chosen turn is at the top. a muted line at the top of the chat reads `↑ viewing turn N of M · pgdn to catch up`; scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away.
192 193
194+### `/btw`
195+
196+opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.
197+
198+each question fires a one-off model call against `system + main transcript + side-chat history so far`. responses render in the overlay and stay there. when you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges — keeping the running context window lean.
199+
200+```
201+/btw # open the overlay, type questions interactively
202+/btw does PUT replace the whole resource?
203+```
204+
205+inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. side-chat exchanges never touch the transcript and aren't persisted to the session file.
206+
193 ### `/compact`207 ### `/compact`
194 208
195 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.209 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.
@@ -233,7 +247,7 @@ frames containing images are full-repainted (no differential diff) to prevent st
233 247
234 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.248 you can keep typing while the agent is working. pressing enter during a turn queues the message instead of interrupting: it shows up above the status bar as `▸ sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. queue as many as you want; they run in order. esc / ctrl+c cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
235 249
236-slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.250+slash commands also work while the agent is busy. read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/lock`, `/unlock`, `/exit`) take effect immediately. destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`) cancel the active turn first and then run.
237 251
238 ## keys (interactive mode)252 ## keys (interactive mode)
239 253
A internal/agent/modes/btw_dialog.go +367 -0
@@ -0,0 +1,367 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/core"
11+ "github.com/patriceckhart/zot/internal/provider"
12+ "github.com/patriceckhart/zot/internal/tui"
13+)
14+
15+// btwTurn is one user/assistant pair within a side chat. Kept
16+// separate from the main transcript so closing the dialog leaves
17+// the main session untouched.
18+type btwTurn struct {
19+ User string
20+ Assistant string
21+ Err string
22+}
23+
24+// btwDialog is the side-chat overlay opened by /btw. It shows the
25+// user's question, runs a one-off model call against the live
26+// snapshot of the main session plus any prior side-chat turns,
27+// renders the assistant reply through the markdown pipeline, and
28+// keeps the main transcript completely untouched.
29+//
30+// Cancellation: esc cancels an in-flight call when one is running,
31+// otherwise closes the dialog.
32+type btwDialog struct {
33+ mu sync.Mutex
34+ active bool
35+ turns []btwTurn
36+ editor *tui.Editor
37+ loading bool
38+ cancel context.CancelFunc
39+
40+ // spin drives the same braille animation + rotating funny-line
41+ // shown in the main status bar. Owned by the dialog so its clock
42+ // is independent of the main spinner (so re-opening the dialog
43+ // always starts fresh and the message doesn't carry over from a
44+ // completed main turn).
45+ spin *spinner
46+
47+ // Frozen at Open() time so the side-chat keeps a stable view of
48+ // the main thread even if a turn happens to land on the main
49+ // agent while the dialog is open (rare but possible).
50+ frozenSystem string
51+ frozenMsgs []provider.Message
52+
53+ // Provider details captured at open time; used by send() to
54+ // build the request without going back through the agent.
55+ client provider.Client
56+ model string
57+
58+ // Theme cached so render() doesn't need it threaded through.
59+ theme tui.Theme
60+}
61+
62+func newBtwDialog() *btwDialog {
63+ return &btwDialog{}
64+}
65+
66+// Active reports whether the dialog is visible and consuming keys.
67+func (d *btwDialog) Active() bool {
68+ if d == nil {
69+ return false
70+ }
71+ d.mu.Lock()
72+ defer d.mu.Unlock()
73+ return d.active
74+}
75+
76+// Open enters the side chat. agent supplies the live transcript and
77+// system prompt, plus the underlying provider client to use for the
78+// one-off completion. seed is an optional first question that gets
79+// auto-submitted (so /btw <text> behaves like the reference).
80+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {
81+ d.mu.Lock()
82+ d.active = true
83+ d.theme = th
84+ d.turns = nil
85+ d.loading = false
86+ d.cancel = nil
87+ d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ "))
88+ d.frozenSystem = system
89+ d.frozenMsgs = agent.Messages()
90+ d.client = agent.Client
91+ d.model = model
92+ d.mu.Unlock()
93+
94+ if seed = strings.TrimSpace(seed); seed != "" {
95+ d.editor.SetValue(seed)
96+ d.submit()
97+ }
98+}
99+
100+// Close hides the dialog. Cancels any in-flight request.
101+func (d *btwDialog) Close() {
102+ d.mu.Lock()
103+ d.active = false
104+ d.turns = nil
105+ d.editor = nil
106+ d.loading = false
107+ cancel := d.cancel
108+ d.cancel = nil
109+ d.frozenMsgs = nil
110+ d.client = nil
111+ d.mu.Unlock()
112+ if cancel != nil {
113+ cancel()
114+ }
115+}
116+
117+// HandleKey routes a keypress to the dialog. Returns true if the
118+// dialog wants the event consumed (always true while active, except
119+// for the special closing case where the caller may want to signal
120+// the parent).
121+func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
122+ if !d.Active() {
123+ return false
124+ }
125+ switch k.Kind {
126+ case tui.KeyEsc:
127+ // First esc: cancel an in-flight call. Subsequent esc closes.
128+ d.mu.Lock()
129+ busy := d.loading
130+ cancel := d.cancel
131+ d.mu.Unlock()
132+ if busy && cancel != nil {
133+ cancel()
134+ return false
135+ }
136+ d.Close()
137+ invalidate()
138+ return true
139+ }
140+
141+ d.mu.Lock()
142+ editor := d.editor
143+ loading := d.loading
144+ d.mu.Unlock()
145+ if editor == nil {
146+ return false
147+ }
148+ // Don't accept new submissions while a call is in flight; arrow
149+ // keys / scrolling still flow through to the editor for caret
150+ // movement and history.
151+ submitted := editor.HandleKey(k)
152+ invalidate()
153+ if submitted && !loading {
154+ d.submit()
155+ }
156+ return false
157+}
158+
159+// submit fires the LLM call for the current input and, on success,
160+// appends a new turn to d.turns.
161+func (d *btwDialog) submit() {
162+ d.mu.Lock()
163+ if d.editor == nil || d.loading {
164+ d.mu.Unlock()
165+ return
166+ }
167+ question := strings.TrimSpace(d.editor.Value())
168+ if question == "" {
169+ d.mu.Unlock()
170+ return
171+ }
172+ d.editor.Clear()
173+ d.loading = true
174+ if d.spin == nil {
175+ d.spin = newSpinner()
176+ }
177+ d.spin.Start()
178+ d.turns = append(d.turns, btwTurn{User: question})
179+ turnIdx := len(d.turns) - 1
180+
181+ // Build the request: system + frozen main transcript + every
182+ // prior side-chat turn (user + assistant) + this question.
183+ msgs := append([]provider.Message(nil), d.frozenMsgs...)
184+ for i, t := range d.turns {
185+ msgs = append(msgs, provider.Message{
186+ Role: provider.RoleUser,
187+ Content: []provider.Content{
188+ provider.TextBlock{Text: t.User},
189+ },
190+ Time: time.Now(),
191+ })
192+ // Only completed turns contribute an assistant reply; the
193+ // in-flight one (turnIdx) hasn't got one yet.
194+ if i < turnIdx && t.Assistant != "" {
195+ msgs = append(msgs, provider.Message{
196+ Role: provider.RoleAssistant,
197+ Content: []provider.Content{
198+ provider.TextBlock{Text: t.Assistant},
199+ },
200+ Time: time.Now(),
201+ })
202+ }
203+ }
204+
205+ ctx, cancel := context.WithCancel(context.Background())
206+ d.cancel = cancel
207+ client := d.client
208+ model := d.model
209+ system := d.frozenSystem
210+ d.mu.Unlock()
211+
212+ go func() {
213+ req := provider.Request{
214+ Model: model,
215+ System: system,
216+ Messages: msgs,
217+ // No tools: side chat is conversational, not agentic.
218+ }
219+ stream, err := client.Stream(ctx, req)
220+ if err != nil {
221+ d.completeTurn(turnIdx, "", err.Error())
222+ return
223+ }
224+
225+ var reply strings.Builder
226+ var finalErr error
227+ for ev := range stream {
228+ switch e := ev.(type) {
229+ case provider.EventTextDelta:
230+ reply.WriteString(e.Delta)
231+ case provider.EventDone:
232+ if e.Err != nil {
233+ finalErr = e.Err
234+ }
235+ }
236+ }
237+
238+ errMsg := ""
239+ if finalErr != nil && ctx.Err() == nil {
240+ errMsg = finalErr.Error()
241+ }
242+ d.completeTurn(turnIdx, reply.String(), errMsg)
243+ }()
244+}
245+
246+// completeTurn fills in the assistant text or error for the turn at
247+// idx and clears the loading state.
248+func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
249+ d.mu.Lock()
250+ defer d.mu.Unlock()
251+ if idx < 0 || idx >= len(d.turns) {
252+ return
253+ }
254+ d.turns[idx].Assistant = assistant
255+ d.turns[idx].Err = errMsg
256+ d.loading = false
257+ d.cancel = nil
258+}
259+
260+// Render returns the side-chat panel lines. Called every frame
261+// while active.
262+func (d *btwDialog) Render(th tui.Theme, width int) []string {
263+ d.mu.Lock()
264+ defer d.mu.Unlock()
265+ if !d.active {
266+ return nil
267+ }
268+
269+ var out []string
270+ out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
271+
272+ if len(d.turns) == 0 && !d.loading {
273+ out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
274+ }
275+
276+ for _, t := range d.turns {
277+ out = append(out, "")
278+ out = append(out, " "+th.FG256(th.User, "▌ you"))
279+ for _, line := range strings.Split(t.User, "\n") {
280+ out = append(out, " "+th.FG256(th.Muted, line))
281+ }
282+ if t.Assistant != "" {
283+ out = append(out, "")
284+ out = append(out, " "+th.FG256(th.Assistant, "▌ zot"))
285+ md := tui.RenderMarkdown(t.Assistant, th, width-4)
286+ for _, line := range strings.Split(md, "\n") {
287+ out = append(out, " "+line)
288+ }
289+ }
290+ if t.Err != "" {
291+ out = append(out, " "+th.FG256(th.Error, "✖ "+t.Err))
292+ }
293+ }
294+
295+ if d.loading && d.spin != nil {
296+ out = append(out, "")
297+ // Match the main chat busy prefix shape: spinner glyph,
298+ // rotating funny-line, elapsed seconds, then a muted hint
299+ // that esc cancels.
300+ prefix := fmt.Sprintf("%s %s · %s",
301+ th.FG256(th.Spinner, d.spin.Frame()),
302+ th.FG256(th.Spinner, d.spin.Message()),
303+ th.FG256(th.Muted, d.spin.Elapsed().String()),
304+ )
305+ out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))
306+ }
307+
308+ out = append(out, "")
309+ if d.editor != nil {
310+ edLines, _, _ := d.editor.Render(width)
311+ for _, l := range edLines {
312+ // Indent the editor body so it lines up with the side-chat
313+ // content column. Editor's prompt already includes a left
314+ // marker, so just two cells of pad.
315+ out = append(out, " "+l)
316+ }
317+ }
318+ out = append(out, frameRuleColor(th, width, th.Accent))
319+ return out
320+}
321+
322+// CursorRow / CursorCol report where the dialog wants the terminal
323+// cursor placed within its render output, so the parent can position
324+// the actual terminal cursor on the editor input. Returns (-1, -1)
325+// when the dialog isn't active or has no editor.
326+func (d *btwDialog) CursorPos(width int) (row, col int) {
327+ d.mu.Lock()
328+ defer d.mu.Unlock()
329+ if !d.active || d.editor == nil {
330+ return -1, -1
331+ }
332+ // Reproduce render's structure to find where the editor sits:
333+ // header (1) + (turns_section_lines) + (loading?) + blank (1) + editor
334+ editorOffset := 1 // header
335+ if len(d.turns) == 0 && !d.loading {
336+ editorOffset++ // muted "ask anything..." line
337+ }
338+ for _, t := range d.turns {
339+ editorOffset++ // blank
340+ editorOffset++ // "you" header
341+ editorOffset += len(strings.Split(t.User, "\n"))
342+ if t.Assistant != "" {
343+ editorOffset++ // blank
344+ editorOffset++ // "zot" header
345+ editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
346+ }
347+ if t.Err != "" {
348+ editorOffset++
349+ }
350+ }
351+ if d.loading {
352+ editorOffset++ // blank
353+ editorOffset++ // spinner line
354+ }
355+ editorOffset++ // pre-editor blank
356+ _, eRow, eCol := d.editor.Render(width - 2)
357+ return editorOffset + eRow, eCol + 2 /* matches render indent */
358+}
359+
360+// errMessage is a tiny helper for the future when we want to surface
361+// retryable failures in a styled way.
362+func errMessage(err error) string {
363+ if err == nil {
364+ return ""
365+ }
366+ return fmt.Sprintf("error: %s", err.Error())
367+}
@@ -0,0 +1,367 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/internal/core"
11+ "github.com/patriceckhart/zot/internal/provider"
12+ "github.com/patriceckhart/zot/internal/tui"
13+)
14+
15+// btwTurn is one user/assistant pair within a side chat. Kept
16+// separate from the main transcript so closing the dialog leaves
17+// the main session untouched.
18+type btwTurn struct {
19+ User string
20+ Assistant string
21+ Err string
22+}
23+
24+// btwDialog is the side-chat overlay opened by /btw. It shows the
25+// user's question, runs a one-off model call against the live
26+// snapshot of the main session plus any prior side-chat turns,
27+// renders the assistant reply through the markdown pipeline, and
28+// keeps the main transcript completely untouched.
29+//
30+// Cancellation: esc cancels an in-flight call when one is running,
31+// otherwise closes the dialog.
32+type btwDialog struct {
33+ mu sync.Mutex
34+ active bool
35+ turns []btwTurn
36+ editor *tui.Editor
37+ loading bool
38+ cancel context.CancelFunc
39+
40+ // spin drives the same braille animation + rotating funny-line
41+ // shown in the main status bar. Owned by the dialog so its clock
42+ // is independent of the main spinner (so re-opening the dialog
43+ // always starts fresh and the message doesn't carry over from a
44+ // completed main turn).
45+ spin *spinner
46+
47+ // Frozen at Open() time so the side-chat keeps a stable view of
48+ // the main thread even if a turn happens to land on the main
49+ // agent while the dialog is open (rare but possible).
50+ frozenSystem string
51+ frozenMsgs []provider.Message
52+
53+ // Provider details captured at open time; used by send() to
54+ // build the request without going back through the agent.
55+ client provider.Client
56+ model string
57+
58+ // Theme cached so render() doesn't need it threaded through.
59+ theme tui.Theme
60+}
61+
62+func newBtwDialog() *btwDialog {
63+ return &btwDialog{}
64+}
65+
66+// Active reports whether the dialog is visible and consuming keys.
67+func (d *btwDialog) Active() bool {
68+ if d == nil {
69+ return false
70+ }
71+ d.mu.Lock()
72+ defer d.mu.Unlock()
73+ return d.active
74+}
75+
76+// Open enters the side chat. agent supplies the live transcript and
77+// system prompt, plus the underlying provider client to use for the
78+// one-off completion. seed is an optional first question that gets
79+// auto-submitted (so /btw <text> behaves like the reference).
80+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {
81+ d.mu.Lock()
82+ d.active = true
83+ d.theme = th
84+ d.turns = nil
85+ d.loading = false
86+ d.cancel = nil
87+ d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ "))
88+ d.frozenSystem = system
89+ d.frozenMsgs = agent.Messages()
90+ d.client = agent.Client
91+ d.model = model
92+ d.mu.Unlock()
93+
94+ if seed = strings.TrimSpace(seed); seed != "" {
95+ d.editor.SetValue(seed)
96+ d.submit()
97+ }
98+}
99+
100+// Close hides the dialog. Cancels any in-flight request.
101+func (d *btwDialog) Close() {
102+ d.mu.Lock()
103+ d.active = false
104+ d.turns = nil
105+ d.editor = nil
106+ d.loading = false
107+ cancel := d.cancel
108+ d.cancel = nil
109+ d.frozenMsgs = nil
110+ d.client = nil
111+ d.mu.Unlock()
112+ if cancel != nil {
113+ cancel()
114+ }
115+}
116+
117+// HandleKey routes a keypress to the dialog. Returns true if the
118+// dialog wants the event consumed (always true while active, except
119+// for the special closing case where the caller may want to signal
120+// the parent).
121+func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
122+ if !d.Active() {
123+ return false
124+ }
125+ switch k.Kind {
126+ case tui.KeyEsc:
127+ // First esc: cancel an in-flight call. Subsequent esc closes.
128+ d.mu.Lock()
129+ busy := d.loading
130+ cancel := d.cancel
131+ d.mu.Unlock()
132+ if busy && cancel != nil {
133+ cancel()
134+ return false
135+ }
136+ d.Close()
137+ invalidate()
138+ return true
139+ }
140+
141+ d.mu.Lock()
142+ editor := d.editor
143+ loading := d.loading
144+ d.mu.Unlock()
145+ if editor == nil {
146+ return false
147+ }
148+ // Don't accept new submissions while a call is in flight; arrow
149+ // keys / scrolling still flow through to the editor for caret
150+ // movement and history.
151+ submitted := editor.HandleKey(k)
152+ invalidate()
153+ if submitted && !loading {
154+ d.submit()
155+ }
156+ return false
157+}
158+
159+// submit fires the LLM call for the current input and, on success,
160+// appends a new turn to d.turns.
161+func (d *btwDialog) submit() {
162+ d.mu.Lock()
163+ if d.editor == nil || d.loading {
164+ d.mu.Unlock()
165+ return
166+ }
167+ question := strings.TrimSpace(d.editor.Value())
168+ if question == "" {
169+ d.mu.Unlock()
170+ return
171+ }
172+ d.editor.Clear()
173+ d.loading = true
174+ if d.spin == nil {
175+ d.spin = newSpinner()
176+ }
177+ d.spin.Start()
178+ d.turns = append(d.turns, btwTurn{User: question})
179+ turnIdx := len(d.turns) - 1
180+
181+ // Build the request: system + frozen main transcript + every
182+ // prior side-chat turn (user + assistant) + this question.
183+ msgs := append([]provider.Message(nil), d.frozenMsgs...)
184+ for i, t := range d.turns {
185+ msgs = append(msgs, provider.Message{
186+ Role: provider.RoleUser,
187+ Content: []provider.Content{
188+ provider.TextBlock{Text: t.User},
189+ },
190+ Time: time.Now(),
191+ })
192+ // Only completed turns contribute an assistant reply; the
193+ // in-flight one (turnIdx) hasn't got one yet.
194+ if i < turnIdx && t.Assistant != "" {
195+ msgs = append(msgs, provider.Message{
196+ Role: provider.RoleAssistant,
197+ Content: []provider.Content{
198+ provider.TextBlock{Text: t.Assistant},
199+ },
200+ Time: time.Now(),
201+ })
202+ }
203+ }
204+
205+ ctx, cancel := context.WithCancel(context.Background())
206+ d.cancel = cancel
207+ client := d.client
208+ model := d.model
209+ system := d.frozenSystem
210+ d.mu.Unlock()
211+
212+ go func() {
213+ req := provider.Request{
214+ Model: model,
215+ System: system,
216+ Messages: msgs,
217+ // No tools: side chat is conversational, not agentic.
218+ }
219+ stream, err := client.Stream(ctx, req)
220+ if err != nil {
221+ d.completeTurn(turnIdx, "", err.Error())
222+ return
223+ }
224+
225+ var reply strings.Builder
226+ var finalErr error
227+ for ev := range stream {
228+ switch e := ev.(type) {
229+ case provider.EventTextDelta:
230+ reply.WriteString(e.Delta)
231+ case provider.EventDone:
232+ if e.Err != nil {
233+ finalErr = e.Err
234+ }
235+ }
236+ }
237+
238+ errMsg := ""
239+ if finalErr != nil && ctx.Err() == nil {
240+ errMsg = finalErr.Error()
241+ }
242+ d.completeTurn(turnIdx, reply.String(), errMsg)
243+ }()
244+}
245+
246+// completeTurn fills in the assistant text or error for the turn at
247+// idx and clears the loading state.
248+func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
249+ d.mu.Lock()
250+ defer d.mu.Unlock()
251+ if idx < 0 || idx >= len(d.turns) {
252+ return
253+ }
254+ d.turns[idx].Assistant = assistant
255+ d.turns[idx].Err = errMsg
256+ d.loading = false
257+ d.cancel = nil
258+}
259+
260+// Render returns the side-chat panel lines. Called every frame
261+// while active.
262+func (d *btwDialog) Render(th tui.Theme, width int) []string {
263+ d.mu.Lock()
264+ defer d.mu.Unlock()
265+ if !d.active {
266+ return nil
267+ }
268+
269+ var out []string
270+ out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
271+
272+ if len(d.turns) == 0 && !d.loading {
273+ out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
274+ }
275+
276+ for _, t := range d.turns {
277+ out = append(out, "")
278+ out = append(out, " "+th.FG256(th.User, "▌ you"))
279+ for _, line := range strings.Split(t.User, "\n") {
280+ out = append(out, " "+th.FG256(th.Muted, line))
281+ }
282+ if t.Assistant != "" {
283+ out = append(out, "")
284+ out = append(out, " "+th.FG256(th.Assistant, "▌ zot"))
285+ md := tui.RenderMarkdown(t.Assistant, th, width-4)
286+ for _, line := range strings.Split(md, "\n") {
287+ out = append(out, " "+line)
288+ }
289+ }
290+ if t.Err != "" {
291+ out = append(out, " "+th.FG256(th.Error, "✖ "+t.Err))
292+ }
293+ }
294+
295+ if d.loading && d.spin != nil {
296+ out = append(out, "")
297+ // Match the main chat busy prefix shape: spinner glyph,
298+ // rotating funny-line, elapsed seconds, then a muted hint
299+ // that esc cancels.
300+ prefix := fmt.Sprintf("%s %s · %s",
301+ th.FG256(th.Spinner, d.spin.Frame()),
302+ th.FG256(th.Spinner, d.spin.Message()),
303+ th.FG256(th.Muted, d.spin.Elapsed().String()),
304+ )
305+ out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))
306+ }
307+
308+ out = append(out, "")
309+ if d.editor != nil {
310+ edLines, _, _ := d.editor.Render(width)
311+ for _, l := range edLines {
312+ // Indent the editor body so it lines up with the side-chat
313+ // content column. Editor's prompt already includes a left
314+ // marker, so just two cells of pad.
315+ out = append(out, " "+l)
316+ }
317+ }
318+ out = append(out, frameRuleColor(th, width, th.Accent))
319+ return out
320+}
321+
322+// CursorRow / CursorCol report where the dialog wants the terminal
323+// cursor placed within its render output, so the parent can position
324+// the actual terminal cursor on the editor input. Returns (-1, -1)
325+// when the dialog isn't active or has no editor.
326+func (d *btwDialog) CursorPos(width int) (row, col int) {
327+ d.mu.Lock()
328+ defer d.mu.Unlock()
329+ if !d.active || d.editor == nil {
330+ return -1, -1
331+ }
332+ // Reproduce render's structure to find where the editor sits:
333+ // header (1) + (turns_section_lines) + (loading?) + blank (1) + editor
334+ editorOffset := 1 // header
335+ if len(d.turns) == 0 && !d.loading {
336+ editorOffset++ // muted "ask anything..." line
337+ }
338+ for _, t := range d.turns {
339+ editorOffset++ // blank
340+ editorOffset++ // "you" header
341+ editorOffset += len(strings.Split(t.User, "\n"))
342+ if t.Assistant != "" {
343+ editorOffset++ // blank
344+ editorOffset++ // "zot" header
345+ editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
346+ }
347+ if t.Err != "" {
348+ editorOffset++
349+ }
350+ }
351+ if d.loading {
352+ editorOffset++ // blank
353+ editorOffset++ // spinner line
354+ }
355+ editorOffset++ // pre-editor blank
356+ _, eRow, eCol := d.editor.Render(width - 2)
357+ return editorOffset + eRow, eCol + 2 /* matches render indent */
358+}
359+
360+// errMessage is a tiny helper for the future when we want to surface
361+// retryable failures in a styled way.
362+func errMessage(err error) string {
363+ if err == nil {
364+ return ""
365+ }
366+ return fmt.Sprintf("error: %s", err.Error())
367+}
M internal/agent/modes/dialog_frame.go +4 -2
@@ -3,6 +3,8 @@ package modes
33 import (
44 "strings"
55
6+ "github.com/mattn/go-runewidth"
7+
68 "github.com/patriceckhart/zot/internal/tui"
79 )
810
@@ -16,7 +18,7 @@ func frameHeader(th tui.Theme, title string, width int) string {
1618 if width <= 0 {
1719 return th.FG256(th.Muted, label)
1820 }
19- padLen := width - len(label)
21+ padLen := width - runewidth.StringWidth(label)
2022 if padLen < 0 {
2123 padLen = 0
2224 }
@@ -39,7 +41,7 @@ func frameHeaderColor(th tui.Theme, title string, width, color int) string {
3941 if width <= 0 {
4042 return th.FG256(color, label)
4143 }
42- padLen := width - len(label)
44+ padLen := width - runewidth.StringWidth(label)
4345 if padLen < 0 {
4446 padLen = 0
4547 }
@@ -3,6 +3,8 @@ package modes
3 import (3 import (
4 "strings"4 "strings"
5 5
6+ "github.com/mattn/go-runewidth"
7+
6 "github.com/patriceckhart/zot/internal/tui"8 "github.com/patriceckhart/zot/internal/tui"
7 )9 )
8 10
@@ -16,7 +18,7 @@ func frameHeader(th tui.Theme, title string, width int) string {
16 if width <= 0 {18 if width <= 0 {
17 return th.FG256(th.Muted, label)19 return th.FG256(th.Muted, label)
18 }20 }
19- padLen := width - len(label)21+ padLen := width - runewidth.StringWidth(label)
20 if padLen < 0 {22 if padLen < 0 {
21 padLen = 023 padLen = 0
22 }24 }
@@ -39,7 +41,7 @@ func frameHeaderColor(th tui.Theme, title string, width, color int) string {
39 if width <= 0 {41 if width <= 0 {
40 return th.FG256(color, label)42 return th.FG256(color, label)
41 }43 }
42- padLen := width - len(label)44+ padLen := width - runewidth.StringWidth(label)
43 if padLen < 0 {45 if padLen < 0 {
44 padLen = 046 padLen = 0
45 }47 }
M internal/agent/modes/interactive.go +32 -1
@@ -126,6 +126,7 @@ type Interactive struct {
126126 modelDialog *modelDialog
127127 sessionDialog *sessionDialog
128128 jumpDialog *jumpDialog
129+ btwDialog *btwDialog
129130 suggest *slashSuggester
130131 spin *spinner
131132
@@ -168,6 +169,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
168169 modelDialog: newModelDialog(),
169170 sessionDialog: newSessionDialog(),
170171 jumpDialog: newJumpDialog(),
172+ btwDialog: newBtwDialog(),
171173 suggest: newSlashSuggester(),
172174 spin: newSpinner(),
173175 }
@@ -350,7 +352,7 @@ func (i *Interactive) Run(ctx context.Context) error {
350352 // and the AfterFunc-driven invalidate got dropped on a
351353 // full channel.
352354 drainPending()
353- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() {
355+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() {
354356 requestRedraw() // keep the spinner / dialog animation moving
355357 }
356358 }
@@ -495,6 +497,8 @@ func (i *Interactive) redraw() {
495497 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
496498 case i.jumpDialog.Active():
497499 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
500+ case i.btwDialog.Active():
501+ dialog = i.btwDialog.Render(i.cfg.Theme, cols)
498502 }
499503
500504 // Slash-command autocomplete: popup above the status line, only
@@ -722,6 +726,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
722726 }
723727 return false
724728 }
729+ if i.btwDialog.Active() {
730+ if k.Kind == tui.KeyCtrlC {
731+ i.btwDialog.Close()
732+ i.invalidate()
733+ return false
734+ }
735+ i.btwDialog.HandleKey(k, i.invalidate)
736+ return false
737+ }
725738
726739 // Global keys.
727740 switch k.Kind {
@@ -959,6 +972,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
959972 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
960973 case "/jump":
961974 i.openJumpDialog(parts[1:])
975+ case "/btw":
976+ i.openBtwDialog(parts[1:])
962977 case "/compact":
963978 i.runCompact(ctx, false)
964979 case "/lock":
@@ -1110,6 +1125,22 @@ func (i *Interactive) cancelAndWaitForIdle() {
11101125 }
11111126 }
11121127
1128+// openBtwDialog opens the side-chat overlay with a frozen snapshot
1129+// of the current main session. The optional argument is auto-
1130+// submitted as the first question, so '/btw does X work?' fires the
1131+// model call immediately instead of just opening an empty dialog.
1132+func (i *Interactive) openBtwDialog(args []string) {
1133+ if i.agent == nil {
1134+ i.mu.Lock()
1135+ i.statusErr = "not logged in. type /login first."
1136+ i.mu.Unlock()
1137+ return
1138+ }
1139+ seed := strings.TrimSpace(strings.Join(args, " "))
1140+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1141+ i.invalidate()
1142+}
1143+
11131144 // openJumpDialog builds a /jump picker from the current transcript.
11141145 // If the user typed "/jump foo" with a filter and it matches exactly
11151146 // one turn, jump there directly without showing the dialog.
@@ -126,6 +126,7 @@ type Interactive struct {
126 modelDialog *modelDialog126 modelDialog *modelDialog
127 sessionDialog *sessionDialog127 sessionDialog *sessionDialog
128 jumpDialog *jumpDialog128 jumpDialog *jumpDialog
129+ btwDialog *btwDialog
129 suggest *slashSuggester130 suggest *slashSuggester
130 spin *spinner131 spin *spinner
131 132
@@ -168,6 +169,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
168 modelDialog: newModelDialog(),169 modelDialog: newModelDialog(),
169 sessionDialog: newSessionDialog(),170 sessionDialog: newSessionDialog(),
170 jumpDialog: newJumpDialog(),171 jumpDialog: newJumpDialog(),
172+ btwDialog: newBtwDialog(),
171 suggest: newSlashSuggester(),173 suggest: newSlashSuggester(),
172 spin: newSpinner(),174 spin: newSpinner(),
173 }175 }
@@ -350,7 +352,7 @@ func (i *Interactive) Run(ctx context.Context) error {
350 // and the AfterFunc-driven invalidate got dropped on a352 // and the AfterFunc-driven invalidate got dropped on a
351 // full channel.353 // full channel.
352 drainPending()354 drainPending()
353- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() {355+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() {
354 requestRedraw() // keep the spinner / dialog animation moving356 requestRedraw() // keep the spinner / dialog animation moving
355 }357 }
356 }358 }
@@ -495,6 +497,8 @@ func (i *Interactive) redraw() {
495 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)497 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
496 case i.jumpDialog.Active():498 case i.jumpDialog.Active():
497 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)499 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
500+ case i.btwDialog.Active():
501+ dialog = i.btwDialog.Render(i.cfg.Theme, cols)
498 }502 }
499 503
500 // Slash-command autocomplete: popup above the status line, only504 // Slash-command autocomplete: popup above the status line, only
@@ -722,6 +726,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
722 }726 }
723 return false727 return false
724 }728 }
729+ if i.btwDialog.Active() {
730+ if k.Kind == tui.KeyCtrlC {
731+ i.btwDialog.Close()
732+ i.invalidate()
733+ return false
734+ }
735+ i.btwDialog.HandleKey(k, i.invalidate)
736+ return false
737+ }
725 738
726 // Global keys.739 // Global keys.
727 switch k.Kind {740 switch k.Kind {
@@ -959,6 +972,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
959 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)972 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
960 case "/jump":973 case "/jump":
961 i.openJumpDialog(parts[1:])974 i.openJumpDialog(parts[1:])
975+ case "/btw":
976+ i.openBtwDialog(parts[1:])
962 case "/compact":977 case "/compact":
963 i.runCompact(ctx, false)978 i.runCompact(ctx, false)
964 case "/lock":979 case "/lock":
@@ -1110,6 +1125,22 @@ func (i *Interactive) cancelAndWaitForIdle() {
1110 }1125 }
1111 }1126 }
1112 1127
1128+// openBtwDialog opens the side-chat overlay with a frozen snapshot
1129+// of the current main session. The optional argument is auto-
1130+// submitted as the first question, so '/btw does X work?' fires the
1131+// model call immediately instead of just opening an empty dialog.
1132+func (i *Interactive) openBtwDialog(args []string) {
1133+ if i.agent == nil {
1134+ i.mu.Lock()
1135+ i.statusErr = "not logged in. type /login first."
1136+ i.mu.Unlock()
1137+ return
1138+ }
1139+ seed := strings.TrimSpace(strings.Join(args, " "))
1140+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1141+ i.invalidate()
1142+}
1143+
1113 // openJumpDialog builds a /jump picker from the current transcript.1144 // openJumpDialog builds a /jump picker from the current transcript.
1114 // If the user typed "/jump foo" with a filter and it matches exactly1145 // If the user typed "/jump foo" with a filter and it matches exactly
1115 // one turn, jump there directly without showing the dialog.1146 // one turn, jump there directly without showing the dialog.
M internal/agent/modes/slash_suggest.go +1 -0
@@ -34,6 +34,7 @@ var slashCatalog = []slashCommand{
3434 {"/model", "pick a model (or /model <id>)"},
3535 {"/sessions", "resume a previous session for this directory"},
3636 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
37+ {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
3738 {"/compact", "summarize and replace the transcript to free up context"},
3839 {"/lock", "confine tools to the current directory"},
3940 {"/unlock", "allow tools to touch paths outside this directory"},
@@ -34,6 +34,7 @@ var slashCatalog = []slashCommand{
34 {"/model", "pick a model (or /model <id>)"},34 {"/model", "pick a model (or /model <id>)"},
35 {"/sessions", "resume a previous session for this directory"},35 {"/sessions", "resume a previous session for this directory"},
36 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},36 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
37+ {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
37 {"/compact", "summarize and replace the transcript to free up context"},38 {"/compact", "summarize and replace the transcript to free up context"},
38 {"/lock", "confine tools to the current directory"},39 {"/lock", "confine tools to the current directory"},
39 {"/unlock", "allow tools to touch paths outside this directory"},40 {"/unlock", "allow tools to touch paths outside this directory"},
M internal/agent/modes/spinner.go +8 -1
@@ -22,7 +22,9 @@ type spinner struct {
2222 }
2323
2424 // funnyWorkingLines is the rotating text. Kept deliberately short so it
25-// fits next to the token counter on narrow terminals.
25+// fits next to the token counter on narrow terminals. A handful of
26+// craftsman-style aphorisms are folded in among the irreverent lines
27+// for a bit of contrast — the rotation never feels too uniform.
2628 var funnyWorkingLines = []string{
2729 "thinking",
2830 "reticulating splines",
@@ -44,6 +46,11 @@ var funnyWorkingLines = []string{
4446 "arguing with a stack trace",
4547 "googling the answer (not really)",
4648 "rewriting history",
49+ "every draft is a stone in the work",
50+ "bringing order to the unhewn",
51+ "finding the load-bearing measure",
52+ "where clarity grows, work grows lighter",
53+ "every correction serves the work",
4754 }
4855
4956 // spinnerFrames is the cli-spinners "dots3" preset — a 10-frame
@@ -22,7 +22,9 @@ type spinner struct {
22 }22 }
23 23
24 // funnyWorkingLines is the rotating text. Kept deliberately short so it24 // funnyWorkingLines is the rotating text. Kept deliberately short so it
25-// fits next to the token counter on narrow terminals.25+// fits next to the token counter on narrow terminals. A handful of
26+// craftsman-style aphorisms are folded in among the irreverent lines
27+// for a bit of contrast — the rotation never feels too uniform.
26 var funnyWorkingLines = []string{28 var funnyWorkingLines = []string{
27 "thinking",29 "thinking",
28 "reticulating splines",30 "reticulating splines",
@@ -44,6 +46,11 @@ var funnyWorkingLines = []string{
44 "arguing with a stack trace",46 "arguing with a stack trace",
45 "googling the answer (not really)",47 "googling the answer (not really)",
46 "rewriting history",48 "rewriting history",
49+ "every draft is a stone in the work",
50+ "bringing order to the unhewn",
51+ "finding the load-bearing measure",
52+ "where clarity grows, work grows lighter",
53+ "every correction serves the work",
47 }54 }
48 55
49 // spinnerFrames is the cli-spinners "dots3" preset — a 10-frame56 // spinnerFrames is the cli-spinners "dots3" preset — a 10-frame

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