detached 25b2bd4

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
362362 | `ctrl+d` | Exit on empty input. |
363363 | `ctrl+l` | Redraw the screen. |
364364 | `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.
365379
366380 ### Editor line navigation
367381
@@ -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 navigation380 ### Editor line navigation
367 381
M internal/agent/changelog.go +72 -7
@@ -27,14 +27,22 @@ type ChangelogInfo struct {
2727 // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so
2828 // startup never blocks on a flaky network.
2929 func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) {
30- if version == "" || version == "dev" || version == "0.0.0" {
30+ if version == "" || version == "dev" {
3131 return ChangelogInfo{}, nil
3232 }
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)
3645 }
37- url := fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag)
3846 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
3947 if err != nil {
4048 return ChangelogInfo{}, err
@@ -67,6 +75,11 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error)
6775 if body.Body == "" {
6876 return ChangelogInfo{}, nil
6977 }
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+ }
7083 return ChangelogInfo{
7184 Version: strings.TrimPrefix(body.TagName, "v"),
7285 Body: body.Body,
@@ -74,6 +87,52 @@ func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error)
7487 }, nil
7588 }
7689
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+
77136 // FetchChangelogAsync runs FetchChangelog on a goroutine and delivers
78137 // the result on the returned channel. Channel always closes.
79138 func FetchChangelogAsync(version string) <-chan ChangelogInfo {
@@ -94,12 +153,18 @@ func FetchChangelogAsync(version string) <-chan ChangelogInfo {
94153 // the first-ever launch (no LastChangelogShown stored — we don't
95154 // dump release notes at someone who just installed).
96155 func ShouldShowChangelog(currentVersion string, cfg Config) bool {
97- if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
156+ if currentVersion == "" || currentVersion == "dev" {
98157 return false
99158 }
100159 if cfg.LastChangelogShown == "" {
101160 return false
102161 }
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+ }
103168 return cfg.LastChangelogShown != currentVersion
104169 }
105170
@@ -120,7 +185,7 @@ func MarkChangelogShown(version string) error {
120185 // correctly trigger the dialog while THIS launch (which is also
121186 // "first-ever") doesn't.
122187 func SeedChangelogVersion(version string) {
123- if version == "" || version == "dev" || version == "0.0.0" {
188+ if version == "" || version == "dev" {
124189 return
125190 }
126191 cfg, err := LoadConfig()
@@ -27,14 +27,22 @@ type ChangelogInfo struct {
27 // Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so27 // 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{}, nil31 return ChangelogInfo{}, nil
32 }32 }
33- tag := version33+
34- if !strings.HasPrefix(tag, "v") {34+ // For local dev builds (0.0.0), fetch the latest release instead
35- tag = "v" + tag35+ // 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{}, err48 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{}, nil76 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 }, nil87 }, 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 delivers136 // 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't153 // 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 false157 return false
99 }158 }
100 if cfg.LastChangelogShown == "" {159 if cfg.LastChangelogShown == "" {
101 return false160 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 != currentVersion168 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 also185 // 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 return189 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 {
554554 if info.Body == "" {
555555 return
556556 }
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+ }
557562 changelogCh <- modes.ChangelogPayload{
558563 Version: info.Version,
559564 Body: info.Body,
@@ -610,7 +615,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
610615 Extensions: extMgr,
611616 ChangelogChan: changelogCh,
612617 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)
614628 },
615629 SkillSnapshot: func() []*skills.Skill {
616630 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 return555 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 {
8383 out = append(out, "")
8484 }
8585
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+ }
88100
89101 const maxRows = 18
90102 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 = 18101 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) {
16551655
16561656 // CancelTurn aborts the active turn if one is running. Used by the
16571657 // 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+
16581668 func (i *Interactive) CancelTurn() {
16591669 i.mu.Lock()
16601670 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 the1656 // 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.cancelTurn1670 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 {
8686 lines = append(lines, frameHeader(th, "session", width))
8787 lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):"))
8888 for i, it := range d.items {
89- plain := " " + it.label
89+ text := " " + it.label
9090 if it.hint != "" {
91- plain += " " + th.FG256(th.Muted, "("+it.hint+")")
91+ text += " (" + it.hint + ")"
9292 }
9393 if i == d.cursor {
94- lines = append(lines, th.PadHighlight(plain, width))
94+ lines = append(lines, th.PadHighlight(text, width))
9595 } else {
96- lines = append(lines, th.FG256(th.Muted, plain))
96+ lines = append(lines, th.FG256(th.Muted, text))
9797 }
9898 }
9999 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.label89+ 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 {
118118 }
119119 rw := runewidthRune(r)
120120 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+ }
121140 break
122141 }
123142 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 break140 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 {
7272 if visible < width {
7373 s += strings.Repeat(" ", width-visible)
7474 }
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
7679 }
7780
7881 // 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 + reset75+ // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close