v0.10.0 8a8e2b3

rightbar.go RawHistoryFolder

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

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