Skip to content

Commit a169e1d

Browse files
ecgangclaude
andcommitted
feat(enable): show live progress while importing existing sessions
First-run 'entire enable' could sit silent for the whole session import (an hour on a large corpus) between the setup summary and the final Imported line. Wire the agentimport Progress reporter into user-visible output via one shared helper: - interactive terminals get the standard spinner, extended with an updatable message (startUpdatableSpinner; startSpinner now delegates to it) tracking 'session i/N - turn j/M' live - non-TTY and ACCESSIBLE runs get one plain ANSI-free line per session, keeping output complete for agents and screen readers - the standalone 'entire import <agent>' command gets the same wiring The final summary line is unchanged, and the spinner stops before any error path prints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01KY93WZV7TJ0P4CSY40NYGZ1Q
1 parent cb07849 commit a169e1d

6 files changed

Lines changed: 260 additions & 8 deletions

File tree

cmd/entire/cli/import_cmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,14 @@ fails even with --dry-run.`, imp.AgentType()),
8080
linkCommitSHA := resolveImportLinkCommitSHA(repo)
8181
logging.Debug(ctx, "import: resolved link commit", "commit_sha", linkCommitSHA)
8282

83+
progress, stopProgress := newImportProgressReporter(c.OutOrStdout(), string(imp.AgentType()))
8384
res, err := agentimport.Run(ctx, repo, imp, agentimport.Options{
8485
RepoRoot: repoRoot, OverridePath: pathFlag, SessionFilter: sessions,
8586
Now: time.Now(), DryRun: dryRun,
8687
LinkCommitSHA: linkCommitSHA,
88+
Progress: progress,
8789
})
90+
stopProgress(err == nil)
8891
if err != nil {
8992
return fmt.Errorf("import %s: %w", imp.Name(), err)
9093
}

cmd/entire/cli/import_progress.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/entireio/cli/cmd/entire/cli/agentimport"
8+
"github.com/entireio/cli/cmd/entire/cli/interactive"
9+
)
10+
11+
// newImportProgressReporter wires an agentimport.Progress to user-visible
12+
// output on w for one agent's import run. On an interactive terminal
13+
// (outside ACCESSIBLE mode) it drives an updatable spinner whose message
14+
// tracks "Importing <agentName> sessions... (session i/N · turn j/M)";
15+
// callers must call the returned stop exactly once when the run finishes —
16+
// on both the success and error paths — so no spinner frame is left
17+
// dangling to corrupt whatever prints next. Otherwise (non-TTY, piped, or
18+
// ACCESSIBLE mode) it prints one plain, ANSI-free line per session from
19+
// SessionStart, and stop is a no-op.
20+
func newImportProgressReporter(w io.Writer, agentName string) (progress *agentimport.Progress, stop func(success bool)) {
21+
if !interactive.IsTerminalWriter(w) || IsAccessibleMode() {
22+
return &agentimport.Progress{
23+
SessionStart: func(sessionIndex, sessionTotal int, _, _ string, turnCount int) {
24+
fmt.Fprintf(w, "Importing %s session %d/%d (%d %s)...\n",
25+
agentName, sessionIndex+1, sessionTotal, turnCount, pluralize("turn", turnCount))
26+
},
27+
}, func(bool) {}
28+
}
29+
30+
update, spinnerStop := startUpdatableSpinner(w, fmt.Sprintf("Importing %s sessions...", agentName))
31+
var curSession, curSessionTotal, curTurnTotal int
32+
render := func(turnsDone int) {
33+
update(fmt.Sprintf("Importing %s sessions... (session %d/%d · turn %d/%d)",
34+
agentName, curSession, curSessionTotal, turnsDone, curTurnTotal))
35+
}
36+
progress = &agentimport.Progress{
37+
SessionStart: func(sessionIndex, sessionTotal int, _, _ string, turnCount int) {
38+
curSession, curSessionTotal, curTurnTotal = sessionIndex+1, sessionTotal, turnCount
39+
render(0)
40+
},
41+
TurnWritten: func(_, turnIndex, _ int) {
42+
render(turnIndex + 1)
43+
},
44+
}
45+
return progress, spinnerStop
46+
}

cmd/entire/cli/progress.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"fmt"
55
"io"
6+
"sync"
67
"time"
78

89
"github.com/entireio/cli/cmd/entire/cli/interactive"
@@ -26,10 +27,35 @@ const (
2627
// On non-terminal writers the animation is omitted but stop(true) still
2728
// prints the completion line.
2829
func startSpinner(w io.Writer, msg string) func(success bool) {
30+
_, stop := startUpdatableSpinner(w, msg)
31+
return stop
32+
}
33+
34+
// startUpdatableSpinner is startSpinner's variant for an operation whose
35+
// status text changes while it runs (e.g. "session 2/5 · turn 3/10"). update
36+
// replaces the message the next frame draws — or, on a non-terminal writer,
37+
// the message stop's completion line uses. update is safe to call at any
38+
// point, including before the spinner's first frame draws and after stop
39+
// returns. stop behaves exactly like startSpinner's, rendering whichever
40+
// message update last set (or msg, if update was never called).
41+
func startUpdatableSpinner(w io.Writer, msg string) (update func(string), stop func(success bool)) {
42+
var mu sync.Mutex
43+
current := msg
44+
setMsg := func(m string) {
45+
mu.Lock()
46+
current = m
47+
mu.Unlock()
48+
}
49+
getMsg := func() string {
50+
mu.Lock()
51+
defer mu.Unlock()
52+
return current
53+
}
54+
2955
if !interactive.IsTerminalWriter(w) {
30-
return func(success bool) {
56+
return setMsg, func(success bool) {
3157
if success {
32-
fmt.Fprintf(w, "✓ %s\n", msg)
58+
fmt.Fprintf(w, "✓ %s\n", getMsg())
3359
}
3460
}
3561
}
@@ -46,23 +72,27 @@ func startSpinner(w io.Writer, msg string) func(success bool) {
4672
ticker := time.NewTicker(spinnerInterval)
4773
defer ticker.Stop()
4874
frame := 0
49-
fmt.Fprintf(w, "\r%s %s", spinnerFrames[frame], msg)
50-
frame = (frame + 1) % len(spinnerFrames)
75+
draw := func() {
76+
// \033[K clears the rest of the line so a shorter message
77+
// (update shrank it) doesn't leave stale trailing characters.
78+
fmt.Fprintf(w, "\r\033[K%s %s", spinnerFrames[frame], getMsg())
79+
frame = (frame + 1) % len(spinnerFrames)
80+
}
81+
draw()
5182
for {
5283
select {
5384
case <-done:
5485
return
5586
case <-ticker.C:
56-
fmt.Fprintf(w, "\r%s %s", spinnerFrames[frame], msg)
57-
frame = (frame + 1) % len(spinnerFrames)
87+
draw()
5888
}
5989
}
6090
}()
61-
return func(success bool) {
91+
return setMsg, func(success bool) {
6292
close(done)
6393
<-stopped
6494
if success {
65-
fmt.Fprintf(w, "\r\033[K✓ %s\n", msg)
95+
fmt.Fprintf(w, "\r\033[K✓ %s\n", getMsg())
6696
return
6797
}
6898
fmt.Fprint(w, "\r\033[K")

cmd/entire/cli/progress_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
// TestStartSpinner_NonTTYFallback locks in startSpinner's non-terminal
9+
// contract now that it delegates to startUpdatableSpinner: no animation, and
10+
// stop(true)/stop(false) behave exactly as they did before the refactor.
11+
func TestStartSpinner_NonTTYFallback(t *testing.T) {
12+
t.Parallel()
13+
tests := []struct {
14+
name string
15+
success bool
16+
want string
17+
}{
18+
{name: "success prints completion line", success: true, want: "✓ doing work\n"},
19+
{name: "failure prints nothing", success: false, want: ""},
20+
}
21+
for _, tt := range tests {
22+
t.Run(tt.name, func(t *testing.T) {
23+
t.Parallel()
24+
var buf bytes.Buffer
25+
stop := startSpinner(&buf, "doing work")
26+
stop(tt.success)
27+
if got := buf.String(); got != tt.want {
28+
t.Errorf("stop(%v) = %q, want %q", tt.success, got, tt.want)
29+
}
30+
})
31+
}
32+
}
33+
34+
// TestStartUpdatableSpinner_NonTTYUpdateBeforeAnyDraw proves update is safe to
35+
// call before anything has been drawn (a non-terminal writer never draws an
36+
// in-flight frame at all, so every call here is "before the first draw") and
37+
// that stop renders whichever message was set last.
38+
func TestStartUpdatableSpinner_NonTTYUpdateBeforeAnyDraw(t *testing.T) {
39+
t.Parallel()
40+
var buf bytes.Buffer
41+
update, stop := startUpdatableSpinner(&buf, "starting")
42+
update("session 1/2 · turn 1/2")
43+
update("session 2/2 · turn 2/2")
44+
stop(true)
45+
if got, want := buf.String(), "✓ session 2/2 · turn 2/2\n"; got != want {
46+
t.Errorf("stop(true) after updates = %q, want %q", got, want)
47+
}
48+
}
49+
50+
// TestStartUpdatableSpinner_NonTTYStopFalseIgnoresUpdates proves a failed run
51+
// leaves no trace, regardless of how many updates preceded it.
52+
func TestStartUpdatableSpinner_NonTTYStopFalseIgnoresUpdates(t *testing.T) {
53+
t.Parallel()
54+
var buf bytes.Buffer
55+
update, stop := startUpdatableSpinner(&buf, "starting")
56+
update("mid-flight")
57+
stop(false)
58+
if got := buf.String(); got != "" {
59+
t.Errorf("stop(false) = %q, want empty (no dangling output)", got)
60+
}
61+
}
62+
63+
// TestStartUpdatableSpinner_NonTTYStopDoesNotPanicOnRepeatCalls documents the
64+
// non-terminal stop closure's existing idempotency: it never closes a
65+
// channel (that only happens on the terminal path), so calling it again is
66+
// safe — it just re-prints the completion line. This matches startSpinner's
67+
// pre-existing non-TTY behavior; the terminal path remains single-call only.
68+
func TestStartUpdatableSpinner_NonTTYStopDoesNotPanicOnRepeatCalls(t *testing.T) {
69+
t.Parallel()
70+
var buf bytes.Buffer
71+
_, stop := startUpdatableSpinner(&buf, "starting")
72+
stop(true)
73+
stop(true)
74+
if got, want := buf.String(), "✓ starting\n✓ starting\n"; got != want {
75+
t.Errorf("double stop(true) = %q, want %q", got, want)
76+
}
77+
}

cmd/entire/cli/setup_import.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,13 @@ func runSelectedImports(ctx context.Context, w io.Writer, repoRoot string, selec
223223

224224
var importedLocalHistory bool
225225
for _, e := range selected {
226+
progress, stopProgress := newImportProgressReporter(w, e.displayName)
226227
res, err := agentimport.Run(ctx, repo, e.imp, agentimport.Options{
227228
RepoRoot: repoRoot,
228229
Now: time.Now(),
230+
Progress: progress,
229231
})
232+
stopProgress(err == nil)
230233
if err != nil {
231234
logging.Warn(ctx, "session import failed", "agent", e.imp.Name(), "error", err)
232235
fmt.Fprintf(w, "Note: could not import %s history: %v\n", e.displayName, err)

cmd/entire/cli/setup_import_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"bytes"
55
"context"
66
"errors"
7+
"fmt"
78
"io"
89
"strings"
910
"testing"
11+
"time"
1012

1113
"github.com/entireio/cli/cmd/entire/cli/agent"
1214
"github.com/entireio/cli/cmd/entire/cli/agent/types"
@@ -282,3 +284,94 @@ func TestMaybeOfferSessionImport_PromptErrorIsBestEffort(t *testing.T) {
282284
t.Error("import ran after a prompt error; expected skip")
283285
}
284286
}
287+
288+
// fixedDiscoverImporter wraps a real agentimport.Importer but overrides
289+
// Discover to return a fixed, caller-supplied set of session files instead of
290+
// scanning the agent's real transcript directory. runSelectedImports (unlike
291+
// the standalone `entire import` command) has no --path flag to redirect
292+
// discovery, so this is the seam tests use to feed it a fixture.
293+
type fixedDiscoverImporter struct {
294+
agentimport.Importer
295+
296+
sessions []agentimport.SessionFile
297+
}
298+
299+
func (f fixedDiscoverImporter) Discover(string, string, time.Time, []string) ([]agentimport.SessionFile, error) {
300+
return f.sessions, nil
301+
}
302+
303+
// writeImportProgressFixtureSession writes a 2-turn Claude Code transcript
304+
// fixture, matching the format agentimport's claude importer parses.
305+
func writeImportProgressFixtureSession(t *testing.T, dir, name string) {
306+
t.Helper()
307+
content := strings.Join([]string{
308+
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
309+
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
310+
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
311+
}, "\n") + "\n"
312+
testutil.WriteFile(t, dir, name, content)
313+
}
314+
315+
// TestRunSelectedImports_NonTTYProgressLines proves the wired-in progress
316+
// reporter, running against a plain (non-terminal) writer, prints exactly one
317+
// plain line per session — carrying the agent name, its position, and its
318+
// turn count — writes no ANSI escapes, and leaves the pre-existing final
319+
// summary line unchanged.
320+
func TestRunSelectedImports_NonTTYProgressLines(t *testing.T) {
321+
// Not parallel: chdirs into a temp repo and performs real checkpoint writes.
322+
dir := t.TempDir()
323+
testutil.InitRepo(t, dir)
324+
testutil.WriteFile(t, dir, "f.txt", "x")
325+
testutil.GitAdd(t, dir, "f.txt")
326+
testutil.GitCommit(t, dir, "init")
327+
t.Chdir(dir)
328+
ctx := context.Background()
329+
330+
sessionsDir := t.TempDir()
331+
writeImportProgressFixtureSession(t, sessionsDir, "sess1.jsonl")
332+
writeImportProgressFixtureSession(t, sessionsDir, "sess2.jsonl")
333+
334+
var claudeImp agentimport.Importer
335+
for _, imp := range agentimport.All() {
336+
if imp.Name() == testAgentName {
337+
claudeImp = imp
338+
}
339+
}
340+
if claudeImp == nil {
341+
t.Fatal("claude-code importer not registered")
342+
}
343+
sessions, err := claudeImp.Discover(dir, sessionsDir, time.Now(), nil)
344+
if err != nil {
345+
t.Fatalf("discover fixture sessions: %v", err)
346+
}
347+
if len(sessions) != 2 {
348+
t.Fatalf("want 2 fixture sessions, got %d", len(sessions))
349+
}
350+
imp := fixedDiscoverImporter{Importer: claudeImp, sessions: sessions}
351+
agentName := string(claudeImp.AgentType())
352+
353+
var buf bytes.Buffer
354+
runSelectedImports(ctx, &buf, dir, []eligibleImport{{imp: imp, displayName: agentName}})
355+
out := buf.String()
356+
357+
if strings.ContainsRune(out, '\x1b') {
358+
t.Fatalf("output contains an ESC byte on a non-TTY writer: %q", out)
359+
}
360+
361+
wantLines := []string{
362+
fmt.Sprintf("Importing %s session 1/2 (2 turns)...", agentName),
363+
fmt.Sprintf("Importing %s session 2/2 (2 turns)...", agentName),
364+
}
365+
for _, line := range wantLines {
366+
if !strings.Contains(out, line) {
367+
t.Errorf("missing progress line %q in output:\n%s", line, out)
368+
}
369+
}
370+
if got := strings.Count(out, fmt.Sprintf("Importing %s session", agentName)); got != 2 {
371+
t.Errorf("got %d progress lines, want exactly 2 (one per session):\n%s", got, out)
372+
}
373+
374+
if want := "Imported 4 turn(s) from 2 session(s) (0 already imported).\n"; !strings.Contains(out, want) {
375+
t.Errorf("final summary line missing or changed; want %q in:\n%s", want, out)
376+
}
377+
}

0 commit comments

Comments
 (0)