refactor(modes): split interactive input and alerts
- commit
- 50fb5efa229b0499cfaa31b52aee3bce553555eb
- author
- brice · · 2026-08-14 03:33 UTC
- committer
- brice · · 2026-08-14 03:56 UTC
- parent
- 180d726
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +182 -155
M packages/agent/modes/interactive.go +3 -155
| @@ -1377,47 +1377,6 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1377 | 1377 | } | |
| 1378 | 1378 | } | |
| 1379 | 1379 | ||
| 1380 | - | func (i *Interactive) requestRendererClear() { | |
| 1381 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1382 | - | scheduler.request(true, false) | |
| 1383 | - | return | |
| 1384 | - | } | |
| 1385 | - | if i.rend != nil { | |
| 1386 | - | i.rend.Clear() | |
| 1387 | - | } | |
| 1388 | - | } | |
| 1389 | - | ||
| 1390 | - | func (i *Interactive) requestRendererInvalidate() { | |
| 1391 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1392 | - | scheduler.request(false, true) | |
| 1393 | - | return | |
| 1394 | - | } | |
| 1395 | - | if i.rend != nil { | |
| 1396 | - | i.rend.Invalidate() | |
| 1397 | - | } | |
| 1398 | - | } | |
| 1399 | - | ||
| 1400 | - | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |
| 1401 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1402 | - | scheduler.requestTheme(theme) | |
| 1403 | - | return | |
| 1404 | - | } | |
| 1405 | - | if i.rend != nil { | |
| 1406 | - | i.rend.SetTheme(theme) | |
| 1407 | - | } | |
| 1408 | - | } | |
| 1409 | - | ||
| 1410 | - | func (i *Interactive) invalidate() { | |
| 1411 | - | i.renderRevision.Add(1) | |
| 1412 | - | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 1413 | - | // owns terminal output, but scheduling it here would bypass | |
| 1414 | - | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 1415 | - | select { | |
| 1416 | - | case i.dirty <- struct{}{}: | |
| 1417 | - | default: | |
| 1418 | - | } | |
| 1419 | - | } | |
| 1420 | - | ||
| 1421 | 1380 | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 1422 | 1381 | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 1423 | 1382 | // transcript cache, but never return a full-frame cache entry that would | |
| @@ -2823,49 +2782,9 @@ func (i *Interactive) restoreConfirmFocus() { | |||
| 2823 | 2782 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 2824 | 2783 | defer i.restoreConfirmFocus() | |
| 2825 | 2784 | ||
| 2826 | - | // A bare Escape is eligible for the session-tree gesture only while the | |
| 2827 | - | // main input owns the key. Any other key, modified Escape, or visible | |
| 2828 | - | // child interaction resets the pending first tap before normal routing. | |
| 2829 | - | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |
| 2830 | - | i.mu.Lock() | |
| 2831 | - | i.doubleEscape.Reset() | |
| 2832 | - | i.mu.Unlock() | |
| 2833 | - | } | |
| 2834 | - | ||
| 2835 | - | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 2836 | - | // here when a child interaction owns input so every editor and filter sees | |
| 2837 | - | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| 2838 | - | // left image-first to preserve macOS clipboard image attachments. | |
| 2839 | - | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |
| 2840 | - | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |
| 2841 | - | if err != nil { | |
| 2842 | - | i.mu.Lock() | |
| 2843 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 2844 | - | i.statusOK = "" | |
| 2845 | - | i.mu.Unlock() | |
| 2846 | - | return false | |
| 2847 | - | } | |
| 2848 | - | if pastedText { | |
| 2849 | - | k = resolved | |
| 2850 | - | i.mu.Lock() | |
| 2851 | - | i.statusErr = "" | |
| 2852 | - | i.statusOK = "" | |
| 2853 | - | i.mu.Unlock() | |
| 2854 | - | } | |
| 2855 | - | } | |
| 2856 | - | ||
| 2857 | - | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 2858 | - | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 2859 | - | // unexpectedly. The hint message also goes stale; clear it. | |
| 2860 | - | if k.Kind != tui.KeyCtrlC { | |
| 2861 | - | i.mu.Lock() | |
| 2862 | - | if !i.lastCtrlC.IsZero() { | |
| 2863 | - | i.lastCtrlC = time.Time{} | |
| 2864 | - | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |
| 2865 | - | i.statusOK = "" | |
| 2866 | - | } | |
| 2867 | - | } | |
| 2868 | - | i.mu.Unlock() | |
| 2785 | + | var ok bool | |
| 2786 | + | if k, ok = i.prepareInputKey(k); !ok { | |
| 2787 | + | return false | |
| 2869 | 2788 | } | |
| 2870 | 2789 | ||
| 2871 | 2790 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| @@ -3920,77 +3839,6 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||
| 3920 | 3839 | // holds an interface, not a concrete *Interactive, so these methods | |
| 3921 | 3840 | // are the only thing the manager sees. | |
| 3922 | 3841 | ||
| 3923 | - | // Notify is the manager's NotifyFromExt entry point. | |
| 3924 | - | func (i *Interactive) Notify(extName, level, message string) { | |
| 3925 | - | i.appendExtensionNote(extName, message, level) | |
| 3926 | - | i.invalidate() | |
| 3927 | - | } | |
| 3928 | - | ||
| 3929 | - | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |
| 3930 | - | // through the same terminal writer as redraws while holding the TUI mutex, | |
| 3931 | - | // so a BEL cannot be interleaved with a frame update. | |
| 3932 | - | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |
| 3933 | - | i.mu.Lock() | |
| 3934 | - | i.emitAlertLocked(alert) | |
| 3935 | - | i.mu.Unlock() | |
| 3936 | - | } | |
| 3937 | - | ||
| 3938 | - | func terminalAlertsEnabled(enabled *bool) bool { | |
| 3939 | - | return enabled == nil || *enabled | |
| 3940 | - | } | |
| 3941 | - | ||
| 3942 | - | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |
| 3943 | - | // hold i.mu because terminal writes share the renderer's output boundary. | |
| 3944 | - | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |
| 3945 | - | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |
| 3946 | - | return | |
| 3947 | - | } | |
| 3948 | - | _ = tui.WriteBell(i.cfg.Terminal) | |
| 3949 | - | } | |
| 3950 | - | ||
| 3951 | - | // scheduleMainAlert defers a main-session alert until the next redraw | |
| 3952 | - | // commits the final frame. Keeping this deferred even when no paced text is | |
| 3953 | - | // pending avoids racing the pacer's final state transition. | |
| 3954 | - | func (i *Interactive) scheduleMainAlert(reason string) { | |
| 3955 | - | if reason == "" { | |
| 3956 | - | return | |
| 3957 | - | } | |
| 3958 | - | i.mu.Lock() | |
| 3959 | - | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |
| 3960 | - | i.mu.Unlock() | |
| 3961 | - | i.invalidate() | |
| 3962 | - | } | |
| 3963 | - | ||
| 3964 | - | // ClearNotes removes every note line owned by extName from the | |
| 3965 | - | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 3966 | - | // transient status line (e.g. an approval prompt) once it no longer | |
| 3967 | - | // applies, instead of leaving it stacked forever. Notes from other | |
| 3968 | - | // extensions and internal notes (auto-compact) are left untouched. | |
| 3969 | - | func (i *Interactive) ClearNotes(extName string) { | |
| 3970 | - | marker := "[" + extName + "] " | |
| 3971 | - | i.mu.Lock() | |
| 3972 | - | if len(i.extNotes) == 0 { | |
| 3973 | - | i.mu.Unlock() | |
| 3974 | - | return | |
| 3975 | - | } | |
| 3976 | - | kept := i.extNotes[:0:0] | |
| 3977 | - | changed := false | |
| 3978 | - | for _, line := range i.extNotes { | |
| 3979 | - | if strings.Contains(line, marker) { | |
| 3980 | - | changed = true | |
| 3981 | - | continue | |
| 3982 | - | } | |
| 3983 | - | kept = append(kept, line) | |
| 3984 | - | } | |
| 3985 | - | if changed { | |
| 3986 | - | i.extNotes = kept | |
| 3987 | - | } | |
| 3988 | - | i.mu.Unlock() | |
| 3989 | - | if changed { | |
| 3990 | - | i.invalidate() | |
| 3991 | - | } | |
| 3992 | - | } | |
| 3993 | - | ||
| 3994 | 3842 | // Submit feeds text through the agent loop as if the user had typed it. | |
| 3995 | 3843 | func (i *Interactive) Submit(text string) { | |
| 3996 | 3844 | if cmd, ok := shellEscapeCommand(text); ok { | |
| @@ -1377,47 +1377,6 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1377 | } | 1377 | } | ||
| 1378 | } | 1378 | } | ||
| 1379 | 1379 | ||||
| 1380 | - | func (i *Interactive) requestRendererClear() { | |||
| 1381 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1382 | - | scheduler.request(true, false) | |||
| 1383 | - | return | |||
| 1384 | - | } | |||
| 1385 | - | if i.rend != nil { | |||
| 1386 | - | i.rend.Clear() | |||
| 1387 | - | } | |||
| 1388 | - | } | |||
| 1389 | - | ||||
| 1390 | - | func (i *Interactive) requestRendererInvalidate() { | |||
| 1391 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1392 | - | scheduler.request(false, true) | |||
| 1393 | - | return | |||
| 1394 | - | } | |||
| 1395 | - | if i.rend != nil { | |||
| 1396 | - | i.rend.Invalidate() | |||
| 1397 | - | } | |||
| 1398 | - | } | |||
| 1399 | - | ||||
| 1400 | - | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||
| 1401 | - | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1402 | - | scheduler.requestTheme(theme) | |||
| 1403 | - | return | |||
| 1404 | - | } | |||
| 1405 | - | if i.rend != nil { | |||
| 1406 | - | i.rend.SetTheme(theme) | |||
| 1407 | - | } | |||
| 1408 | - | } | |||
| 1409 | - | ||||
| 1410 | - | func (i *Interactive) invalidate() { | |||
| 1411 | - | i.renderRevision.Add(1) | |||
| 1412 | - | // Keep ordinary state changes on the main-loop throttle. The scheduler | |||
| 1413 | - | // owns terminal output, but scheduling it here would bypass | |||
| 1414 | - | // redrawMinInterval and paint every intermediate tool-event frame. | |||
| 1415 | - | select { | |||
| 1416 | - | case i.dirty <- struct{}{}: | |||
| 1417 | - | default: | |||
| 1418 | - | } | |||
| 1419 | - | } | |||
| 1420 | - | ||||
| 1421 | func (i *Interactive) cachedChatLocked(cols int) []string { | 1380 | func (i *Interactive) cachedChatLocked(cols int) []string { | ||
| 1422 | // A busy frame contains mutable streaming/tool state. Keep the stable | 1381 | // A busy frame contains mutable streaming/tool state. Keep the stable | ||
| 1423 | // transcript cache, but never return a full-frame cache entry that would | 1382 | // transcript cache, but never return a full-frame cache entry that would | ||
| @@ -2823,49 +2782,9 @@ func (i *Interactive) restoreConfirmFocus() { | |||||
| 2823 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 2782 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 2824 | defer i.restoreConfirmFocus() | 2783 | defer i.restoreConfirmFocus() | ||
| 2825 | 2784 | ||||
| 2826 | - | // A bare Escape is eligible for the session-tree gesture only while the | 2785 | + | var ok bool |
| 2827 | - | // main input owns the key. Any other key, modified Escape, or visible | 2786 | + | if k, ok = i.prepareInputKey(k); !ok { |
| 2828 | - | // child interaction resets the pending first tap before normal routing. | 2787 | + | return false |
| 2829 | - | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |||
| 2830 | - | i.mu.Lock() | |||
| 2831 | - | i.doubleEscape.Reset() | |||
| 2832 | - | i.mu.Unlock() | |||
| 2833 | - | } | |||
| 2834 | - | ||||
| 2835 | - | // Dialogs route keys before the main clipboard handler below. Resolve text | |||
| 2836 | - | // here when a child interaction owns input so every editor and filter sees | |||
| 2837 | - | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |||
| 2838 | - | // left image-first to preserve macOS clipboard image attachments. | |||
| 2839 | - | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |||
| 2840 | - | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |||
| 2841 | - | if err != nil { | |||
| 2842 | - | i.mu.Lock() | |||
| 2843 | - | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 2844 | - | i.statusOK = "" | |||
| 2845 | - | i.mu.Unlock() | |||
| 2846 | - | return false | |||
| 2847 | - | } | |||
| 2848 | - | if pastedText { | |||
| 2849 | - | k = resolved | |||
| 2850 | - | i.mu.Lock() | |||
| 2851 | - | i.statusErr = "" | |||
| 2852 | - | i.statusOK = "" | |||
| 2853 | - | i.mu.Unlock() | |||
| 2854 | - | } | |||
| 2855 | - | } | |||
| 2856 | - | ||||
| 2857 | - | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |||
| 2858 | - | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |||
| 2859 | - | // unexpectedly. The hint message also goes stale; clear it. | |||
| 2860 | - | if k.Kind != tui.KeyCtrlC { | |||
| 2861 | - | i.mu.Lock() | |||
| 2862 | - | if !i.lastCtrlC.IsZero() { | |||
| 2863 | - | i.lastCtrlC = time.Time{} | |||
| 2864 | - | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |||
| 2865 | - | i.statusOK = "" | |||
| 2866 | - | } | |||
| 2867 | - | } | |||
| 2868 | - | i.mu.Unlock() | |||
| 2869 | } | 2788 | } | ||
| 2870 | 2789 | ||||
| 2871 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | 2790 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | ||
| @@ -3920,77 +3839,6 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||||
| 3920 | // holds an interface, not a concrete *Interactive, so these methods | 3839 | // holds an interface, not a concrete *Interactive, so these methods | ||
| 3921 | // are the only thing the manager sees. | 3840 | // are the only thing the manager sees. | ||
| 3922 | 3841 | ||||
| 3923 | - | // Notify is the manager's NotifyFromExt entry point. | |||
| 3924 | - | func (i *Interactive) Notify(extName, level, message string) { | |||
| 3925 | - | i.appendExtensionNote(extName, message, level) | |||
| 3926 | - | i.invalidate() | |||
| 3927 | - | } | |||
| 3928 | - | ||||
| 3929 | - | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |||
| 3930 | - | // through the same terminal writer as redraws while holding the TUI mutex, | |||
| 3931 | - | // so a BEL cannot be interleaved with a frame update. | |||
| 3932 | - | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |||
| 3933 | - | i.mu.Lock() | |||
| 3934 | - | i.emitAlertLocked(alert) | |||
| 3935 | - | i.mu.Unlock() | |||
| 3936 | - | } | |||
| 3937 | - | ||||
| 3938 | - | func terminalAlertsEnabled(enabled *bool) bool { | |||
| 3939 | - | return enabled == nil || *enabled | |||
| 3940 | - | } | |||
| 3941 | - | ||||
| 3942 | - | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |||
| 3943 | - | // hold i.mu because terminal writes share the renderer's output boundary. | |||
| 3944 | - | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |||
| 3945 | - | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |||
| 3946 | - | return | |||
| 3947 | - | } | |||
| 3948 | - | _ = tui.WriteBell(i.cfg.Terminal) | |||
| 3949 | - | } | |||
| 3950 | - | ||||
| 3951 | - | // scheduleMainAlert defers a main-session alert until the next redraw | |||
| 3952 | - | // commits the final frame. Keeping this deferred even when no paced text is | |||
| 3953 | - | // pending avoids racing the pacer's final state transition. | |||
| 3954 | - | func (i *Interactive) scheduleMainAlert(reason string) { | |||
| 3955 | - | if reason == "" { | |||
| 3956 | - | return | |||
| 3957 | - | } | |||
| 3958 | - | i.mu.Lock() | |||
| 3959 | - | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |||
| 3960 | - | i.mu.Unlock() | |||
| 3961 | - | i.invalidate() | |||
| 3962 | - | } | |||
| 3963 | - | ||||
| 3964 | - | // ClearNotes removes every note line owned by extName from the | |||
| 3965 | - | // bottom-sticky ext-notes block. Extensions use this to retract a | |||
| 3966 | - | // transient status line (e.g. an approval prompt) once it no longer | |||
| 3967 | - | // applies, instead of leaving it stacked forever. Notes from other | |||
| 3968 | - | // extensions and internal notes (auto-compact) are left untouched. | |||
| 3969 | - | func (i *Interactive) ClearNotes(extName string) { | |||
| 3970 | - | marker := "[" + extName + "] " | |||
| 3971 | - | i.mu.Lock() | |||
| 3972 | - | if len(i.extNotes) == 0 { | |||
| 3973 | - | i.mu.Unlock() | |||
| 3974 | - | return | |||
| 3975 | - | } | |||
| 3976 | - | kept := i.extNotes[:0:0] | |||
| 3977 | - | changed := false | |||
| 3978 | - | for _, line := range i.extNotes { | |||
| 3979 | - | if strings.Contains(line, marker) { | |||
| 3980 | - | changed = true | |||
| 3981 | - | continue | |||
| 3982 | - | } | |||
| 3983 | - | kept = append(kept, line) | |||
| 3984 | - | } | |||
| 3985 | - | if changed { | |||
| 3986 | - | i.extNotes = kept | |||
| 3987 | - | } | |||
| 3988 | - | i.mu.Unlock() | |||
| 3989 | - | if changed { | |||
| 3990 | - | i.invalidate() | |||
| 3991 | - | } | |||
| 3992 | - | } | |||
| 3993 | - | ||||
| 3994 | // Submit feeds text through the agent loop as if the user had typed it. | 3842 | // Submit feeds text through the agent loop as if the user had typed it. | ||
| 3995 | func (i *Interactive) Submit(text string) { | 3843 | func (i *Interactive) Submit(text string) { | ||
| 3996 | if cmd, ok := shellEscapeCommand(text); ok { | 3844 | if cmd, ok := shellEscapeCommand(text); ok { | ||
A packages/agent/modes/interactive_alerts.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 7 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Notify is the manager's NotifyFromExt entry point. | |
| 11 | + | func (i *Interactive) Notify(extName, level, message string) { | |
| 12 | + | i.appendExtensionNote(extName, message, level) | |
| 13 | + | i.invalidate() | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |
| 17 | + | // through the same terminal writer as redraws while holding the TUI mutex, | |
| 18 | + | // so a BEL cannot be interleaved with a frame update. | |
| 19 | + | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |
| 20 | + | i.mu.Lock() | |
| 21 | + | i.emitAlertLocked(alert) | |
| 22 | + | i.mu.Unlock() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func terminalAlertsEnabled(enabled *bool) bool { | |
| 26 | + | return enabled == nil || *enabled | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |
| 30 | + | // hold i.mu because terminal writes share the renderer's output boundary. | |
| 31 | + | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |
| 32 | + | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | _ = tui.WriteBell(i.cfg.Terminal) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // scheduleMainAlert defers a main-session alert until the next redraw | |
| 39 | + | // commits the final frame. Keeping this deferred even when no paced text is | |
| 40 | + | // pending avoids racing the pacer's final state transition. | |
| 41 | + | func (i *Interactive) scheduleMainAlert(reason string) { | |
| 42 | + | if reason == "" { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | i.mu.Lock() | |
| 46 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |
| 47 | + | i.mu.Unlock() | |
| 48 | + | i.invalidate() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ClearNotes removes every note line owned by extName from the | |
| 52 | + | // bottom-sticky ext-notes block. Extensions use this to retract a | |
| 53 | + | // transient status line (e.g. an approval prompt) once it no longer | |
| 54 | + | // applies, instead of leaving it stacked forever. Notes from other | |
| 55 | + | // extensions and internal notes (auto-compact) are left untouched. | |
| 56 | + | func (i *Interactive) ClearNotes(extName string) { | |
| 57 | + | marker := "[" + extName + "] " | |
| 58 | + | i.mu.Lock() | |
| 59 | + | if len(i.extNotes) == 0 { | |
| 60 | + | i.mu.Unlock() | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | kept := i.extNotes[:0:0] | |
| 64 | + | changed := false | |
| 65 | + | for _, line := range i.extNotes { | |
| 66 | + | if strings.Contains(line, marker) { | |
| 67 | + | changed = true | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | kept = append(kept, line) | |
| 71 | + | } | |
| 72 | + | if changed { | |
| 73 | + | i.extNotes = kept | |
| 74 | + | } | |
| 75 | + | i.mu.Unlock() | |
| 76 | + | if changed { | |
| 77 | + | i.invalidate() | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/agent/extproto" | |||
| 7 | + | "github.com/bnema/zut/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Notify is the manager's NotifyFromExt entry point. | |||
| 11 | + | func (i *Interactive) Notify(extName, level, message string) { | |||
| 12 | + | i.appendExtensionNote(extName, message, level) | |||
| 13 | + | i.invalidate() | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Alert is the manager's AlertFromExt entry point. Alerts are emitted | |||
| 17 | + | // through the same terminal writer as redraws while holding the TUI mutex, | |||
| 18 | + | // so a BEL cannot be interleaved with a frame update. | |||
| 19 | + | func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) { | |||
| 20 | + | i.mu.Lock() | |||
| 21 | + | i.emitAlertLocked(alert) | |||
| 22 | + | i.mu.Unlock() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func terminalAlertsEnabled(enabled *bool) bool { | |||
| 26 | + | return enabled == nil || *enabled | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // emitAlertLocked applies the shared terminal-alert policy. The caller must | |||
| 30 | + | // hold i.mu because terminal writes share the renderer's output boundary. | |||
| 31 | + | func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) { | |||
| 32 | + | if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil { | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | _ = tui.WriteBell(i.cfg.Terminal) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // scheduleMainAlert defers a main-session alert until the next redraw | |||
| 39 | + | // commits the final frame. Keeping this deferred even when no paced text is | |||
| 40 | + | // pending avoids racing the pacer's final state transition. | |||
| 41 | + | func (i *Interactive) scheduleMainAlert(reason string) { | |||
| 42 | + | if reason == "" { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | i.mu.Lock() | |||
| 46 | + | i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason} | |||
| 47 | + | i.mu.Unlock() | |||
| 48 | + | i.invalidate() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // ClearNotes removes every note line owned by extName from the | |||
| 52 | + | // bottom-sticky ext-notes block. Extensions use this to retract a | |||
| 53 | + | // transient status line (e.g. an approval prompt) once it no longer | |||
| 54 | + | // applies, instead of leaving it stacked forever. Notes from other | |||
| 55 | + | // extensions and internal notes (auto-compact) are left untouched. | |||
| 56 | + | func (i *Interactive) ClearNotes(extName string) { | |||
| 57 | + | marker := "[" + extName + "] " | |||
| 58 | + | i.mu.Lock() | |||
| 59 | + | if len(i.extNotes) == 0 { | |||
| 60 | + | i.mu.Unlock() | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | kept := i.extNotes[:0:0] | |||
| 64 | + | changed := false | |||
| 65 | + | for _, line := range i.extNotes { | |||
| 66 | + | if strings.Contains(line, marker) { | |||
| 67 | + | changed = true | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | kept = append(kept, line) | |||
| 71 | + | } | |||
| 72 | + | if changed { | |||
| 73 | + | i.extNotes = kept | |||
| 74 | + | } | |||
| 75 | + | i.mu.Unlock() | |||
| 76 | + | if changed { | |||
| 77 | + | i.invalidate() | |||
| 78 | + | } | |||
| 79 | + | } | |||
A packages/agent/modes/interactive_input.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // prepareInputKey applies main-editor key state that must happen before | |
| 11 | + | // routing the key to a dialog, suggestion, or editor. It returns false when | |
| 12 | + | // clipboard resolution failed and the key must not be delivered further. | |
| 13 | + | func (i *Interactive) prepareInputKey(k tui.Key) (tui.Key, bool) { | |
| 14 | + | // A bare Escape is eligible for the session-tree gesture only while the | |
| 15 | + | // main input owns the key. Any other key, modified Escape, or visible | |
| 16 | + | // child interaction resets the pending first tap before normal routing. | |
| 17 | + | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |
| 18 | + | i.mu.Lock() | |
| 19 | + | i.doubleEscape.Reset() | |
| 20 | + | i.mu.Unlock() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Dialogs route keys before the main clipboard handler below. Resolve text | |
| 24 | + | // here when a child interaction owns input so every editor and filter sees | |
| 25 | + | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |
| 26 | + | // left image-first to preserve macOS clipboard image attachments. | |
| 27 | + | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |
| 28 | + | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |
| 29 | + | if err != nil { | |
| 30 | + | i.mu.Lock() | |
| 31 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |
| 32 | + | i.statusOK = "" | |
| 33 | + | i.mu.Unlock() | |
| 34 | + | return tui.Key{}, false | |
| 35 | + | } | |
| 36 | + | if pastedText { | |
| 37 | + | k = resolved | |
| 38 | + | i.mu.Lock() | |
| 39 | + | i.statusErr = "" | |
| 40 | + | i.statusOK = "" | |
| 41 | + | i.mu.Unlock() | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so pressing | |
| 46 | + | // ctrl+c then typing then ctrl+c much later doesn't quit unexpectedly. The | |
| 47 | + | // hint message also goes stale; clear it. | |
| 48 | + | if k.Kind != tui.KeyCtrlC { | |
| 49 | + | i.mu.Lock() | |
| 50 | + | if !i.lastCtrlC.IsZero() { | |
| 51 | + | i.lastCtrlC = time.Time{} | |
| 52 | + | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |
| 53 | + | i.statusOK = "" | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | i.mu.Unlock() | |
| 57 | + | } | |
| 58 | + | return k, true | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // prepareInputKey applies main-editor key state that must happen before | |||
| 11 | + | // routing the key to a dialog, suggestion, or editor. It returns false when | |||
| 12 | + | // clipboard resolution failed and the key must not be delivered further. | |||
| 13 | + | func (i *Interactive) prepareInputKey(k tui.Key) (tui.Key, bool) { | |||
| 14 | + | // A bare Escape is eligible for the session-tree gesture only while the | |||
| 15 | + | // main input owns the key. Any other key, modified Escape, or visible | |||
| 16 | + | // child interaction resets the pending first tap before normal routing. | |||
| 17 | + | if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() { | |||
| 18 | + | i.mu.Lock() | |||
| 19 | + | i.doubleEscape.Reset() | |||
| 20 | + | i.mu.Unlock() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Dialogs route keys before the main clipboard handler below. Resolve text | |||
| 24 | + | // here when a child interaction owns input so every editor and filter sees | |||
| 25 | + | // the same KeyPaste event as terminal-native bracketed paste. Main chat is | |||
| 26 | + | // left image-first to preserve macOS clipboard image attachments. | |||
| 27 | + | if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() { | |||
| 28 | + | resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText) | |||
| 29 | + | if err != nil { | |||
| 30 | + | i.mu.Lock() | |||
| 31 | + | i.statusErr = "clipboard paste failed: " + err.Error() | |||
| 32 | + | i.statusOK = "" | |||
| 33 | + | i.mu.Unlock() | |||
| 34 | + | return tui.Key{}, false | |||
| 35 | + | } | |||
| 36 | + | if pastedText { | |||
| 37 | + | k = resolved | |||
| 38 | + | i.mu.Lock() | |||
| 39 | + | i.statusErr = "" | |||
| 40 | + | i.statusOK = "" | |||
| 41 | + | i.mu.Unlock() | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so pressing | |||
| 46 | + | // ctrl+c then typing then ctrl+c much later doesn't quit unexpectedly. The | |||
| 47 | + | // hint message also goes stale; clear it. | |||
| 48 | + | if k.Kind != tui.KeyCtrlC { | |||
| 49 | + | i.mu.Lock() | |||
| 50 | + | if !i.lastCtrlC.IsZero() { | |||
| 51 | + | i.lastCtrlC = time.Time{} | |||
| 52 | + | if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") { | |||
| 53 | + | i.statusOK = "" | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | i.mu.Unlock() | |||
| 57 | + | } | |||
| 58 | + | return k, true | |||
| 59 | + | } | |||
M packages/agent/modes/interactive_render.go +41 -0
| @@ -99,3 +99,44 @@ func (s *latestFrameScheduler) stop() { | |||
| 99 | 99 | s.mu.Unlock() | |
| 100 | 100 | <-s.done | |
| 101 | 101 | } | |
| 102 | + | ||
| 103 | + | func (i *Interactive) requestRendererClear() { | |
| 104 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 105 | + | scheduler.request(true, false) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | if i.rend != nil { | |
| 109 | + | i.rend.Clear() | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (i *Interactive) requestRendererInvalidate() { | |
| 114 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 115 | + | scheduler.request(false, true) | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | if i.rend != nil { | |
| 119 | + | i.rend.Invalidate() | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |
| 124 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 125 | + | scheduler.requestTheme(theme) | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | if i.rend != nil { | |
| 129 | + | i.rend.SetTheme(theme) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func (i *Interactive) invalidate() { | |
| 134 | + | i.renderRevision.Add(1) | |
| 135 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler | |
| 136 | + | // owns terminal output, but scheduling it here would bypass | |
| 137 | + | // redrawMinInterval and paint every intermediate tool-event frame. | |
| 138 | + | select { | |
| 139 | + | case i.dirty <- struct{}{}: | |
| 140 | + | default: | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -99,3 +99,44 @@ func (s *latestFrameScheduler) stop() { | |||||
| 99 | s.mu.Unlock() | 99 | s.mu.Unlock() | ||
| 100 | <-s.done | 100 | <-s.done | ||
| 101 | } | 101 | } | ||
| 102 | + | ||||
| 103 | + | func (i *Interactive) requestRendererClear() { | |||
| 104 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 105 | + | scheduler.request(true, false) | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | if i.rend != nil { | |||
| 109 | + | i.rend.Clear() | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (i *Interactive) requestRendererInvalidate() { | |||
| 114 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 115 | + | scheduler.request(false, true) | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | if i.rend != nil { | |||
| 119 | + | i.rend.Invalidate() | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||
| 124 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 125 | + | scheduler.requestTheme(theme) | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | if i.rend != nil { | |||
| 129 | + | i.rend.SetTheme(theme) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (i *Interactive) invalidate() { | |||
| 134 | + | i.renderRevision.Add(1) | |||
| 135 | + | // Keep ordinary state changes on the main-loop throttle. The scheduler | |||
| 136 | + | // owns terminal output, but scheduling it here would bypass | |||
| 137 | + | // redrawMinInterval and paint every intermediate tool-event frame. | |||
| 138 | + | select { | |||
| 139 | + | case i.dirty <- struct{}{}: | |||
| 140 | + | default: | |||
| 141 | + | } | |||
| 142 | + | } | |||