Add clear_notes extension frame and clear notes on new prompt
- commit
- 63e33d9aa94038490742d786bc58b2d13ee75195
- author
- patriceckhart · · 2026-06-07 09:10 UTC
- parent
- 30cff88
- signature
- unsigned
- refs
- tag: v0.2.14
9 files · +137 -5
M docs/extensions.md +17 -2
| @@ -337,7 +337,22 @@ Closes a previously-open panel. | |||
| 337 | 337 | ``` | |
| 338 | 338 | ||
| 339 | 339 | `level` is one of `info`, `success`, `warn`, `error`. The note shows | |
| 340 | - | up below the transcript with the extension's name in brackets. | |
| 340 | + | up below the transcript with the extension's name in brackets. Notes | |
| 341 | + | are one-shot: they clear automatically when the user sends their next | |
| 342 | + | prompt (and on `esc` / `/clear`). | |
| 343 | + | ||
| 344 | + | #### `clear_notes` (one-way, any time) | |
| 345 | + | ||
| 346 | + | Removes every note this extension previously pushed via `notify` / | |
| 347 | + | `display`. Use it for transient status lines (e.g. an approval prompt) | |
| 348 | + | so they do not stack up; notes from other extensions are untouched. | |
| 349 | + | ||
| 350 | + | ```json | |
| 351 | + | {"type":"clear_notes"} | |
| 352 | + | ``` | |
| 353 | + | ||
| 354 | + | In `--mode rpc`, this surfaces to the host as an `ext_clear_notes` | |
| 355 | + | event (alongside `ext_notify` / `ext_display`). | |
| 341 | 356 | ||
| 342 | 357 | #### `shutdown_ack` | |
| 343 | 358 | ||
| @@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||
| 593 | 608 | Phase 1 (shipped): | |
| 594 | 609 | - [x] subprocess lifecycle + hello handshake | |
| 595 | 610 | - [x] `register_command` + `command_invoked` | |
| 596 | - | - [x] `notify` | |
| 611 | + | - [x] `notify` + `clear_notes` | |
| 597 | 612 | - [x] `zot ext` CLI | |
| 598 | 613 | ||
| 599 | 614 | Phase 2 (shipped): | |
| @@ -337,7 +337,22 @@ Closes a previously-open panel. | |||||
| 337 | ``` | 337 | ``` | ||
| 338 | 338 | ||||
| 339 | `level` is one of `info`, `success`, `warn`, `error`. The note shows | 339 | `level` is one of `info`, `success`, `warn`, `error`. The note shows | ||
| 340 | - | up below the transcript with the extension's name in brackets. | 340 | + | up below the transcript with the extension's name in brackets. Notes |
| 341 | + | are one-shot: they clear automatically when the user sends their next | |||
| 342 | + | prompt (and on `esc` / `/clear`). | |||
| 343 | + | ||||
| 344 | + | #### `clear_notes` (one-way, any time) | |||
| 345 | + | ||||
| 346 | + | Removes every note this extension previously pushed via `notify` / | |||
| 347 | + | `display`. Use it for transient status lines (e.g. an approval prompt) | |||
| 348 | + | so they do not stack up; notes from other extensions are untouched. | |||
| 349 | + | ||||
| 350 | + | ```json | |||
| 351 | + | {"type":"clear_notes"} | |||
| 352 | + | ``` | |||
| 353 | + | ||||
| 354 | + | In `--mode rpc`, this surfaces to the host as an `ext_clear_notes` | |||
| 355 | + | event (alongside `ext_notify` / `ext_display`). | |||
| 341 | 356 | ||||
| 342 | #### `shutdown_ack` | 357 | #### `shutdown_ack` | ||
| 343 | 358 | ||||
| @@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` / | |||||
| 593 | Phase 1 (shipped): | 608 | Phase 1 (shipped): | ||
| 594 | - [x] subprocess lifecycle + hello handshake | 609 | - [x] subprocess lifecycle + hello handshake | ||
| 595 | - [x] `register_command` + `command_invoked` | 610 | - [x] `register_command` + `command_invoked` | ||
| 596 | - | - [x] `notify` | 611 | + | - [x] `notify` + `clear_notes` |
| 597 | - [x] `zot ext` CLI | 612 | - [x] `zot ext` CLI | ||
| 598 | 613 | ||||
| 599 | Phase 2 (shipped): | 614 | Phase 2 (shipped): | ||
M packages/agent/cli.go +6 -0
| @@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) { | |||
| 67 | 67 | iv.Display(extName, text) | |
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | + | func (h *interactiveExtHooks) ClearNotes(extName string) { | |
| 71 | + | if iv := h.iv(); iv != nil { | |
| 72 | + | iv.ClearNotes(extName) | |
| 73 | + | } | |
| 74 | + | } | |
| 70 | 75 | func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 71 | 76 | if iv := h.iv(); iv != nil { | |
| 72 | 77 | iv.OpenPanel(extName, spec) | |
| @@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string) | |||
| 256 | 261 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | |
| 257 | 262 | func (nonInteractiveExtHooks) Insert(string) {} | |
| 258 | 263 | func (nonInteractiveExtHooks) Display(string, string) {} | |
| 264 | + | func (nonInteractiveExtHooks) ClearNotes(string) {} | |
| 259 | 265 | func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 260 | 266 | func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 261 | 267 | func (nonInteractiveExtHooks) ClosePanel(string, string) {} | |
| @@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) { | |||||
| 67 | iv.Display(extName, text) | 67 | iv.Display(extName, text) | ||
| 68 | } | 68 | } | ||
| 69 | } | 69 | } | ||
| 70 | + | func (h *interactiveExtHooks) ClearNotes(extName string) { | |||
| 71 | + | if iv := h.iv(); iv != nil { | |||
| 72 | + | iv.ClearNotes(extName) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 70 | func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) { | 75 | func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) { | ||
| 71 | if iv := h.iv(); iv != nil { | 76 | if iv := h.iv(); iv != nil { | ||
| 72 | iv.OpenPanel(extName, spec) | 77 | iv.OpenPanel(extName, spec) | ||
| @@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string) | |||||
| 256 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | 261 | func (nonInteractiveExtHooks) SubmitSlash(string) {} | ||
| 257 | func (nonInteractiveExtHooks) Insert(string) {} | 262 | func (nonInteractiveExtHooks) Insert(string) {} | ||
| 258 | func (nonInteractiveExtHooks) Display(string, string) {} | 263 | func (nonInteractiveExtHooks) Display(string, string) {} | ||
| 264 | + | func (nonInteractiveExtHooks) ClearNotes(string) {} | |||
| 259 | func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {} | 265 | func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {} | ||
| 260 | func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {} | 266 | func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {} | ||
| 261 | func (nonInteractiveExtHooks) ClosePanel(string, string) {} | 267 | func (nonInteractiveExtHooks) ClosePanel(string, string) {} | ||
M packages/agent/extensions/manager.go +6 -0
| @@ -119,6 +119,10 @@ type HostHooks interface { | |||
| 119 | 119 | // invoking the model and without writing to the transcript. | |
| 120 | 120 | Display(extName, text string) | |
| 121 | 121 | ||
| 122 | + | // ClearNotes removes any notes previously pushed by extName via | |
| 123 | + | // Notify/Display so transient status lines do not stack forever. | |
| 124 | + | ClearNotes(extName string) | |
| 125 | + | ||
| 122 | 126 | OpenPanel(extName string, spec extproto.PanelSpec) | |
| 123 | 127 | UpdatePanel(extName, panelID, title string, lines []string, footer string) | |
| 124 | 128 | ClosePanel(extName, panelID string) | |
| @@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 750 | 754 | if err := json.Unmarshal(line, &n); err == nil { | |
| 751 | 755 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | |
| 752 | 756 | } | |
| 757 | + | case "clear_notes": | |
| 758 | + | m.hooks.ClearNotes(ext.Manifest.Name) | |
| 753 | 759 | case "submit_slash": | |
| 754 | 760 | // Spontaneous request to invoke a slash command in the | |
| 755 | 761 | // TUI. Refused unless the payload looks like a slash | |
| @@ -119,6 +119,10 @@ type HostHooks interface { | |||||
| 119 | // invoking the model and without writing to the transcript. | 119 | // invoking the model and without writing to the transcript. | ||
| 120 | Display(extName, text string) | 120 | Display(extName, text string) | ||
| 121 | 121 | ||||
| 122 | + | // ClearNotes removes any notes previously pushed by extName via | |||
| 123 | + | // Notify/Display so transient status lines do not stack forever. | |||
| 124 | + | ClearNotes(extName string) | |||
| 125 | + | ||||
| 122 | OpenPanel(extName string, spec extproto.PanelSpec) | 126 | OpenPanel(extName string, spec extproto.PanelSpec) | ||
| 123 | UpdatePanel(extName, panelID, title string, lines []string, footer string) | 127 | UpdatePanel(extName, panelID, title string, lines []string, footer string) | ||
| 124 | ClosePanel(extName, panelID string) | 128 | ClosePanel(extName, panelID string) | ||
| @@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 750 | if err := json.Unmarshal(line, &n); err == nil { | 754 | if err := json.Unmarshal(line, &n); err == nil { | ||
| 751 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | 755 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | ||
| 752 | } | 756 | } | ||
| 757 | + | case "clear_notes": | |||
| 758 | + | m.hooks.ClearNotes(ext.Manifest.Name) | |||
| 753 | case "submit_slash": | 759 | case "submit_slash": | ||
| 754 | // Spontaneous request to invoke a slash command in the | 760 | // Spontaneous request to invoke a slash command in the | ||
| 755 | // TUI. Refused unless the payload looks like a slash | 761 | // TUI. Refused unless the payload looks like a slash | ||
M packages/agent/extensions/manager_test.go +9 -3
| @@ -16,9 +16,10 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // stubHooks records every callback so the test can assert on them. | |
| 18 | 18 | type stubHooks struct { | |
| 19 | - | mu sync.Mutex | |
| 20 | - | notifies []string | |
| 21 | - | displays []string | |
| 19 | + | mu sync.Mutex | |
| 20 | + | notifies []string | |
| 21 | + | displays []string | |
| 22 | + | clearNotes []string | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | func (s *stubHooks) Notify(name, level, message string) { | |
| @@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) { | |||
| 34 | 35 | defer s.mu.Unlock() | |
| 35 | 36 | s.displays = append(s.displays, name+":"+text) | |
| 36 | 37 | } | |
| 38 | + | func (s *stubHooks) ClearNotes(name string) { | |
| 39 | + | s.mu.Lock() | |
| 40 | + | defer s.mu.Unlock() | |
| 41 | + | s.clearNotes = append(s.clearNotes, name) | |
| 42 | + | } | |
| 37 | 43 | func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 38 | 44 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 39 | 45 | func (s *stubHooks) ClosePanel(string, string) {} | |
| @@ -16,9 +16,10 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // stubHooks records every callback so the test can assert on them. | 17 | // stubHooks records every callback so the test can assert on them. | ||
| 18 | type stubHooks struct { | 18 | type stubHooks struct { | ||
| 19 | - | mu sync.Mutex | 19 | + | mu sync.Mutex |
| 20 | - | notifies []string | 20 | + | notifies []string |
| 21 | - | displays []string | 21 | + | displays []string |
| 22 | + | clearNotes []string | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | func (s *stubHooks) Notify(name, level, message string) { | 25 | func (s *stubHooks) Notify(name, level, message string) { | ||
| @@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) { | |||||
| 34 | defer s.mu.Unlock() | 35 | defer s.mu.Unlock() | ||
| 35 | s.displays = append(s.displays, name+":"+text) | 36 | s.displays = append(s.displays, name+":"+text) | ||
| 36 | } | 37 | } | ||
| 38 | + | func (s *stubHooks) ClearNotes(name string) { | |||
| 39 | + | s.mu.Lock() | |||
| 40 | + | defer s.mu.Unlock() | |||
| 41 | + | s.clearNotes = append(s.clearNotes, name) | |||
| 42 | + | } | |||
| 37 | func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} | 43 | func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} | ||
| 38 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | 44 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | ||
| 39 | func (s *stubHooks) ClosePanel(string, string) {} | 45 | func (s *stubHooks) ClosePanel(string, string) {} | ||
M packages/agent/extproto/extproto.go +8 -0
| @@ -116,6 +116,14 @@ type NotifyFromExt struct { | |||
| 116 | 116 | Message string `json:"message"` | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | // ClearNotesFromExt is a spontaneous frame an extension can send to | |
| 120 | + | // retract every note it previously pushed via notify/display, so | |
| 121 | + | // transient status lines (e.g. an approval prompt) do not stack up | |
| 122 | + | // forever in the bottom-sticky notes block. | |
| 123 | + | type ClearNotesFromExt struct { | |
| 124 | + | Type string `json:"type"` | |
| 125 | + | } | |
| 126 | + | ||
| 119 | 127 | // SubmitSlashFromExt is a spontaneous frame an extension can send at | |
| 120 | 128 | // any time (typically from a panel_key handler) to invoke a slash | |
| 121 | 129 | // command in the host's TUI as if the user had typed it. Text must | |
| @@ -116,6 +116,14 @@ type NotifyFromExt struct { | |||||
| 116 | Message string `json:"message"` | 116 | Message string `json:"message"` | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | + | // ClearNotesFromExt is a spontaneous frame an extension can send to | |||
| 120 | + | // retract every note it previously pushed via notify/display, so | |||
| 121 | + | // transient status lines (e.g. an approval prompt) do not stack up | |||
| 122 | + | // forever in the bottom-sticky notes block. | |||
| 123 | + | type ClearNotesFromExt struct { | |||
| 124 | + | Type string `json:"type"` | |||
| 125 | + | } | |||
| 126 | + | ||||
| 119 | // SubmitSlashFromExt is a spontaneous frame an extension can send at | 127 | // SubmitSlashFromExt is a spontaneous frame an extension can send at | ||
| 120 | // any time (typically from a panel_key handler) to invoke a slash | 128 | // any time (typically from a panel_key handler) to invoke a slash | ||
| 121 | // command in the host's TUI as if the user had typed it. Text must | 129 | // command in the host's TUI as if the user had typed it. Text must | ||
A packages/agent/modes/ext_notes_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func newNotesTestInteractive() *Interactive { | |
| 11 | + | i := &Interactive{dirty: make(chan struct{}, 1)} | |
| 12 | + | i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4} | |
| 13 | + | return i | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) { | |
| 17 | + | i := newNotesTestInteractive() | |
| 18 | + | ||
| 19 | + | i.Notify("kagi", "info", "pending") | |
| 20 | + | i.Notify("kagi", "success", "approved") | |
| 21 | + | i.Notify("other", "info", "keep me") | |
| 22 | + | ||
| 23 | + | if len(i.extNotes) != 3 { | |
| 24 | + | t.Fatalf("expected 3 notes, got %d", len(i.extNotes)) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | i.ClearNotes("kagi") | |
| 28 | + | ||
| 29 | + | if len(i.extNotes) != 1 { | |
| 30 | + | t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes) | |
| 31 | + | } | |
| 32 | + | if !strings.Contains(i.extNotes[0], "[other] ") { | |
| 33 | + | t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0]) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestClearNotesNoMatchKeepsNotes(t *testing.T) { | |
| 38 | + | i := newNotesTestInteractive() | |
| 39 | + | i.Notify("kagi", "info", "pending") | |
| 40 | + | ||
| 41 | + | i.ClearNotes("nope") | |
| 42 | + | ||
| 43 | + | if len(i.extNotes) != 1 { | |
| 44 | + | t.Fatalf("expected note to survive, got %d", len(i.extNotes)) | |
| 45 | + | } | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func newNotesTestInteractive() *Interactive { | |||
| 11 | + | i := &Interactive{dirty: make(chan struct{}, 1)} | |||
| 12 | + | i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4} | |||
| 13 | + | return i | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) { | |||
| 17 | + | i := newNotesTestInteractive() | |||
| 18 | + | ||||
| 19 | + | i.Notify("kagi", "info", "pending") | |||
| 20 | + | i.Notify("kagi", "success", "approved") | |||
| 21 | + | i.Notify("other", "info", "keep me") | |||
| 22 | + | ||||
| 23 | + | if len(i.extNotes) != 3 { | |||
| 24 | + | t.Fatalf("expected 3 notes, got %d", len(i.extNotes)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | i.ClearNotes("kagi") | |||
| 28 | + | ||||
| 29 | + | if len(i.extNotes) != 1 { | |||
| 30 | + | t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes) | |||
| 31 | + | } | |||
| 32 | + | if !strings.Contains(i.extNotes[0], "[other] ") { | |||
| 33 | + | t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0]) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestClearNotesNoMatchKeepsNotes(t *testing.T) { | |||
| 38 | + | i := newNotesTestInteractive() | |||
| 39 | + | i.Notify("kagi", "info", "pending") | |||
| 40 | + | ||||
| 41 | + | i.ClearNotes("nope") | |||
| 42 | + | ||||
| 43 | + | if len(i.extNotes) != 1 { | |||
| 44 | + | t.Fatalf("expected note to survive, got %d", len(i.extNotes)) | |||
| 45 | + | } | |||
| 46 | + | } | |||
M packages/agent/modes/interactive.go +31 -0
| @@ -2356,6 +2356,36 @@ func (i *Interactive) Notify(extName, level, message string) { | |||
| 2356 | 2356 | i.invalidate() | |
| 2357 | 2357 | } | |
| 2358 | 2358 | ||
| 2359 | + | // ClearNotes removes every note line owned by extName from the | |
| 2360 | + | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 2361 | + | // transient status line (e.g. an approval prompt) once it no longer | |
| 2362 | + | // applies, instead of leaving it stacked forever. Notes from other | |
| 2363 | + | // extensions and internal notes (auto-compact) are left untouched. | |
| 2364 | + | func (i *Interactive) ClearNotes(extName string) { | |
| 2365 | + | marker := "[" + extName + "] " | |
| 2366 | + | i.mu.Lock() | |
| 2367 | + | if len(i.extNotes) == 0 { | |
| 2368 | + | i.mu.Unlock() | |
| 2369 | + | return | |
| 2370 | + | } | |
| 2371 | + | kept := i.extNotes[:0:0] | |
| 2372 | + | changed := false | |
| 2373 | + | for _, line := range i.extNotes { | |
| 2374 | + | if strings.Contains(line, marker) { | |
| 2375 | + | changed = true | |
| 2376 | + | continue | |
| 2377 | + | } | |
| 2378 | + | kept = append(kept, line) | |
| 2379 | + | } | |
| 2380 | + | if changed { | |
| 2381 | + | i.extNotes = kept | |
| 2382 | + | } | |
| 2383 | + | i.mu.Unlock() | |
| 2384 | + | if changed { | |
| 2385 | + | i.invalidate() | |
| 2386 | + | } | |
| 2387 | + | } | |
| 2388 | + | ||
| 2359 | 2389 | // Submit feeds text through the agent loop as if the user had typed it. | |
| 2360 | 2390 | func (i *Interactive) Submit(text string) { | |
| 2361 | 2391 | if cmd, ok := shellEscapeCommand(text); ok { | |
| @@ -4237,6 +4267,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 4237 | 4267 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4238 | 4268 | i.toolOrder = nil | |
| 4239 | 4269 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 4270 | + | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 4240 | 4271 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 4241 | 4272 | // Reset the auto-follow baseline so the very next render at | |
| 4242 | 4273 | // interactive.go:1053 doesn't see a synthetic shrink between | |
| @@ -2356,6 +2356,36 @@ func (i *Interactive) Notify(extName, level, message string) { | |||||
| 2356 | i.invalidate() | 2356 | i.invalidate() | ||
| 2357 | } | 2357 | } | ||
| 2358 | 2358 | ||||
| 2359 | + | // ClearNotes removes every note line owned by extName from the | |||
| 2360 | + | // bottom-sticky ext-notes block. Extensions use this to retract a | |||
| 2361 | + | // transient status line (e.g. an approval prompt) once it no longer | |||
| 2362 | + | // applies, instead of leaving it stacked forever. Notes from other | |||
| 2363 | + | // extensions and internal notes (auto-compact) are left untouched. | |||
| 2364 | + | func (i *Interactive) ClearNotes(extName string) { | |||
| 2365 | + | marker := "[" + extName + "] " | |||
| 2366 | + | i.mu.Lock() | |||
| 2367 | + | if len(i.extNotes) == 0 { | |||
| 2368 | + | i.mu.Unlock() | |||
| 2369 | + | return | |||
| 2370 | + | } | |||
| 2371 | + | kept := i.extNotes[:0:0] | |||
| 2372 | + | changed := false | |||
| 2373 | + | for _, line := range i.extNotes { | |||
| 2374 | + | if strings.Contains(line, marker) { | |||
| 2375 | + | changed = true | |||
| 2376 | + | continue | |||
| 2377 | + | } | |||
| 2378 | + | kept = append(kept, line) | |||
| 2379 | + | } | |||
| 2380 | + | if changed { | |||
| 2381 | + | i.extNotes = kept | |||
| 2382 | + | } | |||
| 2383 | + | i.mu.Unlock() | |||
| 2384 | + | if changed { | |||
| 2385 | + | i.invalidate() | |||
| 2386 | + | } | |||
| 2387 | + | } | |||
| 2388 | + | ||||
| 2359 | // Submit feeds text through the agent loop as if the user had typed it. | 2389 | // Submit feeds text through the agent loop as if the user had typed it. | ||
| 2360 | func (i *Interactive) Submit(text string) { | 2390 | func (i *Interactive) Submit(text string) { | ||
| 2361 | if cmd, ok := shellEscapeCommand(text); ok { | 2391 | if cmd, ok := shellEscapeCommand(text); ok { | ||
| @@ -4237,6 +4267,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 4237 | i.toolCalls = map[string]*tui.ToolCallView{} | 4267 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4238 | i.toolOrder = nil | 4268 | i.toolOrder = nil | ||
| 4239 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | 4269 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | ||
| 4270 | + | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |||
| 4240 | i.scrollOffset = 0 // jump back to the bottom on new turn | 4271 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| 4241 | // Reset the auto-follow baseline so the very next render at | 4272 | // Reset the auto-follow baseline so the very next render at | ||
| 4242 | // interactive.go:1053 doesn't see a synthetic shrink between | 4273 | // interactive.go:1053 doesn't see a synthetic shrink between | ||
M packages/agent/rpc.go +8 -0
| @@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) { | |||
| 142 | 142 | }) | |
| 143 | 143 | } | |
| 144 | 144 | } | |
| 145 | + | func (h *rpcExtHooks) ClearNotes(extName string) { | |
| 146 | + | if h.server != nil { | |
| 147 | + | h.server.writeEvent(map[string]any{ | |
| 148 | + | "type": "ext_clear_notes", | |
| 149 | + | "extension": extName, | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 145 | 153 | func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode | |
| 146 | 154 | func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode | |
| 147 | 155 | func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode | |
| @@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) { | |||||
| 142 | }) | 142 | }) | ||
| 143 | } | 143 | } | ||
| 144 | } | 144 | } | ||
| 145 | + | func (h *rpcExtHooks) ClearNotes(extName string) { | |||
| 146 | + | if h.server != nil { | |||
| 147 | + | h.server.writeEvent(map[string]any{ | |||
| 148 | + | "type": "ext_clear_notes", | |||
| 149 | + | "extension": extName, | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 145 | func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode | 153 | func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode | ||
| 146 | func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode | 154 | func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode | ||
| 147 | func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode | 155 | func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode | ||
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +6 -0
| @@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off. | |||
| 145 | 145 | {"type":"event_intercept_response","id":"ghi","block":true, | |
| 146 | 146 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | |
| 147 | 147 | {"type":"notify","level":"info","message":"refreshed cache"} | |
| 148 | + | {"type":"clear_notes"} | |
| 148 | 149 | {"type":"shutdown_ack"} | |
| 149 | 150 | ``` | |
| 150 | 151 | ||
| 152 | + | `notify` notes are one-shot: they clear when the user sends their next | |
| 153 | + | prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every | |
| 154 | + | note this extension pushed earlier (e.g. a transient approval prompt) | |
| 155 | + | without waiting for the next turn; other extensions' notes are kept. | |
| 156 | + | ||
| 151 | 157 | `command_response.action` values: | |
| 152 | 158 | - `"prompt"` — submit `prompt` as a fresh user message | |
| 153 | 159 | - `"insert"` — drop `insert` into the editor at the cursor | |
| @@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off. | |||||
| 145 | {"type":"event_intercept_response","id":"ghi","block":true, | 145 | {"type":"event_intercept_response","id":"ghi","block":true, | ||
| 146 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | 146 | "reason":"refused: command matches the danger pattern \"rm -rf\""} | ||
| 147 | {"type":"notify","level":"info","message":"refreshed cache"} | 147 | {"type":"notify","level":"info","message":"refreshed cache"} | ||
| 148 | + | {"type":"clear_notes"} | |||
| 148 | {"type":"shutdown_ack"} | 149 | {"type":"shutdown_ack"} | ||
| 149 | ``` | 150 | ``` | ||
| 150 | 151 | ||||
| 152 | + | `notify` notes are one-shot: they clear when the user sends their next | |||
| 153 | + | prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every | |||
| 154 | + | note this extension pushed earlier (e.g. a transient approval prompt) | |||
| 155 | + | without waiting for the next turn; other extensions' notes are kept. | |||
| 156 | + | ||||
| 151 | `command_response.action` values: | 157 | `command_response.action` values: | ||
| 152 | - `"prompt"` — submit `prompt` as a fresh user message | 158 | - `"prompt"` — submit `prompt` as a fresh user message | ||
| 153 | - `"insert"` — drop `insert` into the editor at the cursor | 159 | - `"insert"` — drop `insert` into the editor at the cursor | ||