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 { | |||
| 111 | 111 | // the events that have a clear extension-facing meaning are | |
| 112 | 112 | // forwarded; internal-only ones (text_delta, tool_progress) are | |
| 113 | 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 | 134 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| 115 | 135 | if mgr == nil { | |
| 116 | 136 | return | |
| @@ -565,6 +585,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 565 | 585 | if err != nil { | |
| 566 | 586 | return err | |
| 567 | 587 | } | |
| 588 | + | fullMsgCount := len(msgs) | |
| 589 | + | msgs = trimMessagesForResume(msgs, 20) | |
| 568 | 590 | persistMu.Lock() | |
| 569 | 591 | // Flush any unsaved messages to the old session before swapping. | |
| 570 | 592 | // Per-message persistence keeps sessBaselineMsgs current, so | |
| @@ -580,7 +602,12 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 580 | 602 | if usage, uerr := core.SessionUsage(path); uerr == nil { | |
| 581 | 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 | 611 | persistMu.Unlock() | |
| 585 | 612 | 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 | 666 | 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, | |
| 659 | 689 | LoggedInProviders: func() []string { | |
| 660 | 690 | var out []string | |
| 661 | 691 | 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 are | 111 | // the events that have a clear extension-facing meaning are | ||
| 112 | // forwarded; internal-only ones (text_delta, tool_progress) are | 112 | // 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 | return | 136 | 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 err | 586 | 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, so | 592 | // 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 nil | 612 | 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 []string | 690 | 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 { | |||
| 21 | 21 | Reasoning string `json:"reasoning"` | |
| 22 | 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 | 30 | // LastChangelogShown is the version whose release-notes | |
| 25 | 31 | // dialog the user has already seen. When the running binary's | |
| 26 | 32 | // 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-notes | 30 | // LastChangelogShown is the version whose release-notes | ||
| 25 | // dialog the user has already seen. When the running binary's | 31 | // dialog the user has already seen. When the running binary's | ||
| 26 | // version differs, the next interactive run shows the | 32 | // version differs, the next interactive run shows the | ||
M internal/agent/modes/interactive.go +152 -16
| @@ -34,6 +34,12 @@ type InteractiveConfig struct { | |||
| 34 | 34 | MaxSteps int | |
| 35 | 35 | 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 | 43 | // Agent is optional. If nil, zot opens without credentials; the | |
| 38 | 44 | // user must /login before they can prompt. | |
| 39 | 45 | Agent *core.Agent | |
| @@ -168,6 +174,11 @@ type chatCacheKey struct { | |||
| 168 | 174 | 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 | 182 | type Interactive struct { | |
| 172 | 183 | cfg InteractiveConfig | |
| 173 | 184 | view *tui.View | |
| @@ -261,6 +272,7 @@ type Interactive struct { | |||
| 261 | 272 | confirmDialog *confirmDialog | |
| 262 | 273 | logoutDialog *logoutDialog | |
| 263 | 274 | telegramDialog *telegramDialog | |
| 275 | + | settingsDialog *settingsDialog | |
| 264 | 276 | telegramBridge *telegram.Bridge | |
| 265 | 277 | sessionOpsDialog *sessionOpsDialog | |
| 266 | 278 | sessionTreeDialog *sessionTreeDialog | |
| @@ -302,6 +314,11 @@ type Interactive struct { | |||
| 302 | 314 | // Notify / Display. They live above the editor (just below the | |
| 303 | 315 | // transcript) until cleared by /clear or another reset. | |
| 304 | 316 | 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 | 324 | // welcomeVersionDuration is how long the welcome banner shows the | |
| @@ -315,7 +332,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 315 | 332 | cfg: cfg, | |
| 316 | 333 | view: &tui.View{ | |
| 317 | 334 | Theme: cfg.Theme, | |
| 318 | - | ImageProto: tui.DetectImageProtocol(), | |
| 335 | + | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| 319 | 336 | }, | |
| 320 | 337 | // Prompt is the standard half-block accent bar used by chat | |
| 321 | 338 | // speaker labels too, so the input gutter matches the rest | |
| @@ -334,6 +351,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 334 | 351 | confirmDialog: newConfirmDialog(), | |
| 335 | 352 | logoutDialog: newLogoutDialog(), | |
| 336 | 353 | telegramDialog: newTelegramDialog(), | |
| 354 | + | settingsDialog: newSettingsDialog(), | |
| 337 | 355 | sessionOpsDialog: newSessionOpsDialog(), | |
| 338 | 356 | sessionTreeDialog: newSessionTreeDialog(), | |
| 339 | 357 | extPanel: newExtPanelDialog(), | |
| @@ -856,6 +874,8 @@ func (i *Interactive) redraw() { | |||
| 856 | 874 | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | |
| 857 | 875 | case i.telegramDialog.Active(): | |
| 858 | 876 | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | |
| 877 | + | case i.settingsDialog.Active(): | |
| 878 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | |
| 859 | 879 | case i.sessionOpsDialog.Active(): | |
| 860 | 880 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 861 | 881 | case i.sessionTreeDialog.Active(): | |
| @@ -1450,6 +1470,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1450 | 1470 | i.invalidate() | |
| 1451 | 1471 | 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 | 1486 | if i.sessionOpsDialog.Active() { | |
| 1454 | 1487 | if k.Kind == tui.KeyCtrlC { | |
| 1455 | 1488 | i.sessionOpsDialog.Close() | |
| @@ -1546,6 +1579,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1546 | 1579 | // Global keys. | |
| 1547 | 1580 | switch k.Kind { | |
| 1548 | 1581 | case tui.KeyCtrlC: | |
| 1582 | + | i.mu.Lock() | |
| 1583 | + | loadingSession := i.sessionLoading | |
| 1584 | + | i.mu.Unlock() | |
| 1585 | + | if loadingSession { | |
| 1586 | + | return true | |
| 1587 | + | } | |
| 1549 | 1588 | // While busy: do NOT cancel the turn. ctrl+c during a | |
| 1550 | 1589 | // running turn is almost always reflex muscle memory | |
| 1551 | 1590 | // ("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 | 2218 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 2105 | 2219 | parts := strings.Fields(cmd) | |
| 2106 | 2220 | switch parts[0] { | |
| @@ -2154,6 +2268,11 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 2154 | 2268 | } | |
| 2155 | 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 | 2276 | case "/sessions": | |
| 2158 | 2277 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 2159 | 2278 | case "/jump": | |
| @@ -2586,26 +2705,43 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 2586 | 2705 | i.mu.Unlock() | |
| 2587 | 2706 | return | |
| 2588 | 2707 | } | |
| 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" | |
| 2592 | 2711 | i.mu.Unlock() | |
| 2712 | + | i.invalidate() | |
| 2593 | 2713 | return | |
| 2594 | 2714 | } | |
| 2595 | - | ||
| 2596 | - | i.mu.Lock() | |
| 2597 | - | i.statusOK = "resumed session: " + path | |
| 2715 | + | i.sessionLoading = true | |
| 2716 | + | i.statusOK = "resuming session: " + path | |
| 2598 | 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 | 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 | 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 | 3107 | if payloadTooLarge { | |
| 2972 | 3108 | i.statusErr = "" | |
| 2973 | 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 | 3111 | i.pendingPostCompactNote = "context auto-compacted; retrying your last message" | |
| 2976 | 3112 | } | |
| 2977 | 3113 | // Persist the assistant's reply (and every tool row before | |
| @@ -34,6 +34,12 @@ type InteractiveConfig struct { | |||||
| 34 | MaxSteps int | 34 | MaxSteps int | ||
| 35 | CWD string | 35 | 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; the | 43 | // 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.Agent | 45 | Agent *core.Agent | ||
| @@ -168,6 +174,11 @@ type chatCacheKey struct { | |||||
| 168 | expandAll bool | 174 | 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 InteractiveConfig | 183 | cfg InteractiveConfig | ||
| 173 | view *tui.View | 184 | view *tui.View | ||
| @@ -261,6 +272,7 @@ type Interactive struct { | |||||
| 261 | confirmDialog *confirmDialog | 272 | confirmDialog *confirmDialog | ||
| 262 | logoutDialog *logoutDialog | 273 | logoutDialog *logoutDialog | ||
| 263 | telegramDialog *telegramDialog | 274 | telegramDialog *telegramDialog | ||
| 275 | + | settingsDialog *settingsDialog | |||
| 264 | telegramBridge *telegram.Bridge | 276 | telegramBridge *telegram.Bridge | ||
| 265 | sessionOpsDialog *sessionOpsDialog | 277 | sessionOpsDialog *sessionOpsDialog | ||
| 266 | sessionTreeDialog *sessionTreeDialog | 278 | sessionTreeDialog *sessionTreeDialog | ||
| @@ -302,6 +314,11 @@ type Interactive struct { | |||||
| 302 | // Notify / Display. They live above the editor (just below the | 314 | // 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 []string | 316 | 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 the | 324 | // 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 chat | 337 | // Prompt is the standard half-block accent bar used by chat | ||
| 321 | // speaker labels too, so the input gutter matches the rest | 338 | // 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 false | 1471 | 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 a | 1588 | // While busy: do NOT cancel the turn. ctrl+c during a | ||
| 1550 | // running turn is almost always reflex muscle memory | 1589 | // running turn is almost always reflex muscle memory | ||
| 1551 | // ("be quiet" in a shell) rather than a deliberate | 1590 | // ("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 | return | 2706 | 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 | return | 2713 | 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 before | 3113 | // 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 | + | } | |||