v0.1.1 7141aff

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

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