Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions cmd/tusk/cmd_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
Expand Down Expand Up @@ -94,9 +95,20 @@ func serveGraph(ctx context.Context, cmd *cobra.Command, cfg graphConfig) error
return cwdErr
}

opts := []mcp.Option{mcp.WithAliasIntrospector(buildVerbIntrospector(cmd.Root()))}
if logger := mcpLoggerFromFlags(cmd); logger != nil {
opts = append(opts, mcp.WithLogger(logger))
// footer coordinates the interactive status footer with the background logs
// so a -v log line never glues onto the footer. It wraps stderr (where the
// logs already go) and stays a transparent passthrough until runConsole
// activates it on an interactive terminal. Building one logger over it and
// sharing it between the runtime and the view server keeps every background
// component's output flowing through the same coordinator.
footer := newFooterWriter(cmd.ErrOrStderr())

verbose, _ := cmd.Flags().GetBool("verbose")
logger := newLogger(footer, verbose)

opts := []mcp.Option{
mcp.WithAliasIntrospector(buildVerbIntrospector(cmd.Root())),
mcp.WithLogger(logger),
}

runtime, openErr := mcp.Open(cwd, opts...)
Expand All @@ -115,7 +127,7 @@ func serveGraph(ctx context.Context, cmd *cobra.Command, cfg graphConfig) error
Changes: graphview.NewChangeSource(runtime.Root, runtime.Meta),
Manifest: runtime.Manifest,
Embeddings: runtime.Embeddings,
Logger: mcpLoggerFromFlags(cmd),
Logger: logger,
AllowedHosts: graphAllowedHosts(cfg.addr),
}

Expand Down Expand Up @@ -143,7 +155,14 @@ func serveGraph(ctx context.Context, cmd *cobra.Command, cfg graphConfig) error
cfg.ready(listener.Addr().String())
}

httpServer := &http.Server{Handler: viewServer.Handler(), ReadHeaderTimeout: 10 * time.Second}
// Route the HTTP server's own error log through the footer coordinator too,
// so a stray net/http error line (default destination: os.Stderr) does not
// glue onto the interactive footer either.
httpServer := &http.Server{
Handler: viewServer.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: log.New(footer, "", 0),
}

serveErrCh := make(chan error, 1)
go func() { serveErrCh <- httpServer.Serve(listener) }()
Expand All @@ -153,7 +172,7 @@ func serveGraph(ctx context.Context, cmd *cobra.Command, cfg graphConfig) error
}

// Tilt-style foreground console (status line + keypress loop).
runConsole(ctx, cancel, cmd, viewServer, runtime, boundURL)
runConsole(ctx, cancel, cmd, viewServer, runtime, boundURL, footer)

cancel() // unblock RunBackground + viewServer.Run before draining

