From 25b2bd4c961bd3e2fe770d05bd033ec40f1fc53b Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sat, 25 Apr 2026 11:24:09 +0200 Subject: [PATCH] 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. --- README.md | 14 ++++ internal/agent/changelog.go | 79 ++++++++++++++++++++-- internal/agent/cli.go | 16 ++++- internal/agent/modes/changelog_dialog.go | 16 ++++- internal/agent/modes/interactive.go | 10 +++ internal/agent/modes/session_ops_dialog.go | 8 +-- internal/tui/render.go | 19 ++++++ internal/tui/theme.go | 5 +- 8 files changed, 152 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3185100..849b9d3 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,20 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | `ctrl+d` | Exit on empty input. | | `ctrl+l` | Redraw the screen. | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | +| `@` | Open the file picker. Browse files and directories in the working directory. | + +### File picker (`@`) + +| Key | Action | +|---|---| +| `@` | Open the file picker (type after a space or at the start of input). | +| `up`, `down` | Navigate the file list. | +| `right` | Open the selected directory. | +| `left` | Go back to the parent directory. | +| `enter` | Select the file or directory and insert it as a chip (`[file:name]` or `[dir:name/]`). | +| `esc` | Close the file picker. | + +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. ### Editor line navigation diff --git a/internal/agent/changelog.go b/internal/agent/changelog.go index b2cb27d..1aa59c2 100644 --- a/internal/agent/changelog.go +++ b/internal/agent/changelog.go @@ -27,14 +27,22 @@ type ChangelogInfo struct { // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so // startup never blocks on a flaky network. func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) { - if version == "" || version == "dev" || version == "0.0.0" { + if version == "" || version == "dev" { return ChangelogInfo{}, nil } - tag := version - if !strings.HasPrefix(tag, "v") { - tag = "v" + tag + + // For local dev builds (0.0.0), fetch the latest release instead + // of a tagged one so developers always see the newest changelog. + var url string + if version == "0.0.0" { + url = "https://api.github.com/repos/patriceckhart/zot/releases/latest" + } else { + tag := version + if !strings.HasPrefix(tag, "v") { + tag = "v" + tag + } + url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) } - url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return ChangelogInfo{}, err @@ -67,6 +75,11 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) if body.Body == "" { return ChangelogInfo{}, nil } + // Extract only the changelog section and strip markdown headers. + body.Body = extractChangelog(body.Body) + if body.Body == "" { + return ChangelogInfo{}, nil + } return ChangelogInfo{ Version: strings.TrimPrefix(body.TagName, "v"), Body: body.Body, @@ -74,6 +87,52 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) }, nil } +// extractChangelog pulls the content starting from "## Changelog" +// (or the whole body if no such header exists) and strips markdown +// heading markers (# ## ###) from every line so the TUI renders +// clean text. +func extractChangelog(body string) string { + lines := strings.Split(body, "\n") + + // Find the "## Changelog" line. + start := -1 + for i, l := range lines { + trimmed := strings.TrimSpace(l) + if strings.EqualFold(trimmed, "## changelog") || + strings.EqualFold(trimmed, "## Changelog") { + start = i + 1 + break + } + } + if start < 0 { + // No changelog header found; use the whole body. + start = 0 + } + + // Process remaining lines: strip # markers but mark headings + // so the renderer can color them. + var out []string + for _, l := range lines[start:] { + trimmed := strings.TrimSpace(l) + if trimmed == "" { + out = append(out, "") + continue + } + // Detect markdown headings and strip the # but keep text. + if strings.HasPrefix(trimmed, "#") { + heading := strings.TrimLeft(trimmed, "#") + heading = strings.TrimSpace(heading) + if heading != "" { + // Mark headings with a sentinel the dialog can detect. + out = append(out, "\x00H:"+heading) + } + continue + } + out = append(out, l) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers // the result on the returned channel. Channel always closes. func FetchChangelogAsync(version string) <-chan ChangelogInfo { @@ -94,12 +153,18 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo { // the first-ever launch (no LastChangelogShown stored — we don't // dump release notes at someone who just installed). func ShouldShowChangelog(currentVersion string, cfg Config) bool { - if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { + if currentVersion == "" || currentVersion == "dev" { return false } if cfg.LastChangelogShown == "" { return false } + // For local builds (0.0.0), always proceed to the fetch step. + // The caller compares the fetched release version against + // LastChangelogShown to decide whether to actually show. + if currentVersion == "0.0.0" { + return true + } return cfg.LastChangelogShown != currentVersion } @@ -120,7 +185,7 @@ func MarkChangelogShown(version string) error { // correctly trigger the dialog while THIS launch (which is also // "first-ever") doesn't. func SeedChangelogVersion(version string) { - if version == "" || version == "dev" || version == "0.0.0" { + if version == "" || version == "dev" { return } cfg, err := LoadConfig() diff --git a/internal/agent/cli.go b/internal/agent/cli.go index ffb41c5..d4befcb 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -554,6 +554,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { if info.Body == "" { return } + // For dev builds (0.0.0), skip if the latest release was + // already shown (stored by the dismiss callback). + if version == "0.0.0" && info.Version == cfg.LastChangelogShown { + return + } changelogCh <- modes.ChangelogPayload{ Version: info.Version, Body: info.Body, @@ -610,7 +615,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { Extensions: extMgr, ChangelogChan: changelogCh, OnChangelogDismiss: func() { - _ = MarkChangelogShown(version) + // For dev builds (0.0.0) store the actual release version + // so the same changelog doesn't show again next launch. + // For real builds, store the binary version. + v := version + if v == "0.0.0" { + if iv != nil && iv.ChangelogVersion() != "" { + v = iv.ChangelogVersion() + } + } + _ = MarkChangelogShown(v) }, SkillSnapshot: func() []*skills.Skill { if args.NoSkill { diff --git a/internal/agent/modes/changelog_dialog.go b/internal/agent/modes/changelog_dialog.go index 690556c..d1f9f71 100644 --- a/internal/agent/modes/changelog_dialog.go +++ b/internal/agent/modes/changelog_dialog.go @@ -83,8 +83,20 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string { out = append(out, "") } - rendered := tui.RenderMarkdown(d.body, th, width-4) - bodyLines := strings.Split(rendered, "\n") + var bodyLines []string + for _, l := range strings.Split(d.body, "\n") { + if strings.HasPrefix(l, "\x00H:") { + // Heading: render in accent color, bold. + heading := strings.TrimPrefix(l, "\x00H:") + bodyLines = append(bodyLines, th.FG256(th.Accent, tui.Bold(heading))) + } else { + // Regular line: render through markdown for bullet points etc. + rendered := tui.RenderMarkdown(l, th, width-4) + for _, rl := range strings.Split(rendered, "\n") { + bodyLines = append(bodyLines, rl) + } + } + } const maxRows = 18 if d.scroll > len(bodyLines)-1 { diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 189d244..0f9cc48 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -1655,6 +1655,16 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { // CancelTurn aborts the active turn if one is running. Used by the // telegram bridge when the paired user sends /stop. +// ChangelogVersion returns the version string of the changelog +// currently shown (or last shown). Used by the dismiss callback +// to store the correct version for dev builds. +func (i *Interactive) ChangelogVersion() string { + if i.changelogDialog != nil { + return i.changelogDialog.version + } + return "" +} + func (i *Interactive) CancelTurn() { i.mu.Lock() cancel := i.cancelTurn diff --git a/internal/agent/modes/session_ops_dialog.go b/internal/agent/modes/session_ops_dialog.go index 197a0e2..6c6ab0e 100644 --- a/internal/agent/modes/session_ops_dialog.go +++ b/internal/agent/modes/session_ops_dialog.go @@ -86,14 +86,14 @@ func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string { lines = append(lines, frameHeader(th, "session", width)) lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):")) for i, it := range d.items { - plain := " " + it.label + text := " " + it.label if it.hint != "" { - plain += " " + th.FG256(th.Muted, "("+it.hint+")") + text += " (" + it.hint + ")" } if i == d.cursor { - lines = append(lines, th.PadHighlight(plain, width)) + lines = append(lines, th.PadHighlight(text, width)) } else { - lines = append(lines, th.FG256(th.Muted, plain)) + lines = append(lines, th.FG256(th.Muted, text)) } } lines = append(lines, frameRule(th, width)) diff --git a/internal/tui/render.go b/internal/tui/render.go index ff4aeeb..fea7866 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -118,6 +118,25 @@ func truncateToWidth(s string, cols int) string { } rw := runewidthRune(r) if seen+rw > cols { + // Flush any trailing ANSI escapes (resets, erase-to-EOL) + // so background colors and cleanup sequences survive. + for i < len(runes) { + if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { + out.WriteRune(runes[i]) + out.WriteRune(runes[i+1]) + i += 2 + for i < len(runes) { + c := runes[i] + out.WriteRune(c) + i++ + if c >= 0x40 && c <= 0x7e { + break + } + } + } else { + break + } + } break } out.WriteRune(r) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 4c8f4e8..b087c36 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -72,7 +72,10 @@ func (t Theme) PadHighlight(s string, width int) string { if visible < width { s += strings.Repeat(" ", width-visible) } - return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset + // Emit the background color AFTER the reset via a trailing + // erase-to-end-of-line so the highlight extends to the terminal + // edge even if the cell count is slightly off. + return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + "\x1b[K" + reset } // Bold wraps s in bold SGR.