detached 999d53a

Keep extension load errors in chat

commit
999d53a7fdbafc2299968995b98547b811708cc2
author
patriceckhart · · 2026-07-26 19:23 UTC
parent
2c9dbfa
signature
unsigned
refs
tag: v0.3.12
5 files · +101 -4
M docs/extensions.md +4 -2
@@ -644,8 +644,10 @@ Useful while developing an extension: edit, save, `/reload-ext`,
644644 done. Explicit `--ext` paths are remembered and reloaded alongside
645645 discovered extensions. The temporary reload status reports each load
646646 error in red, including its message, and dismisses itself after five
647-seconds. Subprocess startup and handshake errors include the exact stderr
648-log path for the failed extension.
647+seconds. Extension failures from startup and `/reload-ext` remain in the
648+scrolling chat until `/clear`, without being sent to the model or saved in
649+the session transcript. Subprocess startup and handshake errors include the
650+exact stderr log path for the failed extension.
649651
650652 ### TypeScript / Python
651653
@@ -644,8 +644,10 @@ Useful while developing an extension: edit, save, `/reload-ext`,
644 done. Explicit `--ext` paths are remembered and reloaded alongside644 done. Explicit `--ext` paths are remembered and reloaded alongside
645 discovered extensions. The temporary reload status reports each load645 discovered extensions. The temporary reload status reports each load
646 error in red, including its message, and dismisses itself after five646 error in red, including its message, and dismisses itself after five
647-seconds. Subprocess startup and handshake errors include the exact stderr647+seconds. Extension failures from startup and `/reload-ext` remain in the
648-log path for the failed extension.648+scrolling chat until `/clear`, without being sent to the model or saved in
649+the session transcript. Subprocess startup and handshake errors include the
650+exact stderr log path for the failed extension.
649 651
650 ### TypeScript / Python652 ### TypeScript / Python
651 653
M packages/agent/cli.go +4 -2
@@ -401,17 +401,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
401401 var iv *modes.Interactive
402402 extHooks := &interactiveExtHooks{ivPtr: &iv}
403403 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
404+ var startupExtensionErrors []string
404405 // --ext paths first so they win against installed extensions of
405406 // the same name (loadOne's first-write-wins semantics).
406407 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
407- fmt.Fprintln(os.Stderr, "extension load:", e)
408+ startupExtensionErrors = append(startupExtensionErrors, e.Error())
408409 }
409410 // --no-ext skips the global + project-local discovery scan;
410411 // explicit --ext paths above are still honoured so you can run
411412 // "only this extension" with --no-ext --ext ./x.
412413 if !args.NoExt {
413414 for _, e := range extMgr.Discover(ctx) {
414- fmt.Fprintln(os.Stderr, "extension load:", e)
415+ startupExtensionErrors = append(startupExtensionErrors, e.Error())
415416 }
416417 }
417418 // Wait briefly for extensions to flush their initial register_tool
@@ -1013,6 +1014,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10131014 CWD: r.CWD,
10141015 StartupContextPaths: instructionContextPaths(r.ContextFiles),
10151016 StartupExtensionNames: startupExtensionNames(extMgr.All()),
1017+ StartupExtensionErrors: startupExtensionErrors,
10161018 StartupSkillNames: startupSkillNames(startupSkills),
10171019 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
10181020 ZotHome: ZotHome(),
@@ -401,17 +401,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
401 var iv *modes.Interactive401 var iv *modes.Interactive
402 extHooks := &interactiveExtHooks{ivPtr: &iv}402 extHooks := &interactiveExtHooks{ivPtr: &iv}
403 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)403 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
404+ var startupExtensionErrors []string
404 // --ext paths first so they win against installed extensions of405 // --ext paths first so they win against installed extensions of
405 // the same name (loadOne's first-write-wins semantics).406 // the same name (loadOne's first-write-wins semantics).
406 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {407 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
407- fmt.Fprintln(os.Stderr, "extension load:", e)408+ startupExtensionErrors = append(startupExtensionErrors, e.Error())
408 }409 }
409 // --no-ext skips the global + project-local discovery scan;410 // --no-ext skips the global + project-local discovery scan;
410 // explicit --ext paths above are still honoured so you can run411 // explicit --ext paths above are still honoured so you can run
411 // "only this extension" with --no-ext --ext ./x.412 // "only this extension" with --no-ext --ext ./x.
412 if !args.NoExt {413 if !args.NoExt {
413 for _, e := range extMgr.Discover(ctx) {414 for _, e := range extMgr.Discover(ctx) {
414- fmt.Fprintln(os.Stderr, "extension load:", e)415+ startupExtensionErrors = append(startupExtensionErrors, e.Error())
415 }416 }
416 }417 }
417 // Wait briefly for extensions to flush their initial register_tool418 // Wait briefly for extensions to flush their initial register_tool
@@ -1013,6 +1014,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1013 CWD: r.CWD,1014 CWD: r.CWD,
1014 StartupContextPaths: instructionContextPaths(r.ContextFiles),1015 StartupContextPaths: instructionContextPaths(r.ContextFiles),
1015 StartupExtensionNames: startupExtensionNames(extMgr.All()),1016 StartupExtensionNames: startupExtensionNames(extMgr.All()),
1017+ StartupExtensionErrors: startupExtensionErrors,
1016 StartupSkillNames: startupSkillNames(startupSkills),1018 StartupSkillNames: startupSkillNames(startupSkills),
1017 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,1019 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1018 ZotHome: ZotHome(),1020 ZotHome: ZotHome(),
M packages/agent/modes/interactive.go +25 -0
@@ -44,6 +44,10 @@ type InteractiveConfig struct {
4444 StartupExtensionNames []string
4545 StartupSkillNames []string
4646
47+ // StartupExtensionErrors are non-fatal extension load failures collected
48+ // before the TUI starts. They are display-only and never enter the transcript.
49+ StartupExtensionErrors []string
50+
4751 // ShowInstructionsAtStartup controls whether startup resources are visible.
4852 // nil means the default, off.
4953 ShowInstructionsAtStartup *bool
@@ -282,6 +286,7 @@ type chatCacheKey struct {
282286 statusErr string
283287 help string
284288 extNotes string
289+ reloadErrors string
285290 updateAvailable bool
286291 updateCurrent string
287292 updateLatest string
@@ -486,6 +491,10 @@ type Interactive struct {
486491 // transcript) until cleared by /clear or another reset.
487492 extNotes []string
488493
494+ // reloadErrors are host-only extension reload failures. They stay in
495+ // the scrolling chat until /clear without entering the agent transcript.
496+ reloadErrors []string
497+
489498 // shellRunning is true while a !command is executing. It shares
490499 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
491500 // escape can start while one is in flight.
@@ -582,6 +591,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
582591 fileSuggest: newFileSuggester(),
583592 spin: newSpinner(cfg.Theme),
584593 inputHistoryIndex: -1,
594+ reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
585595 }
586596 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
587597 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -906,6 +916,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
906916 statusErr: i.statusErr,
907917 help: strings.Join(i.helpBlock, "\n"),
908918 extNotes: strings.Join(i.extNotes, "\n"),
919+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
909920 updateAvailable: i.updateInfo.Available,
910921 updateCurrent: i.updateInfo.Current,
911922 updateLatest: i.updateInfo.Latest,
@@ -1032,6 +1043,19 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10321043 chat = append(chat, "")
10331044 }
10341045
1046+ // Reload failures remain in the scrolling chat until /clear. They are
1047+ // host-only display state, not provider messages or persisted context.
1048+ // While the latest failure is still the temporary status error, hide its
1049+ // stored copy to avoid painting the same message twice.
1050+ reloadErrors := i.reloadErrors
1051+ if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1052+ reloadErrors = reloadErrors[:n-1]
1053+ }
1054+ if len(reloadErrors) > 0 {
1055+ chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1056+ chat = append(chat, "")
1057+ }
1058+
10351059 // Strip trailing blank rows so the chat content sits flush
10361060 // against the new "blank above status bar" row added by the
10371061 // bottom-region assembly. Build() ends every message with a
@@ -4068,6 +4092,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
40684092 i.parkedTotal = 0
40694093 i.scrollOffset = 0
40704094 i.extNotes = nil
4095+ i.reloadErrors = nil
40714096 i.view.InvalidateRenderCache()
40724097 i.mu.Unlock()
40734098 case "/help":
@@ -44,6 +44,10 @@ type InteractiveConfig struct {
44 StartupExtensionNames []string44 StartupExtensionNames []string
45 StartupSkillNames []string45 StartupSkillNames []string
46 46
47+ // StartupExtensionErrors are non-fatal extension load failures collected
48+ // before the TUI starts. They are display-only and never enter the transcript.
49+ StartupExtensionErrors []string
50+
47 // ShowInstructionsAtStartup controls whether startup resources are visible.51 // ShowInstructionsAtStartup controls whether startup resources are visible.
48 // nil means the default, off.52 // nil means the default, off.
49 ShowInstructionsAtStartup *bool53 ShowInstructionsAtStartup *bool
@@ -282,6 +286,7 @@ type chatCacheKey struct {
282 statusErr string286 statusErr string
283 help string287 help string
284 extNotes string288 extNotes string
289+ reloadErrors string
285 updateAvailable bool290 updateAvailable bool
286 updateCurrent string291 updateCurrent string
287 updateLatest string292 updateLatest string
@@ -486,6 +491,10 @@ type Interactive struct {
486 // transcript) until cleared by /clear or another reset.491 // transcript) until cleared by /clear or another reset.
487 extNotes []string492 extNotes []string
488 493
494+ // reloadErrors are host-only extension reload failures. They stay in
495+ // the scrolling chat until /clear without entering the agent transcript.
496+ reloadErrors []string
497+
489 // shellRunning is true while a !command is executing. It shares498 // shellRunning is true while a !command is executing. It shares
490 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell499 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
491 // escape can start while one is in flight.500 // escape can start while one is in flight.
@@ -582,6 +591,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
582 fileSuggest: newFileSuggester(),591 fileSuggest: newFileSuggester(),
583 spin: newSpinner(cfg.Theme),592 spin: newSpinner(cfg.Theme),
584 inputHistoryIndex: -1,593 inputHistoryIndex: -1,
594+ reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
585 }595 }
586 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)596 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
587 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)597 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
@@ -906,6 +916,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
906 statusErr: i.statusErr,916 statusErr: i.statusErr,
907 help: strings.Join(i.helpBlock, "\n"),917 help: strings.Join(i.helpBlock, "\n"),
908 extNotes: strings.Join(i.extNotes, "\n"),918 extNotes: strings.Join(i.extNotes, "\n"),
919+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
909 updateAvailable: i.updateInfo.Available,920 updateAvailable: i.updateInfo.Available,
910 updateCurrent: i.updateInfo.Current,921 updateCurrent: i.updateInfo.Current,
911 updateLatest: i.updateInfo.Latest,922 updateLatest: i.updateInfo.Latest,
@@ -1032,6 +1043,19 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1032 chat = append(chat, "")1043 chat = append(chat, "")
1033 }1044 }
1034 1045
1046+ // Reload failures remain in the scrolling chat until /clear. They are
1047+ // host-only display state, not provider messages or persisted context.
1048+ // While the latest failure is still the temporary status error, hide its
1049+ // stored copy to avoid painting the same message twice.
1050+ reloadErrors := i.reloadErrors
1051+ if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1052+ reloadErrors = reloadErrors[:n-1]
1053+ }
1054+ if len(reloadErrors) > 0 {
1055+ chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1056+ chat = append(chat, "")
1057+ }
1058+
1035 // Strip trailing blank rows so the chat content sits flush1059 // Strip trailing blank rows so the chat content sits flush
1036 // against the new "blank above status bar" row added by the1060 // against the new "blank above status bar" row added by the
1037 // bottom-region assembly. Build() ends every message with a1061 // bottom-region assembly. Build() ends every message with a
@@ -4068,6 +4092,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4068 i.parkedTotal = 04092 i.parkedTotal = 0
4069 i.scrollOffset = 04093 i.scrollOffset = 0
4070 i.extNotes = nil4094 i.extNotes = nil
4095+ i.reloadErrors = nil
4071 i.view.InvalidateRenderCache()4096 i.view.InvalidateRenderCache()
4072 i.mu.Unlock()4097 i.mu.Unlock()
4073 case "/help":4098 case "/help":
M packages/agent/modes/reload_ext.go +26 -0
@@ -7,6 +7,7 @@ import (
77 "time"
88
99 "github.com/patriceckhart/zot/packages/agent/extensions"
10+ "github.com/patriceckhart/zot/packages/tui"
1011 )
1112
1213 const reloadStatusDuration = 5 * time.Second
@@ -36,6 +37,7 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 {
3637 if failed {
3738 i.statusOK = ""
3839 i.statusErr = msg
40+ i.reloadErrors = append(i.reloadErrors, msg)
3941 return i.reloadStatusSeq
4042 }
4143 i.statusOK = msg
@@ -43,6 +45,30 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 {
4345 return i.reloadStatusSeq
4446 }
4547
48+func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
49+ const marker = "✖ "
50+ const indent = " "
51+ wrapWidth := width - len([]rune(marker))
52+ if wrapWidth < 8 {
53+ wrapWidth = 8
54+ }
55+ var out []string
56+ for _, msg := range errors {
57+ wrapped := tui.WrapANSILine(msg, wrapWidth)
58+ if len(wrapped) == 0 {
59+ wrapped = []string{""}
60+ }
61+ for idx, line := range wrapped {
62+ prefix := marker
63+ if idx > 0 {
64+ prefix = indent
65+ }
66+ out = append(out, theme.FG256(theme.Error, prefix+line))
67+ }
68+ }
69+ return out
70+}
71+
4672 func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool {
4773 i.mu.Lock()
4874 defer i.mu.Unlock()
@@ -7,6 +7,7 @@ import (
7 "time"7 "time"
8 8
9 "github.com/patriceckhart/zot/packages/agent/extensions"9 "github.com/patriceckhart/zot/packages/agent/extensions"
10+ "github.com/patriceckhart/zot/packages/tui"
10 )11 )
11 12
12 const reloadStatusDuration = 5 * time.Second13 const reloadStatusDuration = 5 * time.Second
@@ -36,6 +37,7 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 {
36 if failed {37 if failed {
37 i.statusOK = ""38 i.statusOK = ""
38 i.statusErr = msg39 i.statusErr = msg
40+ i.reloadErrors = append(i.reloadErrors, msg)
39 return i.reloadStatusSeq41 return i.reloadStatusSeq
40 }42 }
41 i.statusOK = msg43 i.statusOK = msg
@@ -43,6 +45,30 @@ func (i *Interactive) setReloadStatus(msg string, failed bool) uint64 {
43 return i.reloadStatusSeq45 return i.reloadStatusSeq
44 }46 }
45 47
48+func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
49+ const marker = "✖ "
50+ const indent = " "
51+ wrapWidth := width - len([]rune(marker))
52+ if wrapWidth < 8 {
53+ wrapWidth = 8
54+ }
55+ var out []string
56+ for _, msg := range errors {
57+ wrapped := tui.WrapANSILine(msg, wrapWidth)
58+ if len(wrapped) == 0 {
59+ wrapped = []string{""}
60+ }
61+ for idx, line := range wrapped {
62+ prefix := marker
63+ if idx > 0 {
64+ prefix = indent
65+ }
66+ out = append(out, theme.FG256(theme.Error, prefix+line))
67+ }
68+ }
69+ return out
70+}
71+
46 func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool {72 func (i *Interactive) clearReloadStatus(seq uint64, msg string, failed bool) bool {
47 i.mu.Lock()73 i.mu.Lock()
48 defer i.mu.Unlock()74 defer i.mu.Unlock()
M packages/agent/modes/reload_ext_test.go +42 -0
@@ -6,6 +6,8 @@ import (
66 "testing"
77
88 "github.com/patriceckhart/zot/packages/agent/extensions"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
911 )
1012
1113 func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
@@ -49,12 +51,52 @@ func TestSetReloadStatusUsesErrorChannelForFailures(t *testing.T) {
4951 if i.statusErr != "broken extension" || i.statusOK != "" {
5052 t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)
5153 }
54+ if len(i.reloadErrors) != 1 || i.reloadErrors[0] != "broken extension" {
55+ t.Fatalf("reload errors = %q, want persistent failure", i.reloadErrors)
56+ }
5257 i.setReloadStatus("reload complete", false)
5358 if i.statusOK != "reload complete" || i.statusErr != "" {
5459 t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)
5560 }
5661 }
5762
63+func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T) {
64+ agent := core.NewAgent(nil, "", "", nil)
65+ i := NewInteractive(InteractiveConfig{
66+ Agent: agent,
67+ Theme: tui.Theme{Error: 1},
68+ StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},
69+ })
70+
71+ chat := strings.Join(i.buildChatLocked(30), "\n")
72+ if !strings.Contains(chat, "startup failed") || !strings.Contains(chat, "/tmp/startup.log") {
73+ t.Fatalf("chat does not contain startup extension failure: %q", chat)
74+ }
75+ if got := len(agent.Messages()); got != 0 {
76+ t.Fatalf("agent context contains %d startup error message(s), want none", got)
77+ }
78+}
79+
80+func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) {
81+ agent := core.NewAgent(nil, "", "", nil)
82+ i := NewInteractive(InteractiveConfig{
83+ Agent: agent,
84+ Theme: tui.Theme{Error: 1},
85+ })
86+ seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)
87+ if !i.clearReloadStatus(seq, i.statusErr, true) {
88+ t.Fatal("temporary reload status was not cleared")
89+ }
90+
91+ chat := strings.Join(i.buildChatLocked(30), "\n")
92+ if !strings.Contains(chat, "broken extension") || !strings.Contains(chat, "/tmp/ext.log") {
93+ t.Fatalf("chat does not contain persistent reload failure: %q", chat)
94+ }
95+ if got := len(agent.Messages()); got != 0 {
96+ t.Fatalf("agent context contains %d reload error message(s), want none", got)
97+ }
98+}
99+
58100 func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) {
59101 i := &Interactive{statusErr: "newer error", statusOK: "newer success"}
60102 if i.clearReloadStatus(0, "old error", true) {
@@ -6,6 +6,8 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/patriceckhart/zot/packages/agent/extensions"8 "github.com/patriceckhart/zot/packages/agent/extensions"
9+ "github.com/patriceckhart/zot/packages/core"
10+ "github.com/patriceckhart/zot/packages/tui"
9 )11 )
10 12
11 func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {13 func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
@@ -49,12 +51,52 @@ func TestSetReloadStatusUsesErrorChannelForFailures(t *testing.T) {
49 if i.statusErr != "broken extension" || i.statusOK != "" {51 if i.statusErr != "broken extension" || i.statusOK != "" {
50 t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)52 t.Fatalf("failed reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)
51 }53 }
54+ if len(i.reloadErrors) != 1 || i.reloadErrors[0] != "broken extension" {
55+ t.Fatalf("reload errors = %q, want persistent failure", i.reloadErrors)
56+ }
52 i.setReloadStatus("reload complete", false)57 i.setReloadStatus("reload complete", false)
53 if i.statusOK != "reload complete" || i.statusErr != "" {58 if i.statusOK != "reload complete" || i.statusErr != "" {
54 t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)59 t.Fatalf("successful reload status used wrong channel: err=%q ok=%q", i.statusErr, i.statusOK)
55 }60 }
56 }61 }
57 62
63+func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T) {
64+ agent := core.NewAgent(nil, "", "", nil)
65+ i := NewInteractive(InteractiveConfig{
66+ Agent: agent,
67+ Theme: tui.Theme{Error: 1},
68+ StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},
69+ })
70+
71+ chat := strings.Join(i.buildChatLocked(30), "\n")
72+ if !strings.Contains(chat, "startup failed") || !strings.Contains(chat, "/tmp/startup.log") {
73+ t.Fatalf("chat does not contain startup extension failure: %q", chat)
74+ }
75+ if got := len(agent.Messages()); got != 0 {
76+ t.Fatalf("agent context contains %d startup error message(s), want none", got)
77+ }
78+}
79+
80+func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) {
81+ agent := core.NewAgent(nil, "", "", nil)
82+ i := NewInteractive(InteractiveConfig{
83+ Agent: agent,
84+ Theme: tui.Theme{Error: 1},
85+ })
86+ seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)
87+ if !i.clearReloadStatus(seq, i.statusErr, true) {
88+ t.Fatal("temporary reload status was not cleared")
89+ }
90+
91+ chat := strings.Join(i.buildChatLocked(30), "\n")
92+ if !strings.Contains(chat, "broken extension") || !strings.Contains(chat, "/tmp/ext.log") {
93+ t.Fatalf("chat does not contain persistent reload failure: %q", chat)
94+ }
95+ if got := len(agent.Messages()); got != 0 {
96+ t.Fatalf("agent context contains %d reload error message(s), want none", got)
97+ }
98+}
99+
58 func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) {100 func TestClearReloadStatusDoesNotClearNewerStatus(t *testing.T) {
59 i := &Interactive{statusErr: "newer error", statusOK: "newer success"}101 i := &Interactive{statusErr: "newer error", statusOK: "newer success"}
60 if i.clearReloadStatus(0, "old error", true) {102 if i.clearReloadStatus(0, "old error", true) {

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