Expand Down
86 changes: 61 additions & 25 deletions cmd/tusk/cmd_graph_console.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,44 +17,52 @@ import (

// runConsole sets up raw-mode key reading (when stdin is a TTY) and drives the
// live console loop. It restores the terminal on return; on a quit key it calls
// cancel so background work unwinds.
func runConsole(ctx context.Context, cancel context.CancelFunc, cmd *cobra.Command, viewServer *graphview.Server, rt *mcp.Runtime, url string) {
out := cmd.OutOrStdout()
_, _ = fmt.Fprintf(out, "tusk graph — serving on %s\n", url)

status := func() { printStatus(out, viewServer, rt) }

// cancel so background work unwinds. footer is the shared coordinated writer the
// background verbose logs also flow through; runConsole activates it only on an
// interactive terminal so the sticky footer and log lines never collide.
func runConsole(ctx context.Context, cancel context.CancelFunc, cmd *cobra.Command, viewServer *graphview.Server, rt *mcp.Runtime, url string, footer *footerWriter) {
fd := int(os.Stdin.Fd())

if !term.IsTerminal(fd) {
// No TTY (CI/pipe): print status once and idle until ctx is cancelled.
status()
// No TTY (CI/pipe): print a plain status line once and idle. The footer
// stays passive, so background logs pass straight through to stderr.
printPlainStatus(cmd.OutOrStdout(), viewServer, rt, url)
<-ctx.Done()
_, _ = fmt.Fprintln(out)

return
}

oldState, makeRawErr := term.MakeRaw(fd)
if makeRawErr != nil {
status()
printPlainStatus(cmd.OutOrStdout(), viewServer, rt, url)
<-ctx.Done()
_, _ = fmt.Fprintln(out)

return
}

defer func() { _ = term.Restore(fd, oldState) }()

// Interactive: route the intro line, the sticky footer, and the background
// verbose logs through the one coordinated writer so a -v log line never glues
// onto the footer text. finish() leaves the cursor on a fresh line for the
// shell prompt.
_, _ = fmt.Fprintf(footer, "tusk graph — serving on %s\n", url)
footer.activate()

defer footer.finish()

status := func() { footer.setFooter(statusLine(viewServer, rt)) }
keys := readKeys(ctx)

consoleLoop(ctx, cancel, out, keys, status, func() { _ = openBrowser(url) })
consoleLoop(ctx, cancel, keys, status, func() { _ = openBrowser(url) })
}

// consoleLoop runs the status+keypress loop until ctx is done or a quit key
// (q/Q/Ctrl-C byte 3) arrives. On a quit key it calls cancel() so background
// goroutines unwind, then returns. Space invokes openURL. Pure and testable:
// the keys channel and side effects are injected.
func consoleLoop(ctx context.Context, cancel context.CancelFunc, out io.Writer, keys <-chan rune, status func(), openURL func()) {
// the keys channel and side effects are injected. Terminal cleanup (the final
// newline) is the caller's responsibility via footerWriter.finish.
func consoleLoop(ctx context.Context, cancel context.CancelFunc, keys <-chan rune, status func(), openURL func()) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()

Expand All @@ -63,13 +71,10 @@ func consoleLoop(ctx context.Context, cancel context.CancelFunc, out io.Writer,

select {
case <-ctx.Done():
_, _ = fmt.Fprintln(out)

return
case key, ok := <-keys:
if !ok {
<-ctx.Done()
_, _ = fmt.Fprintln(out)

return
}
Expand All @@ -79,7 +84,6 @@ func consoleLoop(ctx context.Context, cancel context.CancelFunc, out io.Writer,
openURL()
case 'q', 'Q', 3: // 3 = Ctrl-C in raw mode (ISIG disabled)
cancel()
_, _ = fmt.Fprintln(out)

return
}
Expand All @@ -88,16 +92,48 @@ func consoleLoop(ctx context.Context, cancel context.CancelFunc, out io.Writer,
}
}

func printStatus(out interface{ Write([]byte) (int, error) }, viewServer *graphview.Server, rt *mcp.Runtime) {
gen := "?"
if value, err := rt.Meta.Get("reindex_gen"); err == nil && value != "" {
gen = value
}
// printPlainStatus writes the intro and a single newline-terminated status line
// for the non-interactive path (CI, a pipe, or a failed raw-mode setup), where
// there is no sticky footer to redraw.
func printPlainStatus(out io.Writer, viewServer *graphview.Server, rt *mcp.Runtime, url string) {
_, _ = fmt.Fprintf(out, "tusk graph — serving on %s\n", url)
_, _ = fmt.Fprintln(out, statusLine(viewServer, rt))
}

// statusLine builds the one-line status text from live counts and the current
// reindex-walk state.
func statusLine(viewServer *graphview.Server, rt *mcp.Runtime) string {
nodeCount, _ := rt.Nodes.CountFileNodes()
edges, _ := rt.Edges.ListAll()

_, _ = fmt.Fprintf(out, "\r\033[Kindex gen %s · %d nodes · %d edges · %d clients [space] open [q] quit", gen, nodeCount, len(edges), viewServer.ClientCount())
return formatStatus(rt.WalkStatus.Snapshot(), nodeCount, len(edges), viewServer.ClientCount())
}

// formatStatus renders the graph console's one-line status footer from the
// current reindex-walk state and live counts. It deliberately does NOT surface
// the raw reindex generation counter: that counter bumps on every walk —
// including walks that change nothing — so a quiet, fully-indexed workspace
// leaves it frozen at some N, which readers mistake for N stuck pending items
// (the bug report this fixes was filed for exactly that). Instead it names the
// state — synced / indexing… / walk error — and, once a walk has completed,
// summarizes it (duration + nodes changed) so "idle and synced" is visibly
// distinct from "a walk is running" or "the last walk failed".
func formatStatus(snap mcp.WalkStatusSnapshot, nodeCount, edgeCount, clientCount int) string {
counts := fmt.Sprintf("%d nodes · %d edges · %d clients", nodeCount, edgeCount, clientCount)

const keys = "[space] open [q] quit"

switch {
case snap.Walking:
return fmt.Sprintf("indexing… · %s %s", counts, keys)
case snap.Last.Err != "":
return fmt.Sprintf("walk error · %s %s", counts, keys)
case snap.EverWalked:
return fmt.Sprintf("synced · %s · last walk %dms (%d changed) %s",
counts, snap.Last.DurationMs, snap.Last.Changed(), keys)
default:
return fmt.Sprintf("synced · %s %s", counts, keys)
}
}

// readKeys spawns a goroutine that reads single bytes from stdin and forwards
Expand Down
76 changes: 72 additions & 4 deletions cmd/tusk/cmd_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package main

import (
"context"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"

"github.com/germanamz/tusk/internal/mcp"
)

func TestGraphAllowedHosts(test *testing.T) {
Expand Down Expand Up @@ -83,6 +85,72 @@ func TestServeGraph_Integration(t *testing.T) {
}
}

func TestFormatStatus(test *testing.T) {
cases := []struct {
name string
snap mcp.WalkStatusSnapshot
wantContains []string
wantAbsent []string
}{
{
name: "walking shows an active indicator",
snap: mcp.WalkStatusSnapshot{Walking: true},
wantContains: []string{"indexing…", "2 nodes", "3 edges", "1 clients"},
wantAbsent: []string{"synced", "index gen", "last walk"},
},
{
name: "idle synced with a completed no-op walk",
snap: mcp.WalkStatusSnapshot{
EverWalked: true,
Last: mcp.WalkSummary{DurationMs: 2},
},
wantContains: []string{"synced", "last walk 2ms (0 changed)"},
wantAbsent: []string{"indexing", "index gen", "walk error"},
},
{
name: "idle synced summarizes real changes",
snap: mcp.WalkStatusSnapshot{
EverWalked: true,
Last: mcp.WalkSummary{Indexed: 3, Removed: 1, DurationMs: 12},
},
wantContains: []string{"synced", "last walk 12ms (4 changed)"},
},
{
name: "failed walk is not reported as synced",
snap: mcp.WalkStatusSnapshot{
EverWalked: true,
Last: mcp.WalkSummary{Err: "read error"},
},
wantContains: []string{"walk error"},
wantAbsent: []string{"synced", "last walk"},
},
{
name: "before the first walk it is not stuck",
snap: mcp.WalkStatusSnapshot{},
wantContains: []string{"synced"},
wantAbsent: []string{"index gen", "last walk", "indexing"},
},
}

for _, testCase := range cases {
test.Run(testCase.name, func(subtest *testing.T) {
got := formatStatus(testCase.snap, 2, 3, 1)

for _, want := range testCase.wantContains {
if !strings.Contains(got, want) {
subtest.Errorf("formatStatus = %q, want it to contain %q", got, want)
}
}

for _, absent := range testCase.wantAbsent {
if strings.Contains(got, absent) {
subtest.Errorf("formatStatus = %q, want it to NOT contain %q", got, absent)
}
}
})
}
}

func TestConsoleLoop_QuitKeyCancels(test *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand All @@ -93,7 +161,7 @@ func TestConsoleLoop_QuitKeyCancels(test *testing.T) {

done := make(chan struct{})
go func() {
consoleLoop(ctx, func() { cancelled = true }, io.Discard, keys, func() {}, func() {})
consoleLoop(ctx, func() { cancelled = true }, keys, func() {}, func() {})
close(done)
}()

Expand All @@ -114,7 +182,7 @@ func TestConsoleLoop_CtxDoneReturns(test *testing.T) {

done := make(chan struct{})
go func() {
consoleLoop(ctx, cancel, io.Discard, keys, func() {}, func() {})
consoleLoop(ctx, cancel, keys, func() {}, func() {})
close(done)
}()

Expand All @@ -136,7 +204,7 @@ func TestConsoleLoop_SpaceOpens(test *testing.T) {
keys <- ' '
keys <- 'q'

consoleLoop(ctx, cancel, io.Discard, keys, func() {}, func() { opened++ })
consoleLoop(ctx, cancel, keys, func() {}, func() { opened++ })

if opened != 1 {
test.Fatalf("openURL called %d times, want 1", opened)
Expand Down
Loading
Loading