detached 50fb5ef

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
13771377 }
13781378 }
13791379
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-
14211380 func (i *Interactive) cachedChatLocked(cols int) []string {
14221381 // A busy frame contains mutable streaming/tool state. Keep the stable
14231382 // transcript cache, but never return a full-frame cache entry that would
@@ -2823,49 +2782,9 @@ func (i *Interactive) restoreConfirmFocus() {
28232782 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
28242783 defer i.restoreConfirmFocus()
28252784
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
28692788 }
28702789
28712790 // Dialogs consume keys while open (except ctrl+c, which always closes them).
@@ -3920,77 +3839,6 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) {
39203839 // holds an interface, not a concrete *Interactive, so these methods
39213840 // are the only thing the manager sees.
39223841
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-
39943842 // Submit feeds text through the agent loop as if the user had typed it.
39953843 func (i *Interactive) Submit(text string) {
39963844 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 stable1381 // A busy frame contains mutable streaming/tool state. Keep the stable
1423 // transcript cache, but never return a full-frame cache entry that would1382 // 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 the2785+ var ok bool
2827- // main input owns the key. Any other key, modified Escape, or visible2786+ 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 methods3839 // 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() {
9999 s.mu.Unlock()
100100 <-s.done
101101 }
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.done100 <-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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close