detached 17dec30

interactive: load resumed sessions asynchronously

commit
17dec30b363de19d174899f055349155dc318274
author
patriceckhart · · 2026-05-04 13:47 UTC
parent
aceaffd
signature
unsigned
5 files · +331 -37
M internal/agent/cli.go +51 -21
@@ -111,6 +111,26 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
111111 // the events that have a clear extension-facing meaning are
112112 // forwarded; internal-only ones (text_delta, tool_progress) are
113113 // dropped to keep the per-extension stream sane.
114+func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
115+ if keepTail <= 0 || len(msgs) <= keepTail {
116+ return msgs
117+ }
118+ var out []provider.Message
119+ start := len(msgs) - keepTail
120+ // Preserve the synthetic compaction summary when present so an
121+ // already-compacted session stays compacted after resume.
122+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
123+ out = append(out, msgs[0])
124+ }
125+ // Avoid hydrating a tail that starts with orphan tool_result rows;
126+ // provider APIs require those to be paired with an earlier tool_use.
127+ for start < len(msgs) && msgs[start].Role == provider.RoleTool {
128+ start++
129+ }
130+ out = append(out, msgs[start:]...)
131+ return out
132+}
133+
114134 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
115135 if mgr == nil {
116136 return
@@ -565,6 +585,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
565585 if err != nil {
566586 return err
567587 }
588+ fullMsgCount := len(msgs)
589+ msgs = trimMessagesForResume(msgs, 20)
568590 persistMu.Lock()
569591 // Flush any unsaved messages to the old session before swapping.
570592 // Per-message persistence keeps sessBaselineMsgs current, so
@@ -580,7 +602,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
580602 if usage, uerr := core.SessionUsage(path); uerr == nil {
581603 currentAg.SeedCost(usage)
582604 }
583- sessBaselineMsgs = len(msgs)
605+ // The live agent only receives a compact resume window, but
606+ // the session file remains intact. Keep the persistence
607+ // baseline at the original on-disk message count so future
608+ // turns append after the full session instead of duplicating
609+ // the hydrated tail.
610+ sessBaselineMsgs = fullMsgCount
584611 persistMu.Unlock()
585612 return nil
586613 }
@@ -635,27 +662,30 @@ func runInteractive(ctx context.Context, args Args, version string) error {
635662 }
636663 }()
637664
665+ initialCfg, _ := LoadConfig()
638666 iv = modes.NewInteractive(modes.InteractiveConfig{
639- Terminal: term,
640- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
641- Model: r.Model,
642- Provider: r.Provider,
643- AuthMethod: r.AuthMethod,
644- BaseURL: r.BaseURL,
645- Reasoning: r.Reasoning,
646- SystemPrompt: r.SystemPrompt,
647- Tools: r.ToolRegistry,
648- MaxSteps: r.MaxSteps,
649- CWD: r.CWD,
650- ZotHome: ZotHome(),
651- Version: version,
652- UpdateInfoChan: updateCh,
653- Sandbox: sharedSandbox,
654- Agent: ag,
655- InitialInput: args.Prompt,
656- AuthManager: mgr,
657- BuildAgent: buildAgent,
658- BuildAgentFor: buildAgentFor,
667+ Terminal: term,
668+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
669+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
670+ SettingsStore: configSettingsStore{},
671+ Model: r.Model,
672+ Provider: r.Provider,
673+ AuthMethod: r.AuthMethod,
674+ BaseURL: r.BaseURL,
675+ Reasoning: r.Reasoning,
676+ SystemPrompt: r.SystemPrompt,
677+ Tools: r.ToolRegistry,
678+ MaxSteps: r.MaxSteps,
679+ CWD: r.CWD,
680+ ZotHome: ZotHome(),
681+ Version: version,
682+ UpdateInfoChan: updateCh,
683+ Sandbox: sharedSandbox,
684+ Agent: ag,
685+ InitialInput: args.Prompt,
686+ AuthManager: mgr,
687+ BuildAgent: buildAgent,
688+ BuildAgentFor: buildAgentFor,
659689 LoggedInProviders: func() []string {
660690 var out []string
661691 for _, p := range []string{"anthropic", "openai"} {
@@ -111,6 +111,26 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
111 // the events that have a clear extension-facing meaning are111 // the events that have a clear extension-facing meaning are
112 // forwarded; internal-only ones (text_delta, tool_progress) are112 // forwarded; internal-only ones (text_delta, tool_progress) are
113 // dropped to keep the per-extension stream sane.113 // dropped to keep the per-extension stream sane.
114+func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
115+ if keepTail <= 0 || len(msgs) <= keepTail {
116+ return msgs
117+ }
118+ var out []provider.Message
119+ start := len(msgs) - keepTail
120+ // Preserve the synthetic compaction summary when present so an
121+ // already-compacted session stays compacted after resume.
122+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
123+ out = append(out, msgs[0])
124+ }
125+ // Avoid hydrating a tail that starts with orphan tool_result rows;
126+ // provider APIs require those to be paired with an earlier tool_use.
127+ for start < len(msgs) && msgs[start].Role == provider.RoleTool {
128+ start++
129+ }
130+ out = append(out, msgs[start:]...)
131+ return out
132+}
133+
114 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {134 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
115 if mgr == nil {135 if mgr == nil {
116 return136 return
@@ -565,6 +585,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
565 if err != nil {585 if err != nil {
566 return err586 return err
567 }587 }
588+ fullMsgCount := len(msgs)
589+ msgs = trimMessagesForResume(msgs, 20)
568 persistMu.Lock()590 persistMu.Lock()
569 // Flush any unsaved messages to the old session before swapping.591 // Flush any unsaved messages to the old session before swapping.
570 // Per-message persistence keeps sessBaselineMsgs current, so592 // Per-message persistence keeps sessBaselineMsgs current, so
@@ -580,7 +602,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
580 if usage, uerr := core.SessionUsage(path); uerr == nil {602 if usage, uerr := core.SessionUsage(path); uerr == nil {
581 currentAg.SeedCost(usage)603 currentAg.SeedCost(usage)
582 }604 }
583- sessBaselineMsgs = len(msgs)605+ // The live agent only receives a compact resume window, but
606+ // the session file remains intact. Keep the persistence
607+ // baseline at the original on-disk message count so future
608+ // turns append after the full session instead of duplicating
609+ // the hydrated tail.
610+ sessBaselineMsgs = fullMsgCount
584 persistMu.Unlock()611 persistMu.Unlock()
585 return nil612 return nil
586 }613 }
@@ -635,27 +662,30 @@ func runInteractive(ctx context.Context, args Args, version string) error {
635 }662 }
636 }()663 }()
637 664
665+ initialCfg, _ := LoadConfig()
638 iv = modes.NewInteractive(modes.InteractiveConfig{666 iv = modes.NewInteractive(modes.InteractiveConfig{
639- Terminal: term,667+ Terminal: term,
640- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),668+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
641- Model: r.Model,669+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
642- Provider: r.Provider,670+ SettingsStore: configSettingsStore{},
643- AuthMethod: r.AuthMethod,671+ Model: r.Model,
644- BaseURL: r.BaseURL,672+ Provider: r.Provider,
645- Reasoning: r.Reasoning,673+ AuthMethod: r.AuthMethod,
646- SystemPrompt: r.SystemPrompt,674+ BaseURL: r.BaseURL,
647- Tools: r.ToolRegistry,675+ Reasoning: r.Reasoning,
648- MaxSteps: r.MaxSteps,676+ SystemPrompt: r.SystemPrompt,
649- CWD: r.CWD,677+ Tools: r.ToolRegistry,
650- ZotHome: ZotHome(),678+ MaxSteps: r.MaxSteps,
651- Version: version,679+ CWD: r.CWD,
652- UpdateInfoChan: updateCh,680+ ZotHome: ZotHome(),
653- Sandbox: sharedSandbox,681+ Version: version,
654- Agent: ag,682+ UpdateInfoChan: updateCh,
655- InitialInput: args.Prompt,683+ Sandbox: sharedSandbox,
656- AuthManager: mgr,684+ Agent: ag,
657- BuildAgent: buildAgent,685+ InitialInput: args.Prompt,
658- BuildAgentFor: buildAgentFor,686+ AuthManager: mgr,
687+ BuildAgent: buildAgent,
688+ BuildAgentFor: buildAgentFor,
659 LoggedInProviders: func() []string {689 LoggedInProviders: func() []string {
660 var out []string690 var out []string
661 for _, p := range []string{"anthropic", "openai"} {691 for _, p := range []string{"anthropic", "openai"} {
M internal/agent/config.go +6 -0
@@ -21,6 +21,12 @@ type Config struct {
2121 Reasoning string `json:"reasoning"`
2222 Theme string `json:"theme"`
2323
24+ // InlineImagesEnabled controls whether zot draws screenshots inline
25+ // when the terminal supports an image protocol. nil/missing means
26+ // auto (enabled when supported); false disables; true forces the
27+ // detected protocol when available.
28+ InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
29+
2430 // LastChangelogShown is the version whose release-notes
2531 // dialog the user has already seen. When the running binary's
2632 // version differs, the next interactive run shows the
@@ -21,6 +21,12 @@ type Config struct {
21 Reasoning string `json:"reasoning"`21 Reasoning string `json:"reasoning"`
22 Theme string `json:"theme"`22 Theme string `json:"theme"`
23 23
24+ // InlineImagesEnabled controls whether zot draws screenshots inline
25+ // when the terminal supports an image protocol. nil/missing means
26+ // auto (enabled when supported); false disables; true forces the
27+ // detected protocol when available.
28+ InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
29+
24 // LastChangelogShown is the version whose release-notes30 // LastChangelogShown is the version whose release-notes
25 // dialog the user has already seen. When the running binary's31 // dialog the user has already seen. When the running binary's
26 // version differs, the next interactive run shows the32 // version differs, the next interactive run shows the
M internal/agent/modes/interactive.go +152 -16
@@ -34,6 +34,12 @@ type InteractiveConfig struct {
3434 MaxSteps int
3535 CWD string
3636
37+ // InlineImagesEnabled overrides terminal image rendering. nil means
38+ // auto-detect and render when supported; false disables; true uses
39+ // the detected protocol when available.
40+ InlineImagesEnabled *bool
41+ SettingsStore SettingsStore
42+
3743 // Agent is optional. If nil, zot opens without credentials; the
3844 // user must /login before they can prompt.
3945 Agent *core.Agent
@@ -168,6 +174,11 @@ type chatCacheKey struct {
168174 expandAll bool
169175 }
170176
177+// SettingsStore persists user-toggleable settings surfaced by /settings.
178+type SettingsStore interface {
179+ SetInlineImages(enabled bool) error
180+}
181+
171182 type Interactive struct {
172183 cfg InteractiveConfig
173184 view *tui.View
@@ -261,6 +272,7 @@ type Interactive struct {
261272 confirmDialog *confirmDialog
262273 logoutDialog *logoutDialog
263274 telegramDialog *telegramDialog
275+ settingsDialog *settingsDialog
264276 telegramBridge *telegram.Bridge
265277 sessionOpsDialog *sessionOpsDialog
266278 sessionTreeDialog *sessionTreeDialog
@@ -302,6 +314,11 @@ type Interactive struct {
302314 // Notify / Display. They live above the editor (just below the
303315 // transcript) until cleared by /clear or another reset.
304316 extNotes []string
317+
318+ // sessionLoading is true while a /sessions selection is being read
319+ // on a background goroutine. Keeping this off the input goroutine
320+ // lets ctrl+c/exit remain responsive for very large JSONL sessions.
321+ sessionLoading bool
305322 }
306323
307324 // welcomeVersionDuration is how long the welcome banner shows the
@@ -315,7 +332,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
315332 cfg: cfg,
316333 view: &tui.View{
317334 Theme: cfg.Theme,
318- ImageProto: tui.DetectImageProtocol(),
335+ ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
319336 },
320337 // Prompt is the standard half-block accent bar used by chat
321338 // speaker labels too, so the input gutter matches the rest
@@ -334,6 +351,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
334351 confirmDialog: newConfirmDialog(),
335352 logoutDialog: newLogoutDialog(),
336353 telegramDialog: newTelegramDialog(),
354+ settingsDialog: newSettingsDialog(),
337355 sessionOpsDialog: newSessionOpsDialog(),
338356 sessionTreeDialog: newSessionTreeDialog(),
339357 extPanel: newExtPanelDialog(),
@@ -856,6 +874,8 @@ func (i *Interactive) redraw() {
856874 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
857875 case i.telegramDialog.Active():
858876 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
877+ case i.settingsDialog.Active():
878+ dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
859879 case i.sessionOpsDialog.Active():
860880 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
861881 case i.sessionTreeDialog.Active():
@@ -1450,6 +1470,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
14501470 i.invalidate()
14511471 return false
14521472 }
1473+ if i.settingsDialog.Active() {
1474+ if k.Kind == tui.KeyCtrlC {
1475+ i.settingsDialog.Close()
1476+ i.invalidate()
1477+ return false
1478+ }
1479+ act := i.settingsDialog.HandleKey(k)
1480+ if act.Toggle {
1481+ i.applySettingToggle(act.Key, act.Value)
1482+ }
1483+ i.invalidate()
1484+ return false
1485+ }
14531486 if i.sessionOpsDialog.Active() {
14541487 if k.Kind == tui.KeyCtrlC {
14551488 i.sessionOpsDialog.Close()
@@ -1546,6 +1579,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
15461579 // Global keys.
15471580 switch k.Kind {
15481581 case tui.KeyCtrlC:
1582+ i.mu.Lock()
1583+ loadingSession := i.sessionLoading
1584+ i.mu.Unlock()
1585+ if loadingSession {
1586+ return true
1587+ }
15491588 // While busy: do NOT cancel the turn. ctrl+c during a
15501589 // running turn is almost always reflex muscle memory
15511590 // ("be quiet" in a shell) rather than a deliberate
@@ -2101,6 +2140,81 @@ func (i *Interactive) ClosePanel(extName, panelID string) {
21012140 }
21022141 }
21032142
2143+func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2144+ detected := tui.DetectImageProtocol()
2145+ if override == nil {
2146+ return detected
2147+ }
2148+ if !*override {
2149+ return tui.ImageProtocolNone
2150+ }
2151+ return detected
2152+}
2153+
2154+func imageProtocolName(p tui.ImageProtocol) string {
2155+ switch p {
2156+ case tui.ImageProtocolKitty:
2157+ return "kitty/ghostty"
2158+ case tui.ImageProtocolITerm2:
2159+ return "iTerm2"
2160+ default:
2161+ return "none"
2162+ }
2163+}
2164+
2165+func onOff(v bool) string {
2166+ if v {
2167+ return "enabled"
2168+ }
2169+ return "disabled"
2170+}
2171+
2172+func (i *Interactive) openSettingsDialog() {
2173+ detected := tui.DetectImageProtocol()
2174+ imgEnabled := detected != tui.ImageProtocolNone
2175+ if i.cfg.InlineImagesEnabled != nil {
2176+ imgEnabled = *i.cfg.InlineImagesEnabled
2177+ }
2178+ imgDisabled := detected == tui.ImageProtocolNone
2179+ imgHint := ""
2180+ if imgDisabled {
2181+ imgEnabled = false
2182+ imgHint = "this terminal does not support inline images"
2183+ } else {
2184+ imgHint = "terminal supports " + imageProtocolName(detected)
2185+ }
2186+ i.settingsDialog.Open([]settingsItem{{
2187+ key: "inline_images_enabled",
2188+ label: "render images when supported",
2189+ desc: "draw screenshots inline instead of showing a text placeholder",
2190+ value: imgEnabled,
2191+ disabled: imgDisabled,
2192+ hint: imgHint,
2193+ }})
2194+}
2195+
2196+func (i *Interactive) applySettingToggle(key string, value bool) {
2197+ switch key {
2198+ case "inline_images_enabled":
2199+ val := value
2200+ i.cfg.InlineImagesEnabled = &val
2201+ if i.cfg.SettingsStore != nil {
2202+ if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
2203+ i.mu.Lock()
2204+ i.statusErr = "settings: " + err.Error()
2205+ i.mu.Unlock()
2206+ return
2207+ }
2208+ }
2209+ i.mu.Lock()
2210+ i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
2211+ i.view.InvalidateRenderCache()
2212+ i.statusOK = "inline image rendering " + onOff(value)
2213+ i.statusErr = ""
2214+ i.mu.Unlock()
2215+ }
2216+}
2217+
21042218 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
21052219 parts := strings.Fields(cmd)
21062220 switch parts[0] {
@@ -2154,6 +2268,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
21542268 }
21552269 i.modelDialog.Open(i.cfg.Model, loggedIn)
21562270 }
2271+ case "/settings":
2272+ i.mu.Lock()
2273+ i.statusErr = "/settings is temporarily disabled"
2274+ i.statusOK = ""
2275+ i.mu.Unlock()
21572276 case "/sessions":
21582277 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
21592278 case "/jump":
@@ -2586,26 +2705,43 @@ func (i *Interactive) applySessionSelection(path string) {
25862705 i.mu.Unlock()
25872706 return
25882707 }
2589- if err := i.cfg.LoadSession(path); err != nil {
2590- i.mu.Lock()
2591- i.statusErr = err.Error()
2708+ i.mu.Lock()
2709+ if i.sessionLoading {
2710+ i.statusErr = "already resuming a session"
25922711 i.mu.Unlock()
2712+ i.invalidate()
25932713 return
25942714 }
2595-
2596- i.mu.Lock()
2597- i.statusOK = "resumed session: " + path
2715+ i.sessionLoading = true
2716+ i.statusOK = "resuming session: " + path
25982717 i.statusErr = ""
2599- i.parkedTurn = 0
2600- i.parkedTotal = 0
2601- i.view.InvalidateRenderCache()
2602- if i.agent != nil {
2603- i.view.Messages = i.agent.Messages()
2604- i.cumUsage = i.agent.Cost()
2605- }
26062718 i.mu.Unlock()
2719+ i.invalidate()
26072720
2608- i.scrollToBottom()
2721+ go func() {
2722+ err := i.cfg.LoadSession(path)
2723+ i.mu.Lock()
2724+ defer i.mu.Unlock()
2725+ i.sessionLoading = false
2726+ if err != nil {
2727+ i.statusErr = err.Error()
2728+ i.statusOK = ""
2729+ i.invalidate()
2730+ return
2731+ }
2732+ i.statusOK = "resumed session: " + path
2733+ i.statusErr = ""
2734+ i.parkedTurn = 0
2735+ i.parkedTotal = 0
2736+ i.scrollOffset = 0
2737+ i.extNotes = nil
2738+ i.view.InvalidateRenderCache()
2739+ if i.agent != nil {
2740+ i.view.Messages = i.agent.Messages()
2741+ i.cumUsage = i.agent.Cost()
2742+ }
2743+ i.invalidate()
2744+ }()
26092745 }
26102746
26112747 // scrollToLastTurn parks the viewport at the most recent user turn,
@@ -2971,7 +3107,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
29713107 if payloadTooLarge {
29723108 i.statusErr = ""
29733109 i.queued = append([]string{prompt}, i.queued...)
2974- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large — condensing history before retrying…"))
3110+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
29753111 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
29763112 }
29773113 // Persist the assistant's reply (and every tool row before
@@ -34,6 +34,12 @@ type InteractiveConfig struct {
34 MaxSteps int34 MaxSteps int
35 CWD string35 CWD string
36 36
37+ // InlineImagesEnabled overrides terminal image rendering. nil means
38+ // auto-detect and render when supported; false disables; true uses
39+ // the detected protocol when available.
40+ InlineImagesEnabled *bool
41+ SettingsStore SettingsStore
42+
37 // Agent is optional. If nil, zot opens without credentials; the43 // Agent is optional. If nil, zot opens without credentials; the
38 // user must /login before they can prompt.44 // user must /login before they can prompt.
39 Agent *core.Agent45 Agent *core.Agent
@@ -168,6 +174,11 @@ type chatCacheKey struct {
168 expandAll bool174 expandAll bool
169 }175 }
170 176
177+// SettingsStore persists user-toggleable settings surfaced by /settings.
178+type SettingsStore interface {
179+ SetInlineImages(enabled bool) error
180+}
181+
171 type Interactive struct {182 type Interactive struct {
172 cfg InteractiveConfig183 cfg InteractiveConfig
173 view *tui.View184 view *tui.View
@@ -261,6 +272,7 @@ type Interactive struct {
261 confirmDialog *confirmDialog272 confirmDialog *confirmDialog
262 logoutDialog *logoutDialog273 logoutDialog *logoutDialog
263 telegramDialog *telegramDialog274 telegramDialog *telegramDialog
275+ settingsDialog *settingsDialog
264 telegramBridge *telegram.Bridge276 telegramBridge *telegram.Bridge
265 sessionOpsDialog *sessionOpsDialog277 sessionOpsDialog *sessionOpsDialog
266 sessionTreeDialog *sessionTreeDialog278 sessionTreeDialog *sessionTreeDialog
@@ -302,6 +314,11 @@ type Interactive struct {
302 // Notify / Display. They live above the editor (just below the314 // Notify / Display. They live above the editor (just below the
303 // transcript) until cleared by /clear or another reset.315 // transcript) until cleared by /clear or another reset.
304 extNotes []string316 extNotes []string
317+
318+ // sessionLoading is true while a /sessions selection is being read
319+ // on a background goroutine. Keeping this off the input goroutine
320+ // lets ctrl+c/exit remain responsive for very large JSONL sessions.
321+ sessionLoading bool
305 }322 }
306 323
307 // welcomeVersionDuration is how long the welcome banner shows the324 // welcomeVersionDuration is how long the welcome banner shows the
@@ -315,7 +332,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
315 cfg: cfg,332 cfg: cfg,
316 view: &tui.View{333 view: &tui.View{
317 Theme: cfg.Theme,334 Theme: cfg.Theme,
318- ImageProto: tui.DetectImageProtocol(),335+ ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
319 },336 },
320 // Prompt is the standard half-block accent bar used by chat337 // Prompt is the standard half-block accent bar used by chat
321 // speaker labels too, so the input gutter matches the rest338 // speaker labels too, so the input gutter matches the rest
@@ -334,6 +351,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
334 confirmDialog: newConfirmDialog(),351 confirmDialog: newConfirmDialog(),
335 logoutDialog: newLogoutDialog(),352 logoutDialog: newLogoutDialog(),
336 telegramDialog: newTelegramDialog(),353 telegramDialog: newTelegramDialog(),
354+ settingsDialog: newSettingsDialog(),
337 sessionOpsDialog: newSessionOpsDialog(),355 sessionOpsDialog: newSessionOpsDialog(),
338 sessionTreeDialog: newSessionTreeDialog(),356 sessionTreeDialog: newSessionTreeDialog(),
339 extPanel: newExtPanelDialog(),357 extPanel: newExtPanelDialog(),
@@ -856,6 +874,8 @@ func (i *Interactive) redraw() {
856 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)874 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
857 case i.telegramDialog.Active():875 case i.telegramDialog.Active():
858 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)876 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
877+ case i.settingsDialog.Active():
878+ dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
859 case i.sessionOpsDialog.Active():879 case i.sessionOpsDialog.Active():
860 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)880 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
861 case i.sessionTreeDialog.Active():881 case i.sessionTreeDialog.Active():
@@ -1450,6 +1470,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1450 i.invalidate()1470 i.invalidate()
1451 return false1471 return false
1452 }1472 }
1473+ if i.settingsDialog.Active() {
1474+ if k.Kind == tui.KeyCtrlC {
1475+ i.settingsDialog.Close()
1476+ i.invalidate()
1477+ return false
1478+ }
1479+ act := i.settingsDialog.HandleKey(k)
1480+ if act.Toggle {
1481+ i.applySettingToggle(act.Key, act.Value)
1482+ }
1483+ i.invalidate()
1484+ return false
1485+ }
1453 if i.sessionOpsDialog.Active() {1486 if i.sessionOpsDialog.Active() {
1454 if k.Kind == tui.KeyCtrlC {1487 if k.Kind == tui.KeyCtrlC {
1455 i.sessionOpsDialog.Close()1488 i.sessionOpsDialog.Close()
@@ -1546,6 +1579,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1546 // Global keys.1579 // Global keys.
1547 switch k.Kind {1580 switch k.Kind {
1548 case tui.KeyCtrlC:1581 case tui.KeyCtrlC:
1582+ i.mu.Lock()
1583+ loadingSession := i.sessionLoading
1584+ i.mu.Unlock()
1585+ if loadingSession {
1586+ return true
1587+ }
1549 // While busy: do NOT cancel the turn. ctrl+c during a1588 // While busy: do NOT cancel the turn. ctrl+c during a
1550 // running turn is almost always reflex muscle memory1589 // running turn is almost always reflex muscle memory
1551 // ("be quiet" in a shell) rather than a deliberate1590 // ("be quiet" in a shell) rather than a deliberate
@@ -2101,6 +2140,81 @@ func (i *Interactive) ClosePanel(extName, panelID string) {
2101 }2140 }
2102 }2141 }
2103 2142
2143+func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2144+ detected := tui.DetectImageProtocol()
2145+ if override == nil {
2146+ return detected
2147+ }
2148+ if !*override {
2149+ return tui.ImageProtocolNone
2150+ }
2151+ return detected
2152+}
2153+
2154+func imageProtocolName(p tui.ImageProtocol) string {
2155+ switch p {
2156+ case tui.ImageProtocolKitty:
2157+ return "kitty/ghostty"
2158+ case tui.ImageProtocolITerm2:
2159+ return "iTerm2"
2160+ default:
2161+ return "none"
2162+ }
2163+}
2164+
2165+func onOff(v bool) string {
2166+ if v {
2167+ return "enabled"
2168+ }
2169+ return "disabled"
2170+}
2171+
2172+func (i *Interactive) openSettingsDialog() {
2173+ detected := tui.DetectImageProtocol()
2174+ imgEnabled := detected != tui.ImageProtocolNone
2175+ if i.cfg.InlineImagesEnabled != nil {
2176+ imgEnabled = *i.cfg.InlineImagesEnabled
2177+ }
2178+ imgDisabled := detected == tui.ImageProtocolNone
2179+ imgHint := ""
2180+ if imgDisabled {
2181+ imgEnabled = false
2182+ imgHint = "this terminal does not support inline images"
2183+ } else {
2184+ imgHint = "terminal supports " + imageProtocolName(detected)
2185+ }
2186+ i.settingsDialog.Open([]settingsItem{{
2187+ key: "inline_images_enabled",
2188+ label: "render images when supported",
2189+ desc: "draw screenshots inline instead of showing a text placeholder",
2190+ value: imgEnabled,
2191+ disabled: imgDisabled,
2192+ hint: imgHint,
2193+ }})
2194+}
2195+
2196+func (i *Interactive) applySettingToggle(key string, value bool) {
2197+ switch key {
2198+ case "inline_images_enabled":
2199+ val := value
2200+ i.cfg.InlineImagesEnabled = &val
2201+ if i.cfg.SettingsStore != nil {
2202+ if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
2203+ i.mu.Lock()
2204+ i.statusErr = "settings: " + err.Error()
2205+ i.mu.Unlock()
2206+ return
2207+ }
2208+ }
2209+ i.mu.Lock()
2210+ i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
2211+ i.view.InvalidateRenderCache()
2212+ i.statusOK = "inline image rendering " + onOff(value)
2213+ i.statusErr = ""
2214+ i.mu.Unlock()
2215+ }
2216+}
2217+
2104 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {2218 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2105 parts := strings.Fields(cmd)2219 parts := strings.Fields(cmd)
2106 switch parts[0] {2220 switch parts[0] {
@@ -2154,6 +2268,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2154 }2268 }
2155 i.modelDialog.Open(i.cfg.Model, loggedIn)2269 i.modelDialog.Open(i.cfg.Model, loggedIn)
2156 }2270 }
2271+ case "/settings":
2272+ i.mu.Lock()
2273+ i.statusErr = "/settings is temporarily disabled"
2274+ i.statusOK = ""
2275+ i.mu.Unlock()
2157 case "/sessions":2276 case "/sessions":
2158 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)2277 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2159 case "/jump":2278 case "/jump":
@@ -2586,26 +2705,43 @@ func (i *Interactive) applySessionSelection(path string) {
2586 i.mu.Unlock()2705 i.mu.Unlock()
2587 return2706 return
2588 }2707 }
2589- if err := i.cfg.LoadSession(path); err != nil {2708+ i.mu.Lock()
2590- i.mu.Lock()2709+ if i.sessionLoading {
2591- i.statusErr = err.Error()2710+ i.statusErr = "already resuming a session"
2592 i.mu.Unlock()2711 i.mu.Unlock()
2712+ i.invalidate()
2593 return2713 return
2594 }2714 }
2595-2715+ i.sessionLoading = true
2596- i.mu.Lock()2716+ i.statusOK = "resuming session: " + path
2597- i.statusOK = "resumed session: " + path
2598 i.statusErr = ""2717 i.statusErr = ""
2599- i.parkedTurn = 0
2600- i.parkedTotal = 0
2601- i.view.InvalidateRenderCache()
2602- if i.agent != nil {
2603- i.view.Messages = i.agent.Messages()
2604- i.cumUsage = i.agent.Cost()
2605- }
2606 i.mu.Unlock()2718 i.mu.Unlock()
2719+ i.invalidate()
2607 2720
2608- i.scrollToBottom()2721+ go func() {
2722+ err := i.cfg.LoadSession(path)
2723+ i.mu.Lock()
2724+ defer i.mu.Unlock()
2725+ i.sessionLoading = false
2726+ if err != nil {
2727+ i.statusErr = err.Error()
2728+ i.statusOK = ""
2729+ i.invalidate()
2730+ return
2731+ }
2732+ i.statusOK = "resumed session: " + path
2733+ i.statusErr = ""
2734+ i.parkedTurn = 0
2735+ i.parkedTotal = 0
2736+ i.scrollOffset = 0
2737+ i.extNotes = nil
2738+ i.view.InvalidateRenderCache()
2739+ if i.agent != nil {
2740+ i.view.Messages = i.agent.Messages()
2741+ i.cumUsage = i.agent.Cost()
2742+ }
2743+ i.invalidate()
2744+ }()
2609 }2745 }
2610 2746
2611 // scrollToLastTurn parks the viewport at the most recent user turn,2747 // scrollToLastTurn parks the viewport at the most recent user turn,
@@ -2971,7 +3107,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
2971 if payloadTooLarge {3107 if payloadTooLarge {
2972 i.statusErr = ""3108 i.statusErr = ""
2973 i.queued = append([]string{prompt}, i.queued...)3109 i.queued = append([]string{prompt}, i.queued...)
2974- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large — condensing history before retrying…"))3110+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
2975 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"3111 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
2976 }3112 }
2977 // Persist the assistant's reply (and every tool row before3113 // Persist the assistant's reply (and every tool row before
A internal/agent/modes/settings_dialog.go +110 -0
@@ -0,0 +1,110 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+type settingsDialog struct {
8+ active bool
9+ items []settingsItem
10+ cursor int
11+}
12+
13+type settingsItem struct {
14+ key string
15+ label string
16+ desc string
17+ value bool
18+ disabled bool
19+ hint string
20+}
21+
22+type settingsAction struct {
23+ Toggle bool
24+ Key string
25+ Value bool
26+ Close bool
27+}
28+
29+func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
30+
31+func (d *settingsDialog) Open(items []settingsItem) bool {
32+ if len(items) == 0 {
33+ return false
34+ }
35+ d.items = items
36+ d.cursor = 0
37+ d.active = true
38+ return true
39+}
40+
41+func (d *settingsDialog) Close() { d.active = false }
42+func (d *settingsDialog) Active() bool { return d != nil && d.active }
43+
44+func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
45+ switch k.Kind {
46+ case tui.KeyUp:
47+ if d.cursor > 0 {
48+ d.cursor--
49+ }
50+ case tui.KeyDown:
51+ if d.cursor < len(d.items)-1 {
52+ d.cursor++
53+ }
54+ case tui.KeyEsc:
55+ d.Close()
56+ return settingsAction{Close: true}
57+ case tui.KeyEnter:
58+ return d.toggleCurrent()
59+ case tui.KeyRune:
60+ if k.Rune == ' ' {
61+ return d.toggleCurrent()
62+ }
63+ }
64+ return settingsAction{}
65+}
66+
67+func (d *settingsDialog) toggleCurrent() settingsAction {
68+ if len(d.items) == 0 {
69+ d.Close()
70+ return settingsAction{Close: true}
71+ }
72+ it := d.items[d.cursor]
73+ if it.disabled {
74+ return settingsAction{}
75+ }
76+ it.value = !it.value
77+ d.items[d.cursor] = it
78+ return settingsAction{Toggle: true, Key: it.key, Value: it.value}
79+}
80+
81+func (d *settingsDialog) Render(th tui.Theme, width int) []string {
82+ if !d.Active() {
83+ return nil
84+ }
85+ var lines []string
86+ lines = append(lines, frameHeader(th, "settings", width))
87+ lines = append(lines, th.FG256(th.Muted, "toggle with enter/space, esc to close:"))
88+ for i, it := range d.items {
89+ box := "[ ]"
90+ if it.value {
91+ box = "[x]"
92+ }
93+ plain := " " + box + " " + it.label
94+ if it.hint != "" {
95+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
96+ }
97+ if it.disabled {
98+ lines = append(lines, th.FG256(th.Muted, plain))
99+ } else if i == d.cursor {
100+ lines = append(lines, th.PadHighlight(plain, width))
101+ } else {
102+ lines = append(lines, plain)
103+ }
104+ if it.desc != "" {
105+ lines = append(lines, th.FG256(th.Muted, " "+it.desc))
106+ }
107+ }
108+ lines = append(lines, frameRule(th, width))
109+ return lines
110+}
@@ -0,0 +1,110 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+type settingsDialog struct {
8+ active bool
9+ items []settingsItem
10+ cursor int
11+}
12+
13+type settingsItem struct {
14+ key string
15+ label string
16+ desc string
17+ value bool
18+ disabled bool
19+ hint string
20+}
21+
22+type settingsAction struct {
23+ Toggle bool
24+ Key string
25+ Value bool
26+ Close bool
27+}
28+
29+func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
30+
31+func (d *settingsDialog) Open(items []settingsItem) bool {
32+ if len(items) == 0 {
33+ return false
34+ }
35+ d.items = items
36+ d.cursor = 0
37+ d.active = true
38+ return true
39+}
40+
41+func (d *settingsDialog) Close() { d.active = false }
42+func (d *settingsDialog) Active() bool { return d != nil && d.active }
43+
44+func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
45+ switch k.Kind {
46+ case tui.KeyUp:
47+ if d.cursor > 0 {
48+ d.cursor--
49+ }
50+ case tui.KeyDown:
51+ if d.cursor < len(d.items)-1 {
52+ d.cursor++
53+ }
54+ case tui.KeyEsc:
55+ d.Close()
56+ return settingsAction{Close: true}
57+ case tui.KeyEnter:
58+ return d.toggleCurrent()
59+ case tui.KeyRune:
60+ if k.Rune == ' ' {
61+ return d.toggleCurrent()
62+ }
63+ }
64+ return settingsAction{}
65+}
66+
67+func (d *settingsDialog) toggleCurrent() settingsAction {
68+ if len(d.items) == 0 {
69+ d.Close()
70+ return settingsAction{Close: true}
71+ }
72+ it := d.items[d.cursor]
73+ if it.disabled {
74+ return settingsAction{}
75+ }
76+ it.value = !it.value
77+ d.items[d.cursor] = it
78+ return settingsAction{Toggle: true, Key: it.key, Value: it.value}
79+}
80+
81+func (d *settingsDialog) Render(th tui.Theme, width int) []string {
82+ if !d.Active() {
83+ return nil
84+ }
85+ var lines []string
86+ lines = append(lines, frameHeader(th, "settings", width))
87+ lines = append(lines, th.FG256(th.Muted, "toggle with enter/space, esc to close:"))
88+ for i, it := range d.items {
89+ box := "[ ]"
90+ if it.value {
91+ box = "[x]"
92+ }
93+ plain := " " + box + " " + it.label
94+ if it.hint != "" {
95+ plain += " " + th.FG256(th.Muted, "("+it.hint+")")
96+ }
97+ if it.disabled {
98+ lines = append(lines, th.FG256(th.Muted, plain))
99+ } else if i == d.cursor {
100+ lines = append(lines, th.PadHighlight(plain, width))
101+ } else {
102+ lines = append(lines, plain)
103+ }
104+ if it.desc != "" {
105+ lines = append(lines, th.FG256(th.Muted, " "+it.desc))
106+ }
107+ }
108+ lines = append(lines, frameRule(th, width))
109+ return lines
110+}
A internal/agent/settings_store.go +12 -0
@@ -0,0 +1,12 @@
1+package agent
2+
3+type configSettingsStore struct{}
4+
5+func (configSettingsStore) SetInlineImages(enabled bool) error {
6+ cfg, err := LoadConfig()
7+ if err != nil {
8+ return err
9+ }
10+ cfg.InlineImagesEnabled = &enabled
11+ return SaveConfig(cfg)
12+}
@@ -0,0 +1,12 @@
1+package agent
2+
3+type configSettingsStore struct{}
4+
5+func (configSettingsStore) SetInlineImages(enabled bool) error {
6+ cfg, err := LoadConfig()
7+ if err != nil {
8+ return err
9+ }
10+ cfg.InlineImagesEnabled = &enabled
11+ return SaveConfig(cfg)
12+}

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