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: | |||
| 174 | 174 | | `/model` | pick a model from a list (or `/model <id>` to set directly) | | |
| 175 | 175 | | `/sessions` | resume a previous session for this directory | | |
| 176 | 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 | 178 | | `/compact` | summarize the transcript into one message to free up context | | |
| 178 | 179 | | `/lock` | confine tools to the current directory | | |
| 179 | 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 | 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 | 207 | ### `/compact` | |
| 194 | 208 | ||
| 195 | 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 | 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 | 252 | ## keys (interactive mode) | |
| 239 | 253 | ||
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | ||
| 6 | + | "github.com/mattn/go-runewidth" | |
| 7 | + | ||
| 6 | 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 | 18 | if width <= 0 { | |
| 17 | 19 | return th.FG256(th.Muted, label) | |
| 18 | 20 | } | |
| 19 | - | padLen := width - len(label) | |
| 21 | + | padLen := width - runewidth.StringWidth(label) | |
| 20 | 22 | if padLen < 0 { | |
| 21 | 23 | padLen = 0 | |
| 22 | 24 | } | |
| @@ -39,7 +41,7 @@ func frameHeaderColor(th tui.Theme, title string, width, color int) string { | |||
| 39 | 41 | if width <= 0 { | |
| 40 | 42 | return th.FG256(color, label) | |
| 41 | 43 | } | |
| 42 | - | padLen := width - len(label) | |
| 44 | + | padLen := width - runewidth.StringWidth(label) | |
| 43 | 45 | if padLen < 0 { | |
| 44 | 46 | padLen = 0 | |
| 45 | 47 | } | |
| @@ -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 = 0 | 23 | 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 = 0 | 46 | padLen = 0 | ||
| 45 | } | 47 | } | ||
M internal/agent/modes/interactive.go +32 -1
| @@ -126,6 +126,7 @@ type Interactive struct { | |||
| 126 | 126 | modelDialog *modelDialog | |
| 127 | 127 | sessionDialog *sessionDialog | |
| 128 | 128 | jumpDialog *jumpDialog | |
| 129 | + | btwDialog *btwDialog | |
| 129 | 130 | suggest *slashSuggester | |
| 130 | 131 | spin *spinner | |
| 131 | 132 | ||
| @@ -168,6 +169,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 168 | 169 | modelDialog: newModelDialog(), | |
| 169 | 170 | sessionDialog: newSessionDialog(), | |
| 170 | 171 | jumpDialog: newJumpDialog(), | |
| 172 | + | btwDialog: newBtwDialog(), | |
| 171 | 173 | suggest: newSlashSuggester(), | |
| 172 | 174 | spin: newSpinner(), | |
| 173 | 175 | } | |
| @@ -350,7 +352,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 350 | 352 | // and the AfterFunc-driven invalidate got dropped on a | |
| 351 | 353 | // full channel. | |
| 352 | 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 | 356 | requestRedraw() // keep the spinner / dialog animation moving | |
| 355 | 357 | } | |
| 356 | 358 | } | |
| @@ -495,6 +497,8 @@ func (i *Interactive) redraw() { | |||
| 495 | 497 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 496 | 498 | case i.jumpDialog.Active(): | |
| 497 | 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 | 504 | // 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 | 727 | 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 | 739 | // Global keys. | |
| 727 | 740 | switch k.Kind { | |
| @@ -959,6 +972,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 959 | 972 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 960 | 973 | case "/jump": | |
| 961 | 974 | i.openJumpDialog(parts[1:]) | |
| 975 | + | case "/btw": | |
| 976 | + | i.openBtwDialog(parts[1:]) | |
| 962 | 977 | case "/compact": | |
| 963 | 978 | i.runCompact(ctx, false) | |
| 964 | 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 | 1144 | // openJumpDialog builds a /jump picker from the current transcript. | |
| 1114 | 1145 | // If the user typed "/jump foo" with a filter and it matches exactly | |
| 1115 | 1146 | // one turn, jump there directly without showing the dialog. | |
| @@ -126,6 +126,7 @@ type Interactive struct { | |||||
| 126 | modelDialog *modelDialog | 126 | modelDialog *modelDialog | ||
| 127 | sessionDialog *sessionDialog | 127 | sessionDialog *sessionDialog | ||
| 128 | jumpDialog *jumpDialog | 128 | jumpDialog *jumpDialog | ||
| 129 | + | btwDialog *btwDialog | |||
| 129 | suggest *slashSuggester | 130 | suggest *slashSuggester | ||
| 130 | spin *spinner | 131 | 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 a | 352 | // 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 moving | 356 | 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, only | 504 | // 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 false | 727 | 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 exactly | 1145 | // 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{ | |||
| 34 | 34 | {"/model", "pick a model (or /model <id>)"}, | |
| 35 | 35 | {"/sessions", "resume a previous session for this directory"}, | |
| 36 | 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 | 38 | {"/compact", "summarize and replace the transcript to free up context"}, | |
| 38 | 39 | {"/lock", "confine tools to the current directory"}, | |
| 39 | 40 | {"/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 { | |||
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | // 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 | 28 | var funnyWorkingLines = []string{ | |
| 27 | 29 | "thinking", | |
| 28 | 30 | "reticulating splines", | |
| @@ -44,6 +46,11 @@ var funnyWorkingLines = []string{ | |||
| 44 | 46 | "arguing with a stack trace", | |
| 45 | 47 | "googling the answer (not really)", | |
| 46 | 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 | 56 | // 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 it | 24 | // 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-frame | 56 | // spinnerFrames is the cli-spinners "dots3" preset — a 10-frame | ||