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.
This commit is contained in:
patriceckhart 2026-04-25 11:24:09 +02:00
parent 353da72d28
commit 25b2bd4c96
8 changed files with 152 additions and 15 deletions

View file

@ -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

View file

@ -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()

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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.