feat: changelog on update, full-width highlights, @ file picker docs
Changelog dialog now shows only the changelog section from release notes with headings in accent color. Works for local 0.0.0 builds (fetches latest release). Full-width highlight bars fixed everywhere via erase-to-EOL and trailing ANSI preservation in truncateToWidth. Session ops dialog fixed. README documents the @ file picker.
- commit
- 25b2bd4c961bd3e2fe770d05bd033ec40f1fc53b
- author
- patriceckhart · · 2026-04-25 09:24 UTC
- parent
- 353da72
- signature
- unsigned
- refs
- tag: v0.1.12
8 files · +152 -15
M README.md +14 -0
| @@ -362,6 +362,20 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||
| 362 | 362 | | `ctrl+d` | Exit on empty input. | | |
| 363 | 363 | | `ctrl+l` | Redraw the screen. | | |
| 364 | 364 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 365 | + | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 366 | + | ||
| 367 | + | ### File picker (`@`) | |
| 368 | + | ||
| 369 | + | | Key | Action | | |
| 370 | + | |---|---| | |
| 371 | + | | `@` | Open the file picker (type after a space or at the start of input). | | |
| 372 | + | | `up`, `down` | Navigate the file list. | | |
| 373 | + | | `right` | Open the selected directory. | | |
| 374 | + | | `left` | Go back to the parent directory. | | |
| 375 | + | | `enter` | Select the file or directory and insert it as a chip (`[file:name]` or `[dir:name/]`). | | |
| 376 | + | | `esc` | Close the file picker. | | |
| 377 | + | ||
| 378 | + | Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only entries containing "read"). Selected files are inserted as compact chips that expand to the full path on submit. Dragged-and-dropped files and directories also collapse to chips automatically. | |
| 365 | 379 | ||
| 366 | 380 | ### Editor line navigation | |
| 367 | 381 | ||
| @@ -362,6 +362,20 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||||
| 362 | | `ctrl+d` | Exit on empty input. | | 362 | | `ctrl+d` | Exit on empty input. | | ||
| 363 | | `ctrl+l` | Redraw the screen. | | 363 | | `ctrl+l` | Redraw the screen. | | ||
| 364 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 364 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
| 365 | + | | `@` | Open the file picker. Browse files and directories in the working directory. | | |||
| 366 | + | ||||
| 367 | + | ### File picker (`@`) | |||
| 368 | + | ||||
| 369 | + | | Key | Action | | |||
| 370 | + | |---|---| | |||
| 371 | + | | `@` | Open the file picker (type after a space or at the start of input). | | |||
| 372 | + | | `up`, `down` | Navigate the file list. | | |||
| 373 | + | | `right` | Open the selected directory. | | |||
| 374 | + | | `left` | Go back to the parent directory. | | |||
| 375 | + | | `enter` | Select the file or directory and insert it as a chip (`[file:name]` or `[dir:name/]`). | | |||
| 376 | + | | `esc` | Close the file picker. | | |||
| 377 | + | ||||
| 378 | + | Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only entries containing "read"). Selected files are inserted as compact chips that expand to the full path on submit. Dragged-and-dropped files and directories also collapse to chips automatically. | |||
| 365 | 379 | ||||
| 366 | ### Editor line navigation | 380 | ### Editor line navigation | ||
| 367 | 381 | ||||
M internal/agent/changelog.go +72 -7
| @@ -27,14 +27,22 @@ type ChangelogInfo struct { | |||
| 27 | 27 | // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so | |
| 28 | 28 | // startup never blocks on a flaky network. | |
| 29 | 29 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | |
| 30 | - | if version == "" || version == "dev" || version == "0.0.0" { | |
| 30 | + | if version == "" || version == "dev" { | |
| 31 | 31 | return ChangelogInfo{}, nil | |
| 32 | 32 | } | |
| 33 | - | tag := version | |
| 34 | - | if !strings.HasPrefix(tag, "v") { | |
| 35 | - | tag = "v" + tag | |
| 33 | + | ||
| 34 | + | // For local dev builds (0.0.0), fetch the latest release instead | |
| 35 | + | // of a tagged one so developers always see the newest changelog. | |
| 36 | + | var url string | |
| 37 | + | if version == "0.0.0" { | |
| 38 | + | url = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |
| 39 | + | } else { | |
| 40 | + | tag := version | |
| 41 | + | if !strings.HasPrefix(tag, "v") { | |
| 42 | + | tag = "v" + tag | |
| 43 | + | } | |
| 44 | + | url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |
| 36 | 45 | } | |
| 37 | - | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |
| 38 | 46 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 39 | 47 | if err != nil { | |
| 40 | 48 | return ChangelogInfo{}, err | |
| @@ -67,6 +75,11 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) | |||
| 67 | 75 | if body.Body == "" { | |
| 68 | 76 | return ChangelogInfo{}, nil | |
| 69 | 77 | } | |
| 78 | + | // Extract only the changelog section and strip markdown headers. | |
| 79 | + | body.Body = extractChangelog(body.Body) | |
| 80 | + | if body.Body == "" { | |
| 81 | + | return ChangelogInfo{}, nil | |
| 82 | + | } | |
| 70 | 83 | return ChangelogInfo{ | |
| 71 | 84 | Version: strings.TrimPrefix(body.TagName, "v"), | |
| 72 | 85 | Body: body.Body, | |
| @@ -74,6 +87,52 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) | |||
| 74 | 87 | }, nil | |
| 75 | 88 | } | |
| 76 | 89 | ||
| 90 | + | // extractChangelog pulls the content starting from "## Changelog" | |
| 91 | + | // (or the whole body if no such header exists) and strips markdown | |
| 92 | + | // heading markers (# ## ###) from every line so the TUI renders | |
| 93 | + | // clean text. | |
| 94 | + | func extractChangelog(body string) string { | |
| 95 | + | lines := strings.Split(body, "\n") | |
| 96 | + | ||
| 97 | + | // Find the "## Changelog" line. | |
| 98 | + | start := -1 | |
| 99 | + | for i, l := range lines { | |
| 100 | + | trimmed := strings.TrimSpace(l) | |
| 101 | + | if strings.EqualFold(trimmed, "## changelog") || | |
| 102 | + | strings.EqualFold(trimmed, "## Changelog") { | |
| 103 | + | start = i + 1 | |
| 104 | + | break | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if start < 0 { | |
| 108 | + | // No changelog header found; use the whole body. | |
| 109 | + | start = 0 | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Process remaining lines: strip # markers but mark headings | |
| 113 | + | // so the renderer can color them. | |
| 114 | + | var out []string | |
| 115 | + | for _, l := range lines[start:] { | |
| 116 | + | trimmed := strings.TrimSpace(l) | |
| 117 | + | if trimmed == "" { | |
| 118 | + | out = append(out, "") | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | // Detect markdown headings and strip the # but keep text. | |
| 122 | + | if strings.HasPrefix(trimmed, "#") { | |
| 123 | + | heading := strings.TrimLeft(trimmed, "#") | |
| 124 | + | heading = strings.TrimSpace(heading) | |
| 125 | + | if heading != "" { | |
| 126 | + | // Mark headings with a sentinel the dialog can detect. | |
| 127 | + | out = append(out, "\x00H:"+heading) | |
| 128 | + | } | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | out = append(out, l) | |
| 132 | + | } | |
| 133 | + | return strings.TrimSpace(strings.Join(out, "\n")) | |
| 134 | + | } | |
| 135 | + | ||
| 77 | 136 | // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers | |
| 78 | 137 | // the result on the returned channel. Channel always closes. | |
| 79 | 138 | func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |
| @@ -94,12 +153,18 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |||
| 94 | 153 | // the first-ever launch (no LastChangelogShown stored — we don't | |
| 95 | 154 | // dump release notes at someone who just installed). | |
| 96 | 155 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | |
| 97 | - | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |
| 156 | + | if currentVersion == "" || currentVersion == "dev" { | |
| 98 | 157 | return false | |
| 99 | 158 | } | |
| 100 | 159 | if cfg.LastChangelogShown == "" { | |
| 101 | 160 | return false | |
| 102 | 161 | } | |
| 162 | + | // For local builds (0.0.0), always proceed to the fetch step. | |
| 163 | + | // The caller compares the fetched release version against | |
| 164 | + | // LastChangelogShown to decide whether to actually show. | |
| 165 | + | if currentVersion == "0.0.0" { | |
| 166 | + | return true | |
| 167 | + | } | |
| 103 | 168 | return cfg.LastChangelogShown != currentVersion | |
| 104 | 169 | } | |
| 105 | 170 | ||
| @@ -120,7 +185,7 @@ func MarkChangelogShown(version string) error { | |||
| 120 | 185 | // correctly trigger the dialog while THIS launch (which is also | |
| 121 | 186 | // "first-ever") doesn't. | |
| 122 | 187 | func SeedChangelogVersion(version string) { | |
| 123 | - | if version == "" || version == "dev" || version == "0.0.0" { | |
| 188 | + | if version == "" || version == "dev" { | |
| 124 | 189 | return | |
| 125 | 190 | } | |
| 126 | 191 | cfg, err := LoadConfig() | |
| @@ -27,14 +27,22 @@ type ChangelogInfo struct { | |||||
| 27 | // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so | 27 | // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so | ||
| 28 | // startup never blocks on a flaky network. | 28 | // startup never blocks on a flaky network. | ||
| 29 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | 29 | func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { | ||
| 30 | - | if version == "" || version == "dev" || version == "0.0.0" { | 30 | + | if version == "" || version == "dev" { |
| 31 | return ChangelogInfo{}, nil | 31 | return ChangelogInfo{}, nil | ||
| 32 | } | 32 | } | ||
| 33 | - | tag := version | 33 | + | |
| 34 | - | if !strings.HasPrefix(tag, "v") { | 34 | + | // For local dev builds (0.0.0), fetch the latest release instead |
| 35 | - | tag = "v" + tag | 35 | + | // of a tagged one so developers always see the newest changelog. |
| 36 | + | var url string | |||
| 37 | + | if version == "0.0.0" { | |||
| 38 | + | url = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |||
| 39 | + | } else { | |||
| 40 | + | tag := version | |||
| 41 | + | if !strings.HasPrefix(tag, "v") { | |||
| 42 | + | tag = "v" + tag | |||
| 43 | + | } | |||
| 44 | + | url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |||
| 36 | } | 45 | } | ||
| 37 | - | url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) | |||
| 38 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | 46 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | ||
| 39 | if err != nil { | 47 | if err != nil { | ||
| 40 | return ChangelogInfo{}, err | 48 | return ChangelogInfo{}, err | ||
| @@ -67,6 +75,11 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) | |||||
| 67 | if body.Body == "" { | 75 | if body.Body == "" { | ||
| 68 | return ChangelogInfo{}, nil | 76 | return ChangelogInfo{}, nil | ||
| 69 | } | 77 | } | ||
| 78 | + | // Extract only the changelog section and strip markdown headers. | |||
| 79 | + | body.Body = extractChangelog(body.Body) | |||
| 80 | + | if body.Body == "" { | |||
| 81 | + | return ChangelogInfo{}, nil | |||
| 82 | + | } | |||
| 70 | return ChangelogInfo{ | 83 | return ChangelogInfo{ | ||
| 71 | Version: strings.TrimPrefix(body.TagName, "v"), | 84 | Version: strings.TrimPrefix(body.TagName, "v"), | ||
| 72 | Body: body.Body, | 85 | Body: body.Body, | ||
| @@ -74,6 +87,52 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) | |||||
| 74 | }, nil | 87 | }, nil | ||
| 75 | } | 88 | } | ||
| 76 | 89 | ||||
| 90 | + | // extractChangelog pulls the content starting from "## Changelog" | |||
| 91 | + | // (or the whole body if no such header exists) and strips markdown | |||
| 92 | + | // heading markers (# ## ###) from every line so the TUI renders | |||
| 93 | + | // clean text. | |||
| 94 | + | func extractChangelog(body string) string { | |||
| 95 | + | lines := strings.Split(body, "\n") | |||
| 96 | + | ||||
| 97 | + | // Find the "## Changelog" line. | |||
| 98 | + | start := -1 | |||
| 99 | + | for i, l := range lines { | |||
| 100 | + | trimmed := strings.TrimSpace(l) | |||
| 101 | + | if strings.EqualFold(trimmed, "## changelog") || | |||
| 102 | + | strings.EqualFold(trimmed, "## Changelog") { | |||
| 103 | + | start = i + 1 | |||
| 104 | + | break | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if start < 0 { | |||
| 108 | + | // No changelog header found; use the whole body. | |||
| 109 | + | start = 0 | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Process remaining lines: strip # markers but mark headings | |||
| 113 | + | // so the renderer can color them. | |||
| 114 | + | var out []string | |||
| 115 | + | for _, l := range lines[start:] { | |||
| 116 | + | trimmed := strings.TrimSpace(l) | |||
| 117 | + | if trimmed == "" { | |||
| 118 | + | out = append(out, "") | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 121 | + | // Detect markdown headings and strip the # but keep text. | |||
| 122 | + | if strings.HasPrefix(trimmed, "#") { | |||
| 123 | + | heading := strings.TrimLeft(trimmed, "#") | |||
| 124 | + | heading = strings.TrimSpace(heading) | |||
| 125 | + | if heading != "" { | |||
| 126 | + | // Mark headings with a sentinel the dialog can detect. | |||
| 127 | + | out = append(out, "\x00H:"+heading) | |||
| 128 | + | } | |||
| 129 | + | continue | |||
| 130 | + | } | |||
| 131 | + | out = append(out, l) | |||
| 132 | + | } | |||
| 133 | + | return strings.TrimSpace(strings.Join(out, "\n")) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 77 | // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers | 136 | // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers | ||
| 78 | // the result on the returned channel. Channel always closes. | 137 | // the result on the returned channel. Channel always closes. | ||
| 79 | func FetchChangelogAsync(version string) <-chan ChangelogInfo { | 138 | func FetchChangelogAsync(version string) <-chan ChangelogInfo { | ||
| @@ -94,12 +153,18 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo { | |||||
| 94 | // the first-ever launch (no LastChangelogShown stored — we don't | 153 | // the first-ever launch (no LastChangelogShown stored — we don't | ||
| 95 | // dump release notes at someone who just installed). | 154 | // dump release notes at someone who just installed). | ||
| 96 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | 155 | func ShouldShowChangelog(currentVersion string, cfg Config) bool { | ||
| 97 | - | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | 156 | + | if currentVersion == "" || currentVersion == "dev" { |
| 98 | return false | 157 | return false | ||
| 99 | } | 158 | } | ||
| 100 | if cfg.LastChangelogShown == "" { | 159 | if cfg.LastChangelogShown == "" { | ||
| 101 | return false | 160 | return false | ||
| 102 | } | 161 | } | ||
| 162 | + | // For local builds (0.0.0), always proceed to the fetch step. | |||
| 163 | + | // The caller compares the fetched release version against | |||
| 164 | + | // LastChangelogShown to decide whether to actually show. | |||
| 165 | + | if currentVersion == "0.0.0" { | |||
| 166 | + | return true | |||
| 167 | + | } | |||
| 103 | return cfg.LastChangelogShown != currentVersion | 168 | return cfg.LastChangelogShown != currentVersion | ||
| 104 | } | 169 | } | ||
| 105 | 170 | ||||
| @@ -120,7 +185,7 @@ func MarkChangelogShown(version string) error { | |||||
| 120 | // correctly trigger the dialog while THIS launch (which is also | 185 | // correctly trigger the dialog while THIS launch (which is also | ||
| 121 | // "first-ever") doesn't. | 186 | // "first-ever") doesn't. | ||
| 122 | func SeedChangelogVersion(version string) { | 187 | func SeedChangelogVersion(version string) { | ||
| 123 | - | if version == "" || version == "dev" || version == "0.0.0" { | 188 | + | if version == "" || version == "dev" { |
| 124 | return | 189 | return | ||
| 125 | } | 190 | } | ||
| 126 | cfg, err := LoadConfig() | 191 | cfg, err := LoadConfig() | ||
M internal/agent/cli.go +15 -1
| @@ -554,6 +554,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 554 | 554 | if info.Body == "" { | |
| 555 | 555 | return | |
| 556 | 556 | } | |
| 557 | + | // For dev builds (0.0.0), skip if the latest release was | |
| 558 | + | // already shown (stored by the dismiss callback). | |
| 559 | + | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | |
| 560 | + | return | |
| 561 | + | } | |
| 557 | 562 | changelogCh <- modes.ChangelogPayload{ | |
| 558 | 563 | Version: info.Version, | |
| 559 | 564 | Body: info.Body, | |
| @@ -610,7 +615,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 610 | 615 | Extensions: extMgr, | |
| 611 | 616 | ChangelogChan: changelogCh, | |
| 612 | 617 | OnChangelogDismiss: func() { | |
| 613 | - | _ = MarkChangelogShown(version) | |
| 618 | + | // For dev builds (0.0.0) store the actual release version | |
| 619 | + | // so the same changelog doesn't show again next launch. | |
| 620 | + | // For real builds, store the binary version. | |
| 621 | + | v := version | |
| 622 | + | if v == "0.0.0" { | |
| 623 | + | if iv != nil && iv.ChangelogVersion() != "" { | |
| 624 | + | v = iv.ChangelogVersion() | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | _ = MarkChangelogShown(v) | |
| 614 | 628 | }, | |
| 615 | 629 | SkillSnapshot: func() []*skills.Skill { | |
| 616 | 630 | if args.NoSkill { | |
| @@ -554,6 +554,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 554 | if info.Body == "" { | 554 | if info.Body == "" { | ||
| 555 | return | 555 | return | ||
| 556 | } | 556 | } | ||
| 557 | + | // For dev builds (0.0.0), skip if the latest release was | |||
| 558 | + | // already shown (stored by the dismiss callback). | |||
| 559 | + | if version == "0.0.0" && info.Version == cfg.LastChangelogShown { | |||
| 560 | + | return | |||
| 561 | + | } | |||
| 557 | changelogCh <- modes.ChangelogPayload{ | 562 | changelogCh <- modes.ChangelogPayload{ | ||
| 558 | Version: info.Version, | 563 | Version: info.Version, | ||
| 559 | Body: info.Body, | 564 | Body: info.Body, | ||
| @@ -610,7 +615,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 610 | Extensions: extMgr, | 615 | Extensions: extMgr, | ||
| 611 | ChangelogChan: changelogCh, | 616 | ChangelogChan: changelogCh, | ||
| 612 | OnChangelogDismiss: func() { | 617 | OnChangelogDismiss: func() { | ||
| 613 | - | _ = MarkChangelogShown(version) | 618 | + | // For dev builds (0.0.0) store the actual release version |
| 619 | + | // so the same changelog doesn't show again next launch. | |||
| 620 | + | // For real builds, store the binary version. | |||
| 621 | + | v := version | |||
| 622 | + | if v == "0.0.0" { | |||
| 623 | + | if iv != nil && iv.ChangelogVersion() != "" { | |||
| 624 | + | v = iv.ChangelogVersion() | |||
| 625 | + | } | |||
| 626 | + | } | |||
| 627 | + | _ = MarkChangelogShown(v) | |||
| 614 | }, | 628 | }, | ||
| 615 | SkillSnapshot: func() []*skills.Skill { | 629 | SkillSnapshot: func() []*skills.Skill { | ||
| 616 | if args.NoSkill { | 630 | if args.NoSkill { | ||
M internal/agent/modes/changelog_dialog.go +14 -2
| @@ -83,8 +83,20 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string { | |||
| 83 | 83 | out = append(out, "") | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | - | rendered := tui.RenderMarkdown(d.body, th, width-4) | |
| 87 | - | bodyLines := strings.Split(rendered, "\n") | |
| 86 | + | var bodyLines []string | |
| 87 | + | for _, l := range strings.Split(d.body, "\n") { | |
| 88 | + | if strings.HasPrefix(l, "\x00H:") { | |
| 89 | + | // Heading: render in accent color, bold. | |
| 90 | + | heading := strings.TrimPrefix(l, "\x00H:") | |
| 91 | + | bodyLines = append(bodyLines, th.FG256(th.Accent, tui.Bold(heading))) | |
| 92 | + | } else { | |
| 93 | + | // Regular line: render through markdown for bullet points etc. | |
| 94 | + | rendered := tui.RenderMarkdown(l, th, width-4) | |
| 95 | + | for _, rl := range strings.Split(rendered, "\n") { | |
| 96 | + | bodyLines = append(bodyLines, rl) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 88 | 100 | ||
| 89 | 101 | const maxRows = 18 | |
| 90 | 102 | if d.scroll > len(bodyLines)-1 { | |
| @@ -83,8 +83,20 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string { | |||||
| 83 | out = append(out, "") | 83 | out = append(out, "") | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | - | rendered := tui.RenderMarkdown(d.body, th, width-4) | 86 | + | var bodyLines []string |
| 87 | - | bodyLines := strings.Split(rendered, "\n") | 87 | + | for _, l := range strings.Split(d.body, "\n") { |
| 88 | + | if strings.HasPrefix(l, "\x00H:") { | |||
| 89 | + | // Heading: render in accent color, bold. | |||
| 90 | + | heading := strings.TrimPrefix(l, "\x00H:") | |||
| 91 | + | bodyLines = append(bodyLines, th.FG256(th.Accent, tui.Bold(heading))) | |||
| 92 | + | } else { | |||
| 93 | + | // Regular line: render through markdown for bullet points etc. | |||
| 94 | + | rendered := tui.RenderMarkdown(l, th, width-4) | |||
| 95 | + | for _, rl := range strings.Split(rendered, "\n") { | |||
| 96 | + | bodyLines = append(bodyLines, rl) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 88 | 100 | ||||
| 89 | const maxRows = 18 | 101 | const maxRows = 18 | ||
| 90 | if d.scroll > len(bodyLines)-1 { | 102 | if d.scroll > len(bodyLines)-1 { | ||
M internal/agent/modes/interactive.go +10 -0
| @@ -1655,6 +1655,16 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||
| 1655 | 1655 | ||
| 1656 | 1656 | // CancelTurn aborts the active turn if one is running. Used by the | |
| 1657 | 1657 | // telegram bridge when the paired user sends /stop. | |
| 1658 | + | // ChangelogVersion returns the version string of the changelog | |
| 1659 | + | // currently shown (or last shown). Used by the dismiss callback | |
| 1660 | + | // to store the correct version for dev builds. | |
| 1661 | + | func (i *Interactive) ChangelogVersion() string { | |
| 1662 | + | if i.changelogDialog != nil { | |
| 1663 | + | return i.changelogDialog.version | |
| 1664 | + | } | |
| 1665 | + | return "" | |
| 1666 | + | } | |
| 1667 | + | ||
| 1658 | 1668 | func (i *Interactive) CancelTurn() { | |
| 1659 | 1669 | i.mu.Lock() | |
| 1660 | 1670 | cancel := i.cancelTurn | |
| @@ -1655,6 +1655,16 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||||
| 1655 | 1655 | ||||
| 1656 | // CancelTurn aborts the active turn if one is running. Used by the | 1656 | // CancelTurn aborts the active turn if one is running. Used by the | ||
| 1657 | // telegram bridge when the paired user sends /stop. | 1657 | // telegram bridge when the paired user sends /stop. | ||
| 1658 | + | // ChangelogVersion returns the version string of the changelog | |||
| 1659 | + | // currently shown (or last shown). Used by the dismiss callback | |||
| 1660 | + | // to store the correct version for dev builds. | |||
| 1661 | + | func (i *Interactive) ChangelogVersion() string { | |||
| 1662 | + | if i.changelogDialog != nil { | |||
| 1663 | + | return i.changelogDialog.version | |||
| 1664 | + | } | |||
| 1665 | + | return "" | |||
| 1666 | + | } | |||
| 1667 | + | ||||
| 1658 | func (i *Interactive) CancelTurn() { | 1668 | func (i *Interactive) CancelTurn() { | ||
| 1659 | i.mu.Lock() | 1669 | i.mu.Lock() | ||
| 1660 | cancel := i.cancelTurn | 1670 | cancel := i.cancelTurn | ||
M internal/agent/modes/session_ops_dialog.go +4 -4
| @@ -86,14 +86,14 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string { | |||
| 86 | 86 | lines = append(lines, frameHeader(th, "session", width)) | |
| 87 | 87 | lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):")) | |
| 88 | 88 | for i, it := range d.items { | |
| 89 | - | plain := " " + it.label | |
| 89 | + | text := " " + it.label | |
| 90 | 90 | if it.hint != "" { | |
| 91 | - | plain += " " + th.FG256(th.Muted, "("+it.hint+")") | |
| 91 | + | text += " (" + it.hint + ")" | |
| 92 | 92 | } | |
| 93 | 93 | if i == d.cursor { | |
| 94 | - | lines = append(lines, th.PadHighlight(plain, width)) | |
| 94 | + | lines = append(lines, th.PadHighlight(text, width)) | |
| 95 | 95 | } else { | |
| 96 | - | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 96 | + | lines = append(lines, th.FG256(th.Muted, text)) | |
| 97 | 97 | } | |
| 98 | 98 | } | |
| 99 | 99 | lines = append(lines, frameRule(th, width)) | |
| @@ -86,14 +86,14 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string { | |||||
| 86 | lines = append(lines, frameHeader(th, "session", width)) | 86 | lines = append(lines, frameHeader(th, "session", width)) | ||
| 87 | lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):")) | 87 | lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):")) | ||
| 88 | for i, it := range d.items { | 88 | for i, it := range d.items { | ||
| 89 | - | plain := " " + it.label | 89 | + | text := " " + it.label |
| 90 | if it.hint != "" { | 90 | if it.hint != "" { | ||
| 91 | - | plain += " " + th.FG256(th.Muted, "("+it.hint+")") | 91 | + | text += " (" + it.hint + ")" |
| 92 | } | 92 | } | ||
| 93 | if i == d.cursor { | 93 | if i == d.cursor { | ||
| 94 | - | lines = append(lines, th.PadHighlight(plain, width)) | 94 | + | lines = append(lines, th.PadHighlight(text, width)) |
| 95 | } else { | 95 | } else { | ||
| 96 | - | lines = append(lines, th.FG256(th.Muted, plain)) | 96 | + | lines = append(lines, th.FG256(th.Muted, text)) |
| 97 | } | 97 | } | ||
| 98 | } | 98 | } | ||
| 99 | lines = append(lines, frameRule(th, width)) | 99 | lines = append(lines, frameRule(th, width)) | ||
M internal/tui/render.go +19 -0
| @@ -118,6 +118,25 @@ func truncateToWidth(s string, cols int) string { | |||
| 118 | 118 | } | |
| 119 | 119 | rw := runewidthRune(r) | |
| 120 | 120 | if seen+rw > cols { | |
| 121 | + | // Flush any trailing ANSI escapes (resets, erase-to-EOL) | |
| 122 | + | // so background colors and cleanup sequences survive. | |
| 123 | + | for i < len(runes) { | |
| 124 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { | |
| 125 | + | out.WriteRune(runes[i]) | |
| 126 | + | out.WriteRune(runes[i+1]) | |
| 127 | + | i += 2 | |
| 128 | + | for i < len(runes) { | |
| 129 | + | c := runes[i] | |
| 130 | + | out.WriteRune(c) | |
| 131 | + | i++ | |
| 132 | + | if c >= 0x40 && c <= 0x7e { | |
| 133 | + | break | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } else { | |
| 137 | + | break | |
| 138 | + | } | |
| 139 | + | } | |
| 121 | 140 | break | |
| 122 | 141 | } | |
| 123 | 142 | out.WriteRune(r) | |
| @@ -118,6 +118,25 @@ func truncateToWidth(s string, cols int) string { | |||||
| 118 | } | 118 | } | ||
| 119 | rw := runewidthRune(r) | 119 | rw := runewidthRune(r) | ||
| 120 | if seen+rw > cols { | 120 | if seen+rw > cols { | ||
| 121 | + | // Flush any trailing ANSI escapes (resets, erase-to-EOL) | |||
| 122 | + | // so background colors and cleanup sequences survive. | |||
| 123 | + | for i < len(runes) { | |||
| 124 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { | |||
| 125 | + | out.WriteRune(runes[i]) | |||
| 126 | + | out.WriteRune(runes[i+1]) | |||
| 127 | + | i += 2 | |||
| 128 | + | for i < len(runes) { | |||
| 129 | + | c := runes[i] | |||
| 130 | + | out.WriteRune(c) | |||
| 131 | + | i++ | |||
| 132 | + | if c >= 0x40 && c <= 0x7e { | |||
| 133 | + | break | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } else { | |||
| 137 | + | break | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 121 | break | 140 | break | ||
| 122 | } | 141 | } | ||
| 123 | out.WriteRune(r) | 142 | out.WriteRune(r) | ||
M internal/tui/theme.go +4 -1
| @@ -72,7 +72,10 @@ func (t Theme) PadHighlight(s string, width int) string { | |||
| 72 | 72 | if visible < width { | |
| 73 | 73 | s += strings.Repeat(" ", width-visible) | |
| 74 | 74 | } | |
| 75 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset | |
| 75 | + | // Emit the background color AFTER the reset via a trailing | |
| 76 | + | // erase-to-end-of-line so the highlight extends to the terminal | |
| 77 | + | // edge even if the cell count is slightly off. | |
| 78 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + "\x1b[K" + reset | |
| 76 | 79 | } | |
| 77 | 80 | ||
| 78 | 81 | // Bold wraps s in bold SGR. | |
| @@ -72,7 +72,10 @@ func (t Theme) PadHighlight(s string, width int) string { | |||||
| 72 | if visible < width { | 72 | if visible < width { | ||
| 73 | s += strings.Repeat(" ", width-visible) | 73 | s += strings.Repeat(" ", width-visible) | ||
| 74 | } | 74 | } | ||
| 75 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset | 75 | + | // Emit the background color AFTER the reset via a trailing |
| 76 | + | // erase-to-end-of-line so the highlight extends to the terminal | |||
| 77 | + | // edge even if the cell count is slightly off. | |||
| 78 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + "\x1b[K" + reset | |||
| 76 | } | 79 | } | ||
| 77 | 80 | ||||
| 78 | // Bold wraps s in bold SGR. | 81 | // Bold wraps s in bold SGR. | ||