mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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:
parent
353da72d28
commit
25b2bd4c96
8 changed files with 152 additions and 15 deletions
14
README.md
14
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue