mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-28 06:13:42 +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+d` | Exit on empty input. |
|
||||||
| `ctrl+l` | Redraw the screen. |
|
| `ctrl+l` | Redraw the screen. |
|
||||||
| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
|
| `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
|
### Editor line navigation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,22 @@ type ChangelogInfo struct {
|
||||||
// Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so
|
// Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so
|
||||||
// startup never blocks on a flaky network.
|
// startup never blocks on a flaky network.
|
||||||
func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) {
|
func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) {
|
||||||
if version == "" || version == "dev" || version == "0.0.0" {
|
if version == "" || version == "dev" {
|
||||||
return ChangelogInfo{}, nil
|
return ChangelogInfo{}, nil
|
||||||
}
|
}
|
||||||
tag := version
|
|
||||||
if !strings.HasPrefix(tag, "v") {
|
// For local dev builds (0.0.0), fetch the latest release instead
|
||||||
tag = "v" + tag
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ChangelogInfo{}, err
|
return ChangelogInfo{}, err
|
||||||
|
|
@ -67,6 +75,11 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error)
|
||||||
if body.Body == "" {
|
if body.Body == "" {
|
||||||
return ChangelogInfo{}, nil
|
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{
|
return ChangelogInfo{
|
||||||
Version: strings.TrimPrefix(body.TagName, "v"),
|
Version: strings.TrimPrefix(body.TagName, "v"),
|
||||||
Body: body.Body,
|
Body: body.Body,
|
||||||
|
|
@ -74,6 +87,52 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error)
|
||||||
}, nil
|
}, 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
|
// FetchChangelogAsync runs FetchChangelog on a goroutine and delivers
|
||||||
// the result on the returned channel. Channel always closes.
|
// the result on the returned channel. Channel always closes.
|
||||||
func FetchChangelogAsync(version string) <-chan ChangelogInfo {
|
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
|
// the first-ever launch (no LastChangelogShown stored — we don't
|
||||||
// dump release notes at someone who just installed).
|
// dump release notes at someone who just installed).
|
||||||
func ShouldShowChangelog(currentVersion string, cfg Config) bool {
|
func ShouldShowChangelog(currentVersion string, cfg Config) bool {
|
||||||
if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
|
if currentVersion == "" || currentVersion == "dev" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.LastChangelogShown == "" {
|
if cfg.LastChangelogShown == "" {
|
||||||
return false
|
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
|
return cfg.LastChangelogShown != currentVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +185,7 @@ func MarkChangelogShown(version string) error {
|
||||||
// correctly trigger the dialog while THIS launch (which is also
|
// correctly trigger the dialog while THIS launch (which is also
|
||||||
// "first-ever") doesn't.
|
// "first-ever") doesn't.
|
||||||
func SeedChangelogVersion(version string) {
|
func SeedChangelogVersion(version string) {
|
||||||
if version == "" || version == "dev" || version == "0.0.0" {
|
if version == "" || version == "dev" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg, err := LoadConfig()
|
cfg, err := LoadConfig()
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
|
||||||
if info.Body == "" {
|
if info.Body == "" {
|
||||||
return
|
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{
|
changelogCh <- modes.ChangelogPayload{
|
||||||
Version: info.Version,
|
Version: info.Version,
|
||||||
Body: info.Body,
|
Body: info.Body,
|
||||||
|
|
@ -610,7 +615,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
|
||||||
Extensions: extMgr,
|
Extensions: extMgr,
|
||||||
ChangelogChan: changelogCh,
|
ChangelogChan: changelogCh,
|
||||||
OnChangelogDismiss: func() {
|
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 {
|
SkillSnapshot: func() []*skills.Skill {
|
||||||
if args.NoSkill {
|
if args.NoSkill {
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,20 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
|
||||||
out = append(out, "")
|
out = append(out, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered := tui.RenderMarkdown(d.body, th, width-4)
|
var bodyLines []string
|
||||||
bodyLines := strings.Split(rendered, "\n")
|
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
|
const maxRows = 18
|
||||||
if d.scroll > len(bodyLines)-1 {
|
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
|
// CancelTurn aborts the active turn if one is running. Used by the
|
||||||
// telegram bridge when the paired user sends /stop.
|
// 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() {
|
func (i *Interactive) CancelTurn() {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
cancel := i.cancelTurn
|
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, frameHeader(th, "session", width))
|
||||||
lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
|
lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
|
||||||
for i, it := range d.items {
|
for i, it := range d.items {
|
||||||
plain := " " + it.label
|
text := " " + it.label
|
||||||
if it.hint != "" {
|
if it.hint != "" {
|
||||||
plain += " " + th.FG256(th.Muted, "("+it.hint+")")
|
text += " (" + it.hint + ")"
|
||||||
}
|
}
|
||||||
if i == d.cursor {
|
if i == d.cursor {
|
||||||
lines = append(lines, th.PadHighlight(plain, width))
|
lines = append(lines, th.PadHighlight(text, width))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, th.FG256(th.Muted, plain))
|
lines = append(lines, th.FG256(th.Muted, text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines = append(lines, frameRule(th, width))
|
lines = append(lines, frameRule(th, width))
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,25 @@ func truncateToWidth(s string, cols int) string {
|
||||||
}
|
}
|
||||||
rw := runewidthRune(r)
|
rw := runewidthRune(r)
|
||||||
if seen+rw > cols {
|
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
|
break
|
||||||
}
|
}
|
||||||
out.WriteRune(r)
|
out.WriteRune(r)
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,10 @@ func (t Theme) PadHighlight(s string, width int) string {
|
||||||
if visible < width {
|
if visible < width {
|
||||||
s += strings.Repeat(" ", width-visible)
|
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.
|
// Bold wraps s in bold SGR.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue