v0.17.0 949637b

rightbar.go RawHistoryFolder

1package tui
2
3import (
4	"io"
5	"sort"
6	"strings"
7)
8
9const (
10	// RightBarSeparatorWidth is the one-cell divider between the main pane
11	// and the host-owned right bar.
12	RightBarSeparatorWidth = 1
13	// RightBarMinWidth keeps widget titles and checklist markers readable.
14	RightBarMinWidth = 24
15	// RightBarMaxWidth prevents a long extension line from taking most of
16	// the transcript on wide terminals.
17	RightBarMaxWidth = 36
18	// RightBarMinMainWidth preserves enough room for the editor and chat.
19	RightBarMinMainWidth = 48
20
21	// rightBarRightPadding leaves a small gutter between widget text and the
22	// terminal's right edge.
23	rightBarRightPadding = 1
24)
25
26// RightBarWidget is declarative content for one persistent extension widget.
27// The host owns ordering and layout; widgets are display-only in the first
28// version and should use OpenPanel for interaction.
29type RightBarWidget struct {
30	Extension string
31	ID        string
32	Title     string
33	Lines     []string
34}
35
36// RightBarColumns returns the main-pane and side-rail widths for a terminal.
37// It returns ok=false when keeping both panes usable would leave too little
38// room for the rail; callers should then render right-bar widgets using their
39// normal fallback placement.
40func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
41	if cols <= 0 {
42		return cols, 0, false
43	}
44	available := cols - RightBarSeparatorWidth
45	if available <= RightBarMinMainWidth {
46		return cols, 0, false
47	}
48
49	rightBarWidth = cols / 3
50	if rightBarWidth < RightBarMinWidth {
51		rightBarWidth = RightBarMinWidth
52	}
53	if rightBarWidth > RightBarMaxWidth {
54		rightBarWidth = RightBarMaxWidth
55	}
56	if available-rightBarWidth < RightBarMinMainWidth {
57		rightBarWidth = available - RightBarMinMainWidth
58	}
59	if rightBarWidth < RightBarMinWidth {
60		return cols, 0, false
61	}
62	return available - rightBarWidth, rightBarWidth, true
63}
64
65// RenderRightBar renders a bounded, full-height side rail without adding a
66// second frame around the host-owned separator. Widgets are sorted by
67// extension name and ID so output stays deterministic even when extension
68// frames arrive concurrently. Long lines are clipped with a three-dot ellipsis
69// while reserving one cell of right padding.
70func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
71	if width <= 0 || height <= 0 {
72		return nil
73	}
74
75	contentWidth := width - rightBarRightPadding
76	ordered := append([]RightBarWidget(nil), widgets...)
77	sort.SliceStable(ordered, func(a, b int) bool {
78		if ordered[a].Extension != ordered[b].Extension {
79			return ordered[a].Extension < ordered[b].Extension
80		}
81		return ordered[a].ID < ordered[b].ID
82	})
83
84	content := make([]string, 0, height)
85	truncated := false
86	padLine := func(text string, phase bool) string {
87		if contentWidth <= 0 {
88			return strings.Repeat(" ", width)
89		}
90		if phase {
91			text = truncateRightBarPhaseLine(text, contentWidth)
92		} else {
93			text = truncateRightBarLine(text, contentWidth)
94		}
95		if visible := visibleWidth(text); visible < width {
96			text += strings.Repeat(" ", width-visible)
97		}
98		return text
99	}
100	paintLine := func(text string, color *TerminalColor, dim, phase bool) string {
101		text = padLine(text, phase)
102		if color == nil {
103			return text
104		}
105		paint := func(value string, valueColor TerminalColor) string {
106			if dim {
107				value = Dim(value)
108			}
109			return th.FGColor(valueColor, value)
110		}
111		if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
112			if phase {
113				if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
114					return paint(text[:markerStart], *color) +
115						paint(text[markerStart:markerEnd], th.Muted) +
116						paint(text[markerEnd:nameStart], *color) +
117						paint(Bold(text[nameStart:nameEnd]), *color) +
118						paint(text[nameEnd:], *color)
119				}
120			}
121			return paint(text[:markerStart], *color) +
122				paint(text[markerStart:markerEnd], th.Muted) +
123				paint(text[markerEnd:], *color)
124		}
125		if phase {
126			return paint(Bold(text), *color)
127		}
128		return paint(text, *color)
129	}
130	appendContent := func(text string, color *TerminalColor, dim, phase bool) bool {
131		text = strings.ReplaceAll(text, "\r", "")
132		for _, line := range strings.Split(text, "\n") {
133			if len(content) >= height {
134				truncated = true
135				return false
136			}
137			content = append(content, paintLine(line, color, dim, phase))
138		}
139		return true
140	}
141
142	for idx, widget := range ordered {
143		if idx > 0 && !appendContent("", nil, false, false) {
144			break
145		}
146		label := "[" + widget.Extension + "]"
147		if widget.Title != "" {
148			label += " " + widget.Title
149		}
150		if !appendContent(label, &th.Accent, false, false) {
151			break
152		}
153		if !appendContent("", nil, false, false) {
154			break
155		}
156		activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
157		if activePhaseIndent >= 0 {
158			activePhaseIndent++ // host-owned line prefix
159		}
160		phaseIndent := rightBarChecklistIndent(widget.Lines)
161		if phaseIndent >= 0 {
162			phaseIndent++ // host-owned line prefix
163		}
164		activePhase := false
165		for _, line := range widget.Lines {
166			line = " " + line
167			dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
168			phase := rightBarChecklistPhaseLine(line, phaseIndent)
169			if !appendContent(line, &th.FG, dim, phase) {
170				break
171			}
172		}
173		if truncated {
174			break
175		}
176	}
177	if truncated {
178		marker := paintLine("... right bar ...", &th.Muted, false, false)
179		if len(content) == height {
180			content[len(content)-1] = marker
181		} else {
182			content = append(content, marker)
183		}
184	}
185	for len(content) < height {
186		content = append(content, strings.Repeat(" ", width))
187	}
188	return content
189}
190
191func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
192	start = 0
193	for start < len(text) && text[start] == ' ' {
194		start++
195	}
196	if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
197		return 0, 0, 0, false
198	}
199	switch text[start+1] {
200	case ' ', 'x', 'X', '>':
201		return start, start + 3, text[start+1], true
202	default:
203		return 0, 0, 0, false
204	}
205}
206
207func truncateRightBarLine(text string, width int) string {
208	if width <= 0 || visibleWidth(text) <= width {
209		return text
210	}
211	if width <= 3 {
212		return truncateToWidth("...", width)
213	}
214	return truncateToWidth(text, width-3) + "..."
215}
216
217func truncateRightBarPhaseLine(text string, width int) string {
218	if width <= 0 || visibleWidth(text) <= width {
219		return text
220	}
221	nameStart, nameEnd, ok := rightBarChecklistPhaseName(text)
222	if !ok {
223		return truncateRightBarLine(text, width)
224	}
225	prefix := text[:nameStart]
226	suffix := text[nameEnd:]
227	nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
228	if nameWidth == 0 {
229		return prefix + suffix
230	}
231	if nameWidth < 0 {
232		return truncateRightBarLine(text, width)
233	}
234	return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
235}
236
237func rightBarChecklistIndent(lines []string) int {
238	indent := -1
239	for _, line := range lines {
240		start, _, _, ok := rightBarChecklistMarker(line)
241		if ok && (indent < 0 || start < indent) {
242			indent = start
243		}
244	}
245	return indent
246}
247
248func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
249	if phaseIndent < 0 {
250		return false
251	}
252	start, _, _, ok := rightBarChecklistMarker(text)
253	return ok && start == phaseIndent
254}
255
256func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
257	_, markerEnd, _, markerOK := rightBarChecklistMarker(text)
258	if !markerOK {
259		return 0, 0, false
260	}
261	start = markerEnd
262	if start < len(text) && text[start] == ' ' {
263		start++
264	}
265	trimmedEnd := len(strings.TrimRight(text, " "))
266	if trimmedEnd <= start {
267		return 0, 0, false
268	}
269	suffix := text[start:trimmedEnd]
270	separator := strings.LastIndex(suffix, "  ")
271	if separator < 0 {
272		return rightBarFallbackPhaseName(text, start, trimmedEnd)
273	}
274	nameEnd := start + separator
275	progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
276	if !rightBarProgressToken(progress) {
277		return rightBarFallbackPhaseName(text, start, trimmedEnd)
278	}
279	for nameEnd > start && text[nameEnd-1] == ' ' {
280		nameEnd--
281	}
282	if nameEnd <= start {
283		return 0, 0, false
284	}
285	return start, nameEnd, true
286}
287
288func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
289	for end > start && text[end-1] == ' ' {
290		end--
291	}
292	if end-start >= 3 && text[end-3:end] == "..." {
293		end -= 3
294	}
295	for end > start && text[end-1] == ' ' {
296		end--
297	}
298	if end <= start {
299		return 0, 0, false
300	}
301	return start, end, true
302}
303
304func rightBarProgressToken(value string) bool {
305	separator := strings.IndexByte(value, '/')
306	if separator <= 0 || separator == len(value)-1 {
307		return false
308	}
309	for _, part := range []string{value[:separator], value[separator+1:]} {
310		for _, r := range part {
311			if r < '0' || r > '9' {
312				return false
313			}
314		}
315	}
316	return true
317}
318
319func rightBarActivePhaseIndent(lines []string) int {
320	for _, line := range lines {
321		start, _, marker, ok := rightBarChecklistMarker(line)
322		if ok && marker == '>' {
323			return start
324		}
325	}
326	return -1
327}
328
329func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
330	if activePhaseIndent < 0 {
331		return false
332	}
333	start, _, marker, ok := rightBarChecklistMarker(text)
334	if !ok {
335		return false
336	}
337	if start == activePhaseIndent {
338		*activePhase = marker == '>'
339		return !*activePhase
340	}
341	if start > activePhaseIndent {
342		return !*activePhase
343	}
344	return false
345}
346
347// JoinRightBar combines one main-pane row and one right-bar row without
348// allowing either side to soft-wrap. The returned row occupies exactly
349// mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
350func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
351	return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false)
352}
353
354func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string {
355	if mainWidth < 1 {
356		mainWidth = 1
357	}
358	if rightBarWidth < 1 {
359		rightBarWidth = 1
360	}
361	main = truncateToWidth(main, mainWidth)
362	if visible := visibleWidth(main); visible < mainWidth {
363		main += strings.Repeat(" ", mainWidth-visible)
364	}
365	rightBar = truncateToWidth(rightBar, rightBarWidth)
366	if visible := visibleWidth(rightBar); visible < rightBarWidth {
367		rightBar += strings.Repeat(" ", rightBarWidth-visible)
368	}
369	separator := th.FGColor(th.Muted, "│")
370	if dimSeparator {
371		separator = Dim(separator)
372	}
373	return main + separator + rightBar
374}
375
376// DrawRightBar renders the complete transcript through DrawLog, then
377// composites the persistent rail over the visible viewport. The flow cache
378// remains authoritative so appended chat still enters native scrollback.
379func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
380	r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
381}
382
383// DrawRightBarDimmed renders a right-bar frame whose separator is part of a
384// dimmed backdrop. Callers must pass already-dimmed main and right-bar rows.
385func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
386	r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true)
387}
388
389func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) {
390	if r.cols == 0 || r.rows == 0 {
391		return
392	}
393	mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
394	if !ok {
395		r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
396		return
397	}
398
399	if len(bottom) == 0 {
400		bottom = []string{""}
401	}
402
403	// Keep DrawLog's final blank margin unobstructed, matching the normal flow
404	// renderer and the previous right-bar layout.
405	frameRows := max(0, r.rows-1)
406	barLines := make([]string, frameRows)
407	copy(barLines, rightBar)
408
409	barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
410		r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
411	contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
412	flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
413	if flowUnchanged && !barChanged {
414		return
415	}
416	if !contentUnchanged && r.rightBarActive {
417		// Remove viewport chrome before DrawLog can naturally scroll a row into
418		// history. Otherwise every appended transcript row captures a stale
419		// copy of the rail in native terminal scrollback.
420		r.clearRightBarOverlay()
421	}
422	r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423	if contentUnchanged && !barChanged {
424		// Cursor-only updates do not disturb the composited rail.
425		return
426	}
427	r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428	r.rightBarActive = true
429	r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430	r.rightBarMainCols = mainWidth
431	r.rightBarDimmed = dimSeparator
432}
433
434// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
435// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
436// hardware-row model valid across the intermediate absolute writes.
437func (r *Renderer) clearRightBarOverlay() {
438	if !r.rightBarActive {
439		return
440	}
441	remaining := r.cols - r.rightBarMainCols
442	if remaining > 0 {
443		blank := strings.Repeat(" ", remaining)
444		var w strings.Builder
445		w.WriteString(SeqSynchronizedOn)
446		w.WriteString(SeqHideCursor)
447		for row := 0; row < max(0, r.rows-1); row++ {
448			w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449			w.WriteString("\x1b[0m")
450			w.WriteString(blank)
451		}
452		r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453		w.WriteString(SeqSynchronizedOff)
454		_, _ = io.WriteString(r.out, w.String())
455	}
456	r.rightBarActive = false
457	r.rightBarLines = nil
458	r.rightBarMainCols = 0
459	r.rightBarDimmed = false
460}
461
462func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463	var w strings.Builder
464	w.WriteString(SeqSynchronizedOn)
465	w.WriteString(SeqHideCursor)
466	for row, line := range rightBar {
467		w.WriteString(MoveTo(row+1, mainWidth+1))
468		w.WriteString("\x1b[0m")
469		w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470	}
471	r.appendTrackedLogCursor(&w, mainWidth)
472	w.WriteString(SeqSynchronizedOff)
473	_, _ = io.WriteString(r.out, w.String())
474}
475
476func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
477	if !r.logInit || r.cursorRow < 0 {
478		return
479	}
480	row := len(r.logChat) + r.cursorRow - r.logViewportTop
481	if row < 0 || row >= r.rows {
482		return
483	}
484	cursorCol := r.cursorCol
485	if cursorCol < 0 {
486		cursorCol = 0
487	}
488	if cursorCol >= contentCols {
489		cursorCol = contentCols - 1
490	}
491	w.WriteString(MoveTo(row+1, cursorCol+1))
492	w.WriteString(SeqShowCursor)
493}

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