From d995e3cf6e487ba7b2cad701edd85c04b2997e6d Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Sat, 25 Jul 2026 12:02:43 -0700 Subject: [PATCH 1/2] Strip stacked context envelopes from Codex user turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isInjectedUserText matched only a whole-message envelope: one known tag block, or the project-doc preamble followed by . Codex composes them. A catalog, then "# AGENTS.md instructions" and its block, arrive as one user turn — and the preamble heading does not always carry the " instructions for " spelling the old rule required. Nothing matched, so the machinery rendered as conversation: every read of such a session opened with it, and every cross-agent fork seeded the next agent with the user's global AGENTS.md. Strip envelopes one at a time and call the turn injected only when nothing a person wrote is left. That is stricter than the old rule in the right direction: matching on prefix alone, it would have discarded real prose that happened to follow a preamble. --- internal/codex/codex.go | 52 ++++++++++++++++++++++++++++++------ internal/codex/codex_test.go | 30 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal/codex/codex.go b/internal/codex/codex.go index d470701..990f40e 100644 --- a/internal/codex/codex.go +++ b/internal/codex/codex.go @@ -347,22 +347,58 @@ func joinContent(m codexMessage) string { return strings.Join(parts, "\n") } +// injectionTags wrap the environment and project context Codex feeds in as +// user turns. +var injectionTags = []string{"INSTRUCTIONS", "skill", "user_instructions", "environment_context", "system-reminder", "recommended_plugins"} + // isInjectedUserText reports whether a user message is environment/context that // Codex injects as a user turn rather than something the person typed: the -// project-doc preamble (e.g. "# AGENTS.md instructions for …"), or a block -// wholly wrapped in a known injection tag. It deliberately matches only the -// whole-message envelope so real prose is never dropped. +// project-doc preamble (e.g. "# AGENTS.md instructions for …"), or blocks +// wrapped in a known injection tag. +// +// One turn can carry several of these at once — a plugin catalog, then the +// project-doc preamble — so the test is that *nothing but* envelopes is there: +// strip them one by one and see whether anything a person wrote is left. That +// keeps the original guarantee (real prose is never dropped) while catching +// the composite turns a single whole-message match misses. func isInjectedUserText(s string) bool { t := strings.TrimSpace(s) - if strings.HasPrefix(t, "# ") && strings.Contains(firstLine(t), " instructions for ") && strings.Contains(t, "") { - return true + if t == "" { + return false } - for _, tag := range []string{"INSTRUCTIONS", "skill", "user_instructions", "environment_context", "system-reminder"} { - if strings.HasPrefix(t, "<"+tag+">") && strings.HasSuffix(t, "") { + for { + rest, ok := trimEnvelope(t) + if !ok { + return false + } + if t = strings.TrimSpace(rest); t == "" { return true } } - return false +} + +// trimEnvelope removes one leading injection envelope — a known tag block, or +// the heading line that introduces a project-doc block — and +// reports whether it found one. +func trimEnvelope(t string) (string, bool) { + for _, tag := range injectionTags { + open, closing := "<"+tag+">", "" + if !strings.HasPrefix(t, open) { + continue + } + if i := strings.Index(t, closing); i >= 0 { + return t[i+len(closing):], true + } + } + // The preamble is a heading naming the doc ("# AGENTS.md instructions + // for …", or just "# AGENTS.md instructions") ahead of its block; the + // block itself is what makes the match safe. + if strings.HasPrefix(t, "# ") && strings.Contains(firstLine(t), "instructions") { + if rest := strings.TrimSpace(strings.TrimPrefix(t, firstLine(t))); strings.HasPrefix(rest, "") { + return rest, true + } + } + return "", false } func firstLine(s string) string { diff --git a/internal/codex/codex_test.go b/internal/codex/codex_test.go index 681a48e..c826886 100644 --- a/internal/codex/codex_test.go +++ b/internal/codex/codex_test.go @@ -114,3 +114,33 @@ func TestListRecencyAndResolveByID(t *testing.T) { t.Errorf("resolved wrong session: %v", src.Metadata) } } + +// A user turn can arrive as several stacked envelopes — a plugin catalog, then +// the project-doc preamble — with nothing a person typed anywhere in it. Those +// are context, not conversation, and they seed forks as well as fill listings. +func TestIsInjectedUserText(t *testing.T) { + injected := []string{ + "\nplugin list\n\n# AGENTS.md instructions\n\n\nbe nice\n", + "# AGENTS.md instructions for /home/u/src/proj\n\n\nbe nice\n", + "context", + "cwd\na skill", + } + for _, s := range injected { + if !isInjectedUserText(s) { + t.Errorf("expected injected: %q", s) + } + } + + // Anything the person actually wrote keeps the whole turn, envelope and all. + kept := []string{ + "fix the auth flow", + "context\n\nfix the auth flow", + "# AGENTS.md instructions\n\nwrite them for me", + "", + } + for _, s := range kept { + if isInjectedUserText(s) { + t.Errorf("expected kept: %q", s) + } + } +} From 69cf9c38d3e037b483e01584a08fa543df186ac6 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Sat, 25 Jul 2026 12:02:55 -0700 Subject: [PATCH 2/2] Rework the listing and say what a fork is about to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing is where someone lands when the question is "where was I", and it answered with a rank that had to be combined with an agent name by hand, plus a 36-column session id nobody types. The id squeezed titles to 22 characters, truncating the only column that identifies a session. Show the handle instead — claude/3, the string that re-selects the row — and a relative age while "how long ago" is still the question, giving TITLE everything left over. Providers whose agent never named a session fall back to the directory name, which made every row in a cwd-scoped listing read identically; those rows now show the session's opening message, which is what tells them apart. The id stays in --json, where the scripts that want it live. A bare listing now spans every agent. More than one agent works in a directory — that is the premise of the tool — and a listing scoped to whichever agent happened to be newest hid half the answer. Ranks stay per-agent, so a handle means the same thing in either listing. Fork acts on an inferred source, and it is the one command that spends money on the guess: with --into, the wrong session seeds a launch. It now names the session on stderr before launching, early enough to interrupt while a large transcript is still being read. Same-agent --into is allowed with --last or --since-compact. Seeding an agent from its own session is a reseed rather than a resume: it sheds a spent context and keeps the work, which is what someone wants after finishing a task instead of losing one. The trims are rejected on a native fork, so their presence cannot be a mistyped one; bare same-agent --into is still refused, and now names both exits. --- README.md | 6 +- internal/cli/cli.go | 109 ++++++++++++++++++++++++++--- internal/cli/cli_test.go | 124 ++++++++++++++++++++++++++++++--- internal/render/common.go | 32 +++++++++ internal/render/render.go | 101 ++++++++++++++++++--------- internal/render/render_test.go | 99 +++++++++++++++++++------- 6 files changed, 397 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 752171f..27a8a0b 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,12 @@ Omit `` and catchup uses whichever agent has the newest session in this d **For you:** run in your terminal to re-enter a session: ```bash +catchup --list # where was I — recent sessions, every agent catchup fork # fork the newest session across agents catchup fork # fork that agent's newest session catchup fork codex --into claude # continue a Codex session in Claude +catchup fork claude --into claude --since-compact + # restart the same agent clean, keeping the work ``` **For agents:** run inside a session to read prior work: @@ -80,7 +83,7 @@ catchup --last 4 # read last 4 exchanges catchup --json # render JSON; also --html ``` -Use `fork` to continue with the same agent and keep native session state. Use `fork --into` to start another agent with the transcript. Use read commands when you want old work in a clean context. +Use `fork` to continue with the same agent and keep native session state. Use `fork --into` to start another agent with the transcript — or the same agent with `--last`/`--since-compact`, which restarts it clean on a trimmed transcript when the context is spent but the work isn't. Use read commands when you want old work in a clean context. ## Boundaries @@ -88,6 +91,7 @@ Use `fork` to continue with the same agent and keep native session state. Use `f - Conversation only. It strips tool calls, command output, and reasoning traces. - Read-only, except `fork`. - Same-agent `fork` uses the agent's native resume path, so it keeps real session state. +- Same-agent `fork --into` is the opposite trade: native state is dropped for a clean context. - Cross-agent `fork --into` seeds the new agent with a transcript, not native state. ## License diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 020ab1a..0a7d0e8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,10 +15,12 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "syscall" "time" + "github.com/mattn/go-runewidth" "github.com/wilbeibi/catchup/internal/agy" "github.com/wilbeibi/catchup/internal/claude" "github.com/wilbeibi/catchup/internal/cline" @@ -48,7 +50,7 @@ RECAP — how much of the session (default: all of it) -i, --info metadata only, no messages FIND — which session (default: newest here) - --list list recent sessions + --list list recent sessions here, across every agent -q, --query search by keyword (implies --list) / the Nth newest, e.g. codex/3 --id an exact session id @@ -57,10 +59,11 @@ FIND — which session (default: newest here) HAND OFF — continue the work fork [agent] native resume, full state - --into seed a different agent with the transcript + --into seed a different agent with the transcript; the same + agent, with --last/--since-compact, restarts it clean --from with --into: seed from a file, - (stdin), or http(s) URL --model launch it on a specific model, e.g. a cheaper one - (the target agent's own model name, e.g. gpt-5.6) + (the target agent's own model name, passed as-is) OUTPUT — as what (default: Markdown) --md, --markdown Markdown (the default) @@ -76,6 +79,7 @@ Examples: catchup claude --dir ~/src/proj latest session from another directory catchup fork --into codex hand the newest session here to Codex catchup fork claude --into codex --model gpt-5.6 ...on a specific model + catchup fork claude --into claude --since-compact ...clean, same agent catchup fork --into claude --from handoff.md ...from a saved transcript catchup install-skill install the SKILL.md for every agent @@ -149,6 +153,10 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st if !ok { return nil // several query matches: the listing printed is the answer } + // The source is usually inferred, and this is the one command that + // spends money on the guess, so name it before the launch — early + // enough to interrupt while a large transcript is still being read. + announceFork(stderr, src, cmd.Into) if cmd.Into != "" { return forkInto(ctx, src, cmd, stdin, stdout, stderr) } @@ -159,9 +167,17 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st return installSkill(cmd.Target.Provider, skillDirs, skillMD, version, stdout) } + // A listing with no agent named spans every agent: "where was I" is a + // cross-agent question by nature — the whole premise of catchup is that + // more than one agent works here — and the table's handles carry the agent + // name, so its rows stay selectable. + if cmd.Target.Provider == "" && cmd.List { + return listAcross(ctx, roots, cmd, cwd, stdout, stderr) + } + // With no agent named, the agent owning the newest session in cwd is the // target; the normal locate below then re-selects within that provider, so - // --list, -q, and the trims all work against the detected agent. + // -q and the trims all work against the detected agent. if cmd.Target.Provider == "" { src, err := newestAcross(ctx, roots, cwd) if err != nil { @@ -209,6 +225,76 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st return render.Thread(stdout, thread, cmd.Format) } +// listAcross renders one time-ordered table spanning every provider's cwd +// listing. Ranks stay per-agent — claude/2 selects the same session here as it +// does in catchup claude --list — so only row order is merged, never the +// numbering. +func listAcross(ctx context.Context, roots session.Roots, cmd Command, cwd string, stdout, stderr io.Writer) error { + opts := session.ListOptions{Query: cmd.Target.Query, Cwd: cwd, Limit: cmd.Limit} + var merged []session.Summary + for _, name := range providerNames() { + prov, _ := selectProvider(name) // providerNames is the closed set selectProvider switches on + sums, err := prov.List(ctx, roots, opts) + if err != nil { + // A store that cannot be read means missing rows, not a failed + // listing: say so and show what the other agents have. + fmt.Fprintf(stderr, "catchup: %s sessions omitted: %v\n", name, err) + continue + } + for _, s := range sums { + if s.Ref.Provider == "" { + s.Ref.Provider = name + } + merged = append(merged, s) + } + } + sort.SliceStable(merged, func(i, j int) bool { return merged[i].UpdatedAt.After(merged[j].UpdatedAt) }) + if len(merged) > cmd.Limit { + merged = merged[:cmd.Limit] + } + // Nothing at all in this directory is a diagnosis, not an empty table: + // newestAcross's error names where each agent's sessions actually are. A + // query finding nothing is just a query finding nothing. + if len(merged) == 0 && cmd.Target.Query == "" { + if _, err := newestAcross(ctx, roots, cwd); err != nil { + return err + } + } + return render.List(stdout, "", merged, cmd.Format) +} + +// announceFork names the session a fork is about to continue. The source is +// inferred whenever the agent is omitted, and fork is the one action that acts +// on the guess rather than printing it — with --into a wrong guess spends real +// tokens seeding the wrong transcript — so the guess is always shown, on +// stderr, before the launch. +func announceFork(stderr io.Writer, src session.Source, into string) { + line := "catchup: forking " + src.Ref.Provider + if into != "" { + line += " → " + into + } + var facts []string + if age := render.Age(src.UpdatedAt); age != "" { + facts = append(facts, age) + } + // Fields collapses a multi-line title; the announce is one line. A title + // that is just the directory name is a provider's fallback for a session + // its agent never named, and identifies nothing here — same as in a + // listing, where those rows show their opening message instead. + // Providers that title a session from its first user message can produce + // hundreds of characters — a pasted prompt, or catchup's own seed + // preamble when the session was itself seeded — so it is truncated by + // display width, like a table cell. + title := strings.Join(strings.Fields(src.Metadata["title"]), " ") + if title != "" && title != filepath.Base(src.Metadata["cwd"]) { + facts = append(facts, runewidth.Truncate(title, 60, "…")) + } + if len(facts) > 0 { + line += " (" + strings.Join(facts, ": ") + ")" + } + fmt.Fprintln(stderr, line) +} + // expandTilde resolves a leading ~ against this process's home. The shell // normally does this, but --dir=~/x (the inline = form) and values quoted in // scripts arrive with the tilde intact. @@ -503,11 +589,18 @@ func execInto(ctx context.Context, name string, args []string, stdin io.Reader, // forkInto is the cross-agent half of fork: it cannot transplant one agent's // native state into another, so it renders the source session's transcript and -// launches the target agent with that transcript as its opening prompt. The -// same-agent case is rejected because the native fork is strictly better there. +// launches the target agent with that transcript as its opening prompt. +// +// Same-agent --into is the deliberate exception: seeding one agent from its own +// session is a reseed, not a resume — it sheds a bloated context and keeps the +// knowledge, which is what someone wants after finishing a task rather than +// after losing one. A trim is what distinguishes it from a mistyped native +// fork: --last and --since-compact are rejected on a native fork, so their +// presence can only mean "seed me a shorter version of this". func forkInto(ctx context.Context, src session.Source, cmd Command, stdin io.Reader, stdout, stderr io.Writer) error { - if cmd.Into == src.Ref.Provider { - return fmt.Errorf("--into %s: the session is already %s's; use catchup fork %s for a native fork with full state", cmd.Into, cmd.Into, cmd.Into) + if cmd.Into == src.Ref.Provider && cmd.LastN == 0 && !cmd.SinceCompact { + return fmt.Errorf("--into %s: the session is already %s's; use catchup fork %s to resume it with full state, or add --since-compact/--last N to start a clean %s seeded with its transcript", + cmd.Into, cmd.Into, cmd.Into, cmd.Into) } if _, err := selectProvider(cmd.Into); err != nil { return err diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9694807..cc4b180 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -207,8 +207,9 @@ func TestRunCwdFiltering(t *testing.T) { } roots := session.Roots{Codex: root} - // With cwd: only the matching session appears. - out := runWithCwd(t, roots, "/home/u/src/catchup", "codex", "--list") + // With cwd: only the matching session appears. Asserted against --json, + // which is where session ids live; the table shows handles and titles. + out := runWithCwd(t, roots, "/home/u/src/catchup", "codex", "--list", "--json") if !strings.Contains(out, "sess-catchup") { t.Errorf("expected sess-catchup in cwd-filtered listing, got:\n%s", out) } @@ -217,7 +218,7 @@ func TestRunCwdFiltering(t *testing.T) { } // --dir substitutes another directory for the cwd. - out = runWithCwd(t, roots, "/somewhere/else", "codex", "--list", "--dir", "/home/u/src/other") + out = runWithCwd(t, roots, "/somewhere/else", "codex", "--list", "--json", "--dir", "/home/u/src/other") if !strings.Contains(out, "sess-other") || strings.Contains(out, "sess-catchup") { t.Errorf("--dir should select exactly the named directory, got:\n%s", out) } @@ -546,6 +547,85 @@ func TestRunInstallSkillAllProviders(t *testing.T) { } } +// runBoth is runWithCwd when stderr matters: the guesses, hints, and warnings +// that keep stdout a clean wire format all land there. +func runBoth(t *testing.T, roots session.Roots, cwd string, args ...string) (string, string) { + t.Helper() + var out, errOut bytes.Buffer + if err := Run(context.Background(), args, roots, nil, nil, nil, "test", cwd, nil, &out, &errOut); err != nil { + t.Fatalf("Run(%v) error: %v (stderr: %s)", args, err, errOut.String()) + } + return out.String(), errOut.String() +} + +// A bare listing spans every agent: "where was I" is a cross-agent question, +// and each row names the agent that owns it so the handles stay selectable. +func TestRunListAcrossAgents(t *testing.T) { + roots := codexRoot(t) + roots.Claude = claudeRoot(t).Claude + + out, _ := runBoth(t, roots, "", "--list") + for _, want := range []string{"codex/1", "claude/1", "claude/2"} { + if !strings.Contains(out, want) { + t.Errorf("cross-agent listing missing %q:\n%s", want, out) + } + } + + // Naming an agent keeps the listing scoped to it. + out, _ = runBoth(t, roots, "", "claude", "--list") + if strings.Contains(out, "codex/") { + t.Errorf("catchup claude --list should not list codex:\n%s", out) + } + + // The JSON listing carries the agent per row too, so scripts can tell the + // merged rows apart. + out, _ = runBoth(t, roots, "", "--list", "--json") + for _, want := range []string{`"agent": "codex"`, `"agent": "claude"`} { + if !strings.Contains(out, want) { + t.Errorf("cross-agent JSON listing missing %s:\n%s", want, out) + } + } +} + +// fork acts on an inferred source, so it says which one, before the launch. +func TestRunForkAnnouncesSource(t *testing.T) { + roots := codexRoot(t) + + withForkRunner(t, func(ctx context.Context, src session.Source, model string, stdin io.Reader, stdout, stderr io.Writer) error { + return nil + }) + _, errOut := runBoth(t, roots, "/home/u/src/proj", "fork") + if !strings.Contains(errOut, "catchup: forking codex") { + t.Errorf("native fork should name its source:\n%s", errOut) + } + if strings.Contains(errOut, "→") { + t.Errorf("native fork has no target agent to announce:\n%s", errOut) + } + // The fixture's cwd is /home/u/src/proj and its agent never titled the + // session, so codex falls back to "proj" — a name that identifies nothing. + if strings.Contains(errOut, "proj") { + t.Errorf("announce should drop a directory-name title:\n%s", errOut) + } + + // Providers that title a session from its first user message produce + // titles of any length: a real cline session was titled with catchup's + // own 100-character seed preamble. The announce stays one line. + long := session.Source{Ref: session.Ref{Provider: "codex"}, Metadata: map[string]string{"title": strings.Repeat("long ", 40)}} + var b bytes.Buffer + announceFork(&b, long, "claude") + if got := len([]rune(b.String())); got > 100 { + t.Errorf("announce line is %d runes, want one line:\n%s", got, b.String()) + } + + withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + return nil + }) + _, errOut = runBoth(t, roots, "/home/u/src/proj", "fork", "--into", "claude") + if !strings.Contains(errOut, "catchup: forking codex → claude") { + t.Errorf("--into should announce both ends:\n%s", errOut) + } +} + func withForkRunner(t *testing.T, runner forkRunner) { t.Helper() old := runFork @@ -584,17 +664,45 @@ func TestRunForkInto(t *testing.T) { } } +// A bare same-agent --into is far more likely a mistyped native fork, so it is +// refused — but a trimmed one is a deliberate reseed: shed the context, keep +// the knowledge. func TestRunForkIntoSameAgent(t *testing.T) { roots := codexRoot(t) withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - t.Fatal("runner should not be called for a same-agent --into") + t.Fatal("runner should not be called for a bare same-agent --into") return nil }) var out, errOut bytes.Buffer err := Run(context.Background(), []string{"fork", "codex", "--into", "codex"}, roots, nil, nil, nil, "test", "/home/u/src/proj", nil, &out, &errOut) - if err == nil || !strings.Contains(err.Error(), "native fork") { - t.Fatalf("want same-agent rejection pointing at native fork, got %v", err) + if err == nil || !strings.Contains(err.Error(), "resume it with full state") { + t.Fatalf("want same-agent rejection pointing at the native fork, got %v", err) + } + if !strings.Contains(err.Error(), "--since-compact") { + t.Errorf("rejection should name the reseed it was probably meant to be: %v", err) + } +} + +func TestRunForkIntoSameAgentReseed(t *testing.T) { + roots := codexRoot(t) + for _, trim := range [][]string{{"--since-compact"}, {"--last", "5"}} { + var gotArgs []string + withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + gotArgs = args + return nil + }) + var out, errOut bytes.Buffer + args := append([]string{"fork", "codex", "--into", "codex"}, trim...) + if err := Run(context.Background(), args, roots, nil, nil, nil, "test", "/home/u/src/proj", nil, &out, &errOut); err != nil { + t.Fatalf("fork codex --into codex %v: %v", trim, err) + } + if len(gotArgs) == 0 { + t.Fatalf("fork codex --into codex %v seeded nothing", trim) + } + if !strings.Contains(gotArgs[len(gotArgs)-1], "prior codex session") { + t.Errorf("reseed prompt missing the transcript preamble: %q", gotArgs[len(gotArgs)-1]) + } } } @@ -699,9 +807,9 @@ func TestRunForkSelectors(t *testing.T) { if forked { t.Error("ambiguous fork query must not fork") } - for _, want := range []string{"sess-auth", "sess-parser"} { + for _, want := range []string{"fix the auth flow", "refactor the parser pipeline"} { if !strings.Contains(out, want) { - t.Errorf("ambiguous fork listing missing %s:\n%s", want, out) + t.Errorf("ambiguous fork listing missing %q:\n%s", want, out) } } if !strings.Contains(errOut, "rerun as catchup fork codex/") { diff --git a/internal/render/common.go b/internal/render/common.go index bf4f004..46daefe 100644 --- a/internal/render/common.go +++ b/internal/render/common.go @@ -15,6 +15,38 @@ import ( // tsHuman is the compact, local-friendly timestamp used in headings and tables. const tsHuman = "2006-01-02 15:04" +// dateHuman is tsHuman without the clock: the spelling listings use once a +// session is old enough that the time of day says nothing. +const dateHuman = "2006-01-02" + +// timeNow is the clock the relative ages are measured against; a var so tests +// can pin "now" and assert exact cells. +var timeNow = time.Now + +// Age renders a timestamp the way someone scanning a listing reads it: how +// long ago, while "how long ago" is still the question being asked, and a +// plain date once it is not. The cutoff is a week — past that, "9d ago" is +// arithmetic, not an answer. +func Age(t time.Time) string { + if t.IsZero() { + return "" + } + d := timeNow().Sub(t) + switch { + case d < time.Minute: // includes clock skew: a stamp from a machine running ahead + + return "just now" + case d < time.Hour: + return strconv.Itoa(int(d.Minutes())) + "m ago" + case d < 24*time.Hour: + return strconv.Itoa(int(d.Hours())) + "h ago" + case d < 7*24*time.Hour: + return strconv.Itoa(int(d.Hours()/24)) + "d ago" + default: + return t.Local().Format(dateHuman) + } +} + // kv is an ordered key/value pair for descriptive output (frontmatter, tables). type kv struct{ Key, Val string } diff --git a/internal/render/render.go b/internal/render/render.go index 6a83736..14102a3 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -9,6 +9,8 @@ package render import ( "fmt" "io" + "path/filepath" + "strconv" "github.com/mattn/go-runewidth" "github.com/wilbeibi/catchup/internal/session" @@ -44,7 +46,8 @@ func Meta(w io.Writer, s session.Source, f session.Format) error { // List renders a ranked listing: a plain table by default, or a JSON array // for scripts. HTML has no listing view; the cli rejects that combination -// before it gets here. +// before it gets here. provider names the agent every row belongs to, and is +// empty for a cross-agent listing, where each row carries its own. func List(w io.Writer, provider string, summaries []session.Summary, f session.Format) error { switch f { case session.FormatJSON: @@ -56,22 +59,35 @@ func List(w io.Writer, provider string, summaries []session.Summary, f session.F } } -// tableList renders the human listing. It adapts to terminal width: columns -// are "#", "UPDATED", "TITLE", "SESSION". TITLE gets the remaining space -// after fixed columns and the longest session ID in the batch. Columns are -// aligned with display-width-aware padding so CJK characters (2 columns each -// in terminals) align correctly. +// tableList renders the human listing: columns are "SESSION", "UPDATED", +// "TITLE". SESSION is the selector the reader would retype (claude/3), not the +// session id — nobody types a UUID by hand, and the id is one --json away for +// the scripts that want it. TITLE takes whatever width is left, since it is the +// only column that answers "was this the one?". Columns are aligned with +// display-width-aware padding so CJK characters (2 columns each in terminals) +// align correctly. func tableList(w io.Writer, provider string, summaries []session.Summary) error { if len(summaries) == 0 { + if provider == "" { + _, err := fmt.Fprintln(w, "no sessions found") + return err + } _, err := fmt.Fprintf(w, "no %s sessions found\n", provider) return err } const gutter = 1 - rankW := 3 // fits ranks up to 999 - updW := 16 // "2006-01-02 15:04" - sidW := maxSidWidth(summaries) - titleW := termWidth(w) - rankW - updW - sidW - 3*gutter + handles := make([]string, len(summaries)) + for i, s := range summaries { + handles[i] = handle(s, provider) + } + ages := make([]string, len(summaries)) + for i, s := range summaries { + ages[i] = Age(s.UpdatedAt) + } + selW := maxWidth("SESSION", handles) + updW := maxWidth("UPDATED", ages) + titleW := termWidth(w) - selW - updW - 2*gutter if titleW < 15 { titleW = 15 } @@ -80,35 +96,58 @@ func tableList(w io.Writer, provider string, summaries []session.Summary) error } // Header - fmt.Fprintf(w, "%s %s %s %s\n", - runewidth.FillRight("#", rankW), + fmt.Fprintf(w, "%s %s %s\n", + runewidth.FillRight("SESSION", selW), runewidth.FillRight("UPDATED", updW), - runewidth.FillRight("TITLE", titleW), - runewidth.FillRight("SESSION", sidW), + "TITLE", ) - for _, s := range summaries { - updated := "" - if !s.UpdatedAt.IsZero() { - updated = s.UpdatedAt.Local().Format(tsHuman) - } - title := runewidth.Truncate(oneLine(s.Title), titleW, "…") - fmt.Fprintf(w, "%s %s %s %s\n", - runewidth.FillRight(fmt.Sprintf("%d", s.Rank), rankW), - runewidth.FillRight(updated, updW), - runewidth.FillRight(title, titleW), - s.Ref.SessionID, + for i, s := range summaries { + fmt.Fprintf(w, "%s %s %s\n", + runewidth.FillRight(handles[i], selW), + runewidth.FillRight(ages[i], updW), + runewidth.Truncate(titleCell(s), titleW, "…"), ) } return nil } -// maxSidWidth returns the maximum display width of session IDs in the batch, -// clamped to at least the width of the header "SESSION" (7). -func maxSidWidth(summaries []session.Summary) int { - m := 7 // len("SESSION") - for _, s := range summaries { - if w := runewidth.StringWidth(s.Ref.SessionID); w > m { +// titleCell is the text that has to answer "was this the one?". Providers whose +// agent never named the session fall back to the directory name, which says +// nothing in a listing already scoped to one directory — every row reads the +// same. The session's opening message is what actually tells those rows apart, +// so it stands in. +func titleCell(s session.Summary) string { + title := oneLine(s.Title) + if s.Preview == "" { + return title + } + if title == "" || title == filepath.Base(s.Cwd) { + return oneLine(s.Preview) + } + return title +} + +// handle is the "/" selector that re-selects a listed row on a +// later invocation. A row's own provider wins over the listing's, so a +// cross-agent table labels every row correctly; fallback covers the providers +// whose List leaves Ref.Provider unstamped. +func handle(s session.Summary, provider string) string { + name := s.Ref.Provider + if name == "" { + name = provider + } + if name == "" { + return strconv.Itoa(s.Rank) + } + return name + "/" + strconv.Itoa(s.Rank) +} + +// maxWidth returns the display width of the widest of a header and its cells. +func maxWidth(header string, cells []string) int { + m := runewidth.StringWidth(header) + for _, c := range cells { + if w := runewidth.StringWidth(c); w > m { m = w } } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 0b577f8..7ec202d 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -90,34 +90,83 @@ func TestHTMLEscapes(t *testing.T) { func TestList(t *testing.T) { var b bytes.Buffer sums := []session.Summary{ - {Ref: session.Ref{Provider: "codex", SessionID: "019f05d8"}, Rank: 1, + {Ref: session.Ref{Provider: "codex", SessionID: "deadbeef-cafe-babe-0123-456789abcdef"}, Rank: 3, UpdatedAt: time.Now(), Title: "skeleton", Cwd: "/src/catchup", Preview: "let's\nimplement"}, } if err := List(&b, "codex", sums, session.FormatMarkdown); err != nil { t.Fatal(err) } out := b.String() - if !strings.Contains(out, "#") || !strings.Contains(out, "019f05d8") { - t.Errorf("list missing header or row:\n%s", out) + // The row's handle is the command that re-selects it. + if !strings.Contains(out, "SESSION") || !strings.Contains(out, "codex/3") { + t.Errorf("list missing header or handle:\n%s", out) + } + // The id is --json's business; in the table it only steals title width. + if strings.Contains(out, "deadbeef") { + t.Errorf("session id should not appear in the human table:\n%s", out) } if strings.Contains(out, "let's") { t.Errorf("preview should not appear in list:\n%s", out) } - // Full session IDs are preserved so --id can restore them. - sums[0].Ref.SessionID = "deadbeef-cafe-babe-0123-456789abcdef" +} + +// TestListCrossAgent covers the bare `catchup --list` table: no listing-wide +// provider, so every row must label itself. +func TestListCrossAgent(t *testing.T) { + sums := []session.Summary{ + {Ref: session.Ref{Provider: "claude", SessionID: "a"}, Rank: 1, UpdatedAt: time.Now(), Title: "one"}, + {Ref: session.Ref{Provider: "codex", SessionID: "b"}, Rank: 2, UpdatedAt: time.Now(), Title: "two"}, + } + var b bytes.Buffer + if err := List(&b, "", sums, session.FormatMarkdown); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{"claude/1", "codex/2"} { + if !strings.Contains(out, want) { + t.Errorf("cross-agent listing missing %q:\n%s", want, out) + } + } + b.Reset() - if err := List(&b, "codex", sums, session.FormatMarkdown); err != nil { + if err := List(&b, "", nil, session.FormatMarkdown); err != nil { t.Fatal(err) } - out = b.String() - if !strings.Contains(out, "deadbeef-cafe-babe-0123-456789abcdef") { - t.Errorf("full session id should appear:\n%s", out) + if got := b.String(); got != "no sessions found\n" { + t.Errorf("empty cross-agent listing = %q", got) + } +} + +func TestAge(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + timeNow = func() time.Time { return now } + t.Cleanup(func() { timeNow = time.Now }) + + cases := []struct { + ago time.Duration + want string + }{ + {30 * time.Second, "just now"}, + {14 * time.Minute, "14m ago"}, + {3 * time.Hour, "3h ago"}, + {50 * time.Hour, "2d ago"}, + {6 * 24 * time.Hour, "6d ago"}, + // Past a week, "how long ago" stops being the question. + {9 * 24 * time.Hour, now.Add(-9 * 24 * time.Hour).Local().Format(dateHuman)}, + } + for _, c := range cases { + if got := Age(now.Add(-c.ago)); got != c.want { + t.Errorf("Age(-%s) = %q, want %q", c.ago, got, c.want) + } + } + if got := Age(time.Time{}); got != "" { + t.Errorf("Age(zero) = %q, want empty", got) } } -// TestListCJKAlignment locks in the display-width-aware padding: a CJK title -// (2 columns per rune) must not shift the SESSION column relative to the -// header or to an ASCII-only row. Regression for the tabwriter replacement. +// TestListCJKAlignment locks in the display-width-aware layout: a CJK title +// (2 columns per rune) must not shift the TITLE column relative to the header +// or to an ASCII-only row, and must not overflow the terminal width. func TestListCJKAlignment(t *testing.T) { // termWidth falls back to $COLUMNS when w is not a *os.File. t.Setenv("COLUMNS", "80") @@ -127,7 +176,7 @@ func TestListCJKAlignment(t *testing.T) { title string }{ {"ascii", "Engineering basics"}, - {"cjk", "Engineering博文三结论开头写法"}, + {"cjk", "Engineering博文三结论开头写法博文三结论开头写法博文三结论开头写法博文三结论开头写法"}, } sums := make([]session.Summary, 0, len(cases)) for i, c := range cases { @@ -148,23 +197,21 @@ func TestListCJKAlignment(t *testing.T) { t.Fatalf("expected %d lines, got %d:\n%s", len(cases)+1, len(lines), b.String()) } - // The SESSION column must start at the same *display column* in every - // line. CJK runes are 3 bytes but 2 columns, so byte offset is not - // enough — measure the display width of the prefix before SESSION. - marker := "0123456789abcdef" - want := runewidth.StringWidth(lines[0][:strings.Index(lines[0], "SESSION")]) - if want < 0 { - t.Fatalf("header missing SESSION:\n%s", lines[0]) - } + // The TITLE column must start at the same *display column* in every line. + // CJK runes are 3 bytes but 2 columns, so byte offset is not enough — + // measure the display width of the prefix before the title. + want := runewidth.StringWidth(lines[0][:strings.Index(lines[0], "TITLE")]) for i, ln := range lines[1:] { - idx := strings.Index(ln, marker) + idx := strings.Index(ln, "Engineering") if idx < 0 { - t.Fatalf("line %d missing session id:\n%s", i+1, ln) + t.Fatalf("line %d missing title:\n%s", i+1, ln) } - got := runewidth.StringWidth(ln[:idx]) - if got != want { - t.Errorf("line %d (%s): SESSION at display col %d, want %d (header)\n%s", + if got := runewidth.StringWidth(ln[:idx]); got != want { + t.Errorf("line %d (%s): TITLE at display col %d, want %d (header)\n%s", i+1, cases[i].name, got, want, ln) } + if got := runewidth.StringWidth(ln); got > 80 { + t.Errorf("line %d (%s): %d display columns, want <= 80\n%s", i+1, cases[i].name, got, ln) + } } }