From 8e02c2c8c53298b79e021ee4182cd8fae7409180 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 03:21:33 -0700 Subject: [PATCH] feat: render interactive tui --- go.mod | 5 +- go.sum | 6 +- internal/cli/app.go | 18 +-- internal/cli/tui.go | 277 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 internal/cli/tui.go diff --git a/go.mod b/go.mod index e59c6e6..e05d604 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 33e5044..874d1a9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cli/app.go b/internal/cli/app.go index 5970fd7..9a08bbf 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -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) { diff --git a/internal/cli/tui.go b/internal/cli/tui.go new file mode 100644 index 0000000..2caa2e9 --- /dev/null +++ b/internal/cli/tui.go @@ -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 "" +}