feat: render interactive tui

This commit is contained in:
Vincent Koc 2026-04-27 03:21:33 -07:00
parent 7d04391940
commit 8e02c2c8c5
No known key found for this signature in database
4 changed files with 295 additions and 11 deletions

5
go.mod
View File

@ -3,17 +3,18 @@ module github.com/openclaw/gitcrawl
go 1.26.2
require (
github.com/mattn/go-isatty v0.0.20
github.com/pelletier/go-toml/v2 v2.3.0
golang.org/x/term v0.42.0
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

6
go.sum
View File

@ -19,8 +19,10 @@ golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=

View File

@ -456,13 +456,17 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
if clusters == nil {
clusters = []store.ClusterSummary{}
}
return a.writeOutput("tui", map[string]any{
"repository": repo.FullName,
"inferred_repository": inferred,
"mode": "cluster-browser",
"sort": sort,
"clusters": clusters,
}, true)
payload := clusterBrowserPayload{
Repository: repo.FullName,
InferredRepository: inferred,
Mode: "cluster-browser",
Sort: sort,
Clusters: clusters,
}
if a.format != FormatText || !a.canRunInteractiveTUI() {
return a.writeOutput("tui", payload, true)
}
return a.runInteractiveTUI(payload)
}
func (a *App) resolveOptionalRepository(ctx context.Context, rt localRuntime, args []string) (store.Repository, bool, error) {

277
internal/cli/tui.go Normal file
View File

@ -0,0 +1,277 @@
package cli
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
"github.com/mattn/go-isatty"
"github.com/openclaw/gitcrawl/internal/store"
"golang.org/x/term"
)
type clusterBrowserPayload struct {
Repository string `json:"repository"`
InferredRepository bool `json:"inferred_repository"`
Mode string `json:"mode"`
Sort string `json:"sort"`
Clusters []store.ClusterSummary `json:"clusters"`
}
type clusterBrowserModel struct {
payload clusterBrowserPayload
selected int
offset int
width int
height int
}
func (a *App) canRunInteractiveTUI() bool {
out, ok := a.Stdout.(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(out.Fd()) && isatty.IsTerminal(os.Stdin.Fd())
}
func (a *App) runInteractiveTUI(payload clusterBrowserPayload) error {
out, ok := a.Stdout.(*os.File)
if !ok {
return a.writeOutput("tui", payload, true)
}
return runClusterBrowserTUI(os.Stdin, out, payload)
}
func runClusterBrowserTUI(in *os.File, out *os.File, payload clusterBrowserPayload) error {
oldState, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return fmt.Errorf("enter raw terminal mode: %w", err)
}
defer func() {
_ = term.Restore(int(in.Fd()), oldState)
}()
fmt.Fprint(out, "\x1b[?1049h\x1b[?25l")
defer fmt.Fprint(out, "\x1b[?25h\x1b[?1049l")
model := clusterBrowserModel{payload: payload}
if err := renderClusterBrowser(out, &model); err != nil {
return err
}
var buf [16]byte
for {
n, err := in.Read(buf[:])
if err != nil {
return err
}
if n == 0 {
continue
}
if handleClusterBrowserInput(&model, string(buf[:n])) {
return nil
}
if err := renderClusterBrowser(out, &model); err != nil {
return err
}
}
}
func handleClusterBrowserInput(model *clusterBrowserModel, input string) bool {
switch input {
case "q", "Q", "\x03":
return true
case "j", "\x1b[B":
moveClusterSelection(model, 1)
case "k", "\x1b[A":
moveClusterSelection(model, -1)
case "\x06", "\x1b[6~":
moveClusterSelection(model, visibleClusterRows(model)-1)
case "\x02", "\x1b[5~":
moveClusterSelection(model, -(visibleClusterRows(model) - 1))
case "g":
model.selected = 0
case "G":
if len(model.payload.Clusters) > 0 {
model.selected = len(model.payload.Clusters) - 1
}
}
keepSelectionVisible(model)
return false
}
func moveClusterSelection(model *clusterBrowserModel, delta int) {
if len(model.payload.Clusters) == 0 {
model.selected = 0
return
}
model.selected += delta
if model.selected < 0 {
model.selected = 0
}
if model.selected >= len(model.payload.Clusters) {
model.selected = len(model.payload.Clusters) - 1
}
}
func keepSelectionVisible(model *clusterBrowserModel) {
rows := visibleClusterRows(model)
if rows <= 0 {
model.offset = 0
return
}
if model.selected < model.offset {
model.offset = model.selected
}
if model.selected >= model.offset+rows {
model.offset = model.selected - rows + 1
}
if model.offset < 0 {
model.offset = 0
}
}
func renderClusterBrowser(out io.Writer, model *clusterBrowserModel) error {
width, height, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 || height <= 0 {
width, height = 100, 32
}
model.width = width
model.height = height
keepSelectionVisible(model)
var b strings.Builder
b.WriteString("\x1b[H\x1b[2J")
writeTUILine(&b, width, "\x1b[1mGitcrawl\x1b[0m "+model.payload.Repository+" sort="+model.payload.Sort)
if model.payload.InferredRepository {
writeTUILine(&b, width, "repo inferred from local database")
} else {
writeTUILine(&b, width, "")
}
writeTUILine(&b, width, strings.Repeat("-", width))
rows := visibleClusterRows(model)
if len(model.payload.Clusters) == 0 {
writeTUILine(&b, width, "no clusters found")
} else {
end := model.offset + rows
if end > len(model.payload.Clusters) {
end = len(model.payload.Clusters)
}
for i := model.offset; i < end; i++ {
cluster := model.payload.Clusters[i]
marker := " "
styleStart := ""
styleEnd := ""
if i == model.selected {
marker = ">"
styleStart = "\x1b[7m"
styleEnd = "\x1b[0m"
}
number := ""
if cluster.RepresentativeNumber > 0 {
number = fmt.Sprintf(" #%d", cluster.RepresentativeNumber)
}
line := fmt.Sprintf("%s %-24s %4d %-12s%s %s",
marker,
truncateRunes(cluster.StableSlug, 24),
cluster.MemberCount,
truncateRunes(cluster.RepresentativeKind, 12),
number,
firstNonEmpty(cluster.RepresentativeTitle, cluster.Title),
)
writeTUIStyledLine(&b, width, styleStart, line, styleEnd)
}
}
for currentVisualLineCount(b.String()) < height-6 {
writeTUILine(&b, width, "")
}
writeTUILine(&b, width, strings.Repeat("-", width))
writeClusterDetail(&b, width, model)
writeTUILine(&b, width, "j/k or arrows move g/G jump ctrl-f/ctrl-b page q quit")
_, err = io.WriteString(out, b.String())
return err
}
func writeClusterDetail(b *strings.Builder, width int, model *clusterBrowserModel) {
if len(model.payload.Clusters) == 0 {
writeTUILine(b, width, "")
writeTUILine(b, width, "")
return
}
cluster := model.payload.Clusters[model.selected]
writeTUILine(b, width, fmt.Sprintf("%s %s members=%d status=%s",
cluster.StableSlug,
threadRef(cluster),
cluster.MemberCount,
firstNonEmpty(cluster.Status, "unknown"),
))
writeTUILine(b, width, firstNonEmpty(cluster.RepresentativeTitle, cluster.Title))
writeTUILine(b, width, "updated "+cluster.UpdatedAt)
}
func threadRef(cluster store.ClusterSummary) string {
if cluster.RepresentativeNumber == 0 {
return ""
}
if cluster.RepresentativeKind == "" {
return fmt.Sprintf("#%d", cluster.RepresentativeNumber)
}
return fmt.Sprintf("%s #%d", cluster.RepresentativeKind, cluster.RepresentativeNumber)
}
func visibleClusterRows(model *clusterBrowserModel) int {
rows := model.height - 9
if rows < 1 {
return 1
}
return rows
}
func writeTUILine(b *strings.Builder, width int, line string) {
if width <= 0 {
width = 80
}
b.WriteString(truncateRunes(line, width))
b.WriteString("\r\n")
}
func writeTUIStyledLine(b *strings.Builder, width int, styleStart, line, styleEnd string) {
if width <= 0 {
width = 80
}
b.WriteString(styleStart)
b.WriteString(truncateRunes(line, width))
b.WriteString(styleEnd)
b.WriteString("\r\n")
}
func truncateRunes(value string, max int) string {
if max <= 0 {
return ""
}
if utf8.RuneCountInString(value) <= max {
return value
}
if max <= 3 {
return strings.Repeat(".", max)
}
runes := []rune(value)
return string(runes[:max-3]) + "..."
}
func currentVisualLineCount(value string) int {
return strings.Count(value, "\n")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}