From 25a64f8058d65ae53ef3ca27039875359859a861 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:42:23 -0500 Subject: [PATCH 01/17] feat(onboarding): folder-candidacy heuristic (git or markers, minus deny-list) Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/project_detect.go | 59 +++++++++++++++++++++++++++--- internal/ui/project_detect_test.go | 35 ++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/internal/ui/project_detect.go b/internal/ui/project_detect.go index d37f9627..59f57678 100644 --- a/internal/ui/project_detect.go +++ b/internal/ui/project_detect.go @@ -70,20 +70,69 @@ func readProjectInstructions(dir string) (instructions string, sourceFile string return "", "" } +// projectMarkerFiles signal that a directory is a real project even without git. +var projectMarkerFiles = []string{ + ".git", "package.json", "go.mod", "Cargo.toml", "pyproject.toml", + "requirements.txt", "Gemfile", "pom.xml", "build.gradle", "composer.json", + "AGENTS.md", "CLAUDE.md", ".cursorrules", "Makefile", +} + +// denyListedHomeChildren are directory names that, directly under $HOME, are +// never project candidates (system / dumping-ground folders). +var denyListedHomeChildren = map[string]bool{ + "Desktop": true, "Documents": true, "Downloads": true, "Music": true, + "Pictures": true, "Movies": true, "Public": true, "Library": true, + "Applications": true, +} + +// isProjectCandidate reports whether dir is worth proactively offering as a +// TaskYou project: it must not be a system/dumping dir, and must show at least +// one positive signal (git repo or a project marker file). +func isProjectCandidate(dir string) bool { + if dir == "" { + return false + } + clean := filepath.Clean(dir) + + // Deny-list: root, temp, $HOME itself, and bare home children. + if clean == "/" || clean == filepath.Clean(os.TempDir()) || strings.HasPrefix(clean, "/tmp") { + return false + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + home = filepath.Clean(home) + if clean == home { + return false + } + if filepath.Dir(clean) == home && denyListedHomeChildren[filepath.Base(clean)] { + return false + } + } + + // Positive signal: git repo or any marker file present. + if dirIsGitRepo(clean) { + return true + } + for _, marker := range projectMarkerFiles { + if _, err := os.Stat(filepath.Join(clean, marker)); err == nil { + return true + } + } + return false +} + // detectProjectFromDir builds a project pre-filled with values inferred from a -// git repository at dir. It returns nil when dir is not a git repo. The returned -// project is NOT persisted - callers decide whether to save it. +// candidate directory. Returns nil when dir is not a project candidate. Worktrees +// default ON only for git repos (git is optional; non-git projects skip worktrees). func detectProjectFromDir(dir string) (project *db.Project, instructionSource string) { - if !dirIsGitRepo(dir) { + if !isProjectCandidate(dir) { return nil, "" } - instructions, source := readProjectInstructions(dir) return &db.Project{ Name: inferProjectName(dir), Path: filepath.Clean(dir), Instructions: instructions, - UseWorktrees: true, + UseWorktrees: dirIsGitRepo(dir), }, source } diff --git a/internal/ui/project_detect_test.go b/internal/ui/project_detect_test.go index 9e8e7a05..91c006af 100644 --- a/internal/ui/project_detect_test.go +++ b/internal/ui/project_detect_test.go @@ -162,3 +162,38 @@ func TestProjectSuggestionDismissedKey(t *testing.T) { t.Fatalf("expected cleaned paths to produce equal keys: %q vs %q", k1, k2) } } + +func TestIsProjectCandidate(t *testing.T) { + home, _ := os.UserHomeDir() + + // Junk dir: home itself and standard bare children → never a candidate. + for _, junk := range []string{home, filepath.Join(home, "Desktop"), filepath.Join(home, "Downloads"), "/", "/tmp"} { + if isProjectCandidate(junk) { + t.Errorf("isProjectCandidate(%q) = true, want false", junk) + } + } + + // A git repo is a candidate. + gitDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(gitDir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if !isProjectCandidate(gitDir) { + t.Errorf("git repo %q should be a candidate", gitDir) + } + + // A non-git dir with a project marker is a candidate. + markerDir := t.TempDir() + if err := os.WriteFile(filepath.Join(markerDir, "go.mod"), []byte("module x\n"), 0o644); err != nil { + t.Fatal(err) + } + if !isProjectCandidate(markerDir) { + t.Errorf("dir with go.mod %q should be a candidate", markerDir) + } + + // An empty, signal-less dir is NOT a candidate. + emptyDir := t.TempDir() + if isProjectCandidate(emptyDir) { + t.Errorf("empty dir %q should not be a candidate", emptyDir) + } +} From 7b2735247f11335a9987bbbfd12b64a82c7694c8 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:45:26 -0500 Subject: [PATCH 02/17] fix(onboarding): exact /tmp match, drop redundant .git marker, add non-git coverage - Replace HasPrefix /tmp check with exact clean == "/tmp" to avoid wrongly blocking /tmpl-style paths - Remove .git from projectMarkerFiles since dirIsGitRepo() already covers that signal - Add TestDetectProjectFromDir_NonGitMarker: verifies go.mod-only dir yields a non-nil project with UseWorktrees=false, and empty dir yields nil Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/project_detect.go | 4 ++-- internal/ui/project_detect_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/ui/project_detect.go b/internal/ui/project_detect.go index 59f57678..079afcd7 100644 --- a/internal/ui/project_detect.go +++ b/internal/ui/project_detect.go @@ -72,7 +72,7 @@ func readProjectInstructions(dir string) (instructions string, sourceFile string // projectMarkerFiles signal that a directory is a real project even without git. var projectMarkerFiles = []string{ - ".git", "package.json", "go.mod", "Cargo.toml", "pyproject.toml", + "package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt", "Gemfile", "pom.xml", "build.gradle", "composer.json", "AGENTS.md", "CLAUDE.md", ".cursorrules", "Makefile", } @@ -95,7 +95,7 @@ func isProjectCandidate(dir string) bool { clean := filepath.Clean(dir) // Deny-list: root, temp, $HOME itself, and bare home children. - if clean == "/" || clean == filepath.Clean(os.TempDir()) || strings.HasPrefix(clean, "/tmp") { + if clean == "/" || clean == filepath.Clean(os.TempDir()) || clean == "/tmp" { return false } if home, err := os.UserHomeDir(); err == nil && home != "" { diff --git a/internal/ui/project_detect_test.go b/internal/ui/project_detect_test.go index 91c006af..13de4ea1 100644 --- a/internal/ui/project_detect_test.go +++ b/internal/ui/project_detect_test.go @@ -130,6 +130,34 @@ func TestDetectProjectFromDir(t *testing.T) { } } +func TestDetectProjectFromDir_NonGitMarker(t *testing.T) { + // A dir with only go.mod (no .git) should be detected as a project + // with UseWorktrees==false. + markerDir := t.TempDir() + if err := os.WriteFile(filepath.Join(markerDir, "go.mod"), []byte("module example\n"), 0o644); err != nil { + t.Fatal(err) + } + p, _ := detectProjectFromDir(markerDir) + if p == nil { + t.Fatal("expected non-nil project for dir with go.mod, got nil") + } + if p.UseWorktrees { + t.Errorf("expected UseWorktrees false for non-git project, got true") + } + if p.Name == "" { + t.Errorf("expected non-empty Name") + } + if p.Path == "" { + t.Errorf("expected non-empty Path") + } + + // An empty dir (no markers, no git) must return nil. + emptyDir := t.TempDir() + if p2, _ := detectProjectFromDir(emptyDir); p2 != nil { + t.Errorf("expected nil for empty dir, got %+v", p2) + } +} + func TestUniqueProjectName(t *testing.T) { tmpDir := t.TempDir() database, err := db.Open(filepath.Join(tmpDir, "test.db")) From 3a1607b65c330077d31bdb80a65762ce92fb954d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:47:29 -0500 Subject: [PATCH 03/17] feat(onboarding): infer project name/alias/description via claude -p Co-Authored-By: Claude Sonnet 4.6 --- internal/ai/project_infer.go | 124 ++++++++++++++++++++++++++++++ internal/ai/project_infer_test.go | 30 ++++++++ 2 files changed, 154 insertions(+) create mode 100644 internal/ai/project_infer.go create mode 100644 internal/ai/project_infer_test.go diff --git a/internal/ai/project_infer.go b/internal/ai/project_infer.go new file mode 100644 index 00000000..0c7db770 --- /dev/null +++ b/internal/ai/project_infer.go @@ -0,0 +1,124 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/bborn/workflow/internal/executor" +) + +// ProjectMetadata is the structured result of inferring a project's identity. +type ProjectMetadata struct { + Name string `json:"name"` + Alias string `json:"alias"` + Description string `json:"description"` +} + +// inferenceTimeout caps how long we wait on `claude -p`. +const inferenceTimeout = 12 * time.Second + +// InferProjectMetadata shells out to `claude -p` (print mode) to infer a clean +// project name, short alias, and one-line description from the folder. It reuses +// the user's Claude CLI auth via CLAUDE_CONFIG_DIR (no API key), mirroring +// executor.RenameClaudeSession. Returns an error when claude is unavailable, +// times out, or returns unparseable output; callers MUST degrade gracefully. +func InferProjectMetadata(dir, configDir string) (ProjectMetadata, error) { + ctx, cancel := context.WithTimeout(context.Background(), inferenceTimeout) + defer cancel() + + prompt := buildInferencePrompt(dir) + cmd := exec.CommandContext(ctx, "claude", "-p", prompt) + cmd.Dir = dir + cmd.Env = append(os.Environ(), fmt.Sprintf("CLAUDE_CONFIG_DIR=%s", executor.ResolveClaudeConfigDir(configDir))) + out, err := cmd.Output() + if err != nil { + return ProjectMetadata{}, fmt.Errorf("claude -p inference failed: %w", err) + } + return parseInferenceJSON(string(out)) +} + +func buildInferencePrompt(dir string) string { + var sb strings.Builder + sb.WriteString("You are naming a software project for a task manager. ") + sb.WriteString("Given a folder, respond with ONLY a JSON object: ") + sb.WriteString(`{"name": "...", "alias": "...", "description": "..."}`) + sb.WriteString(".\n- name: a clean human project name (kebab or title case), not a file path.\n") + sb.WriteString("- alias: a short lowercase handle (3-12 chars), no spaces.\n") + sb.WriteString("- description: one sentence (<= 12 words) describing what the project is.\n") + sb.WriteString("Output JSON only, no prose, no code fences.\n\n") + sb.WriteString("Folder name: " + filepath.Base(filepath.Clean(dir)) + "\n\n") + sb.WriteString("Files:\n" + shallowFileListing(dir) + "\n") + if snippet := readmeSnippet(dir); snippet != "" { + sb.WriteString("\nREADME/AGENTS excerpt:\n" + snippet + "\n") + } + return sb.String() +} + +// shallowFileListing returns up to 40 top-level entry names, dirs marked with /. +func shallowFileListing(dir string) string { + entries, err := os.ReadDir(dir) + if err != nil { + return "(unreadable)" + } + var names []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + if e.IsDir() { + names = append(names, e.Name()+"/") + } else { + names = append(names, e.Name()) + } + } + sort.Strings(names) + if len(names) > 40 { + names = names[:40] + } + return strings.Join(names, "\n") +} + +// readmeSnippet returns the first ~1200 chars of the best available doc file. +func readmeSnippet(dir string) string { + for _, name := range []string{"AGENTS.md", "CLAUDE.md", "README.md"} { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + s := strings.TrimSpace(string(data)) + if s == "" { + continue + } + if len(s) > 1200 { + s = s[:1200] + } + return s + } + return "" +} + +// parseInferenceJSON extracts a ProjectMetadata from claude's output, tolerating +// surrounding prose or fences by scanning for the first {...} block. +func parseInferenceJSON(raw string) (ProjectMetadata, error) { + s := strings.TrimSpace(raw) + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end < 0 || end <= start { + return ProjectMetadata{}, fmt.Errorf("no JSON object found in inference output") + } + var meta ProjectMetadata + if err := json.Unmarshal([]byte(s[start:end+1]), &meta); err != nil { + return ProjectMetadata{}, fmt.Errorf("parse inference JSON: %w", err) + } + if strings.TrimSpace(meta.Name) == "" { + return ProjectMetadata{}, fmt.Errorf("inference returned empty name") + } + return meta, nil +} diff --git a/internal/ai/project_infer_test.go b/internal/ai/project_infer_test.go new file mode 100644 index 00000000..6f433662 --- /dev/null +++ b/internal/ai/project_infer_test.go @@ -0,0 +1,30 @@ +package ai + +import "testing" + +func TestParseInferenceJSON(t *testing.T) { + meta, err := parseInferenceJSON(`{"name":"acme-rocket","alias":"acme","description":"Rust CLI for rockets"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Name != "acme-rocket" || meta.Alias != "acme" || meta.Description != "Rust CLI for rockets" { + t.Errorf("got %+v", meta) + } + + meta, err = parseInferenceJSON("Here you go:\n```json\n{\"name\":\"foo\",\"alias\":\"f\",\"description\":\"d\"}\n```") + if err != nil || meta.Name != "foo" { + t.Errorf("fenced parse failed: %+v err=%v", meta, err) + } + + if _, err := parseInferenceJSON("not json at all"); err == nil { + t.Error("expected error on non-JSON") + } +} + +func TestInferProjectMetadata_DegradesWhenClaudeMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // empty PATH dir => claude not found + _, err := InferProjectMetadata(t.TempDir(), "") + if err == nil { + t.Error("expected error when claude binary is unavailable") + } +} From fbe1b551790b92894584710dd3c585fb3276b9fa Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:53:04 -0500 Subject: [PATCH 04/17] fix(onboarding): capture claude -p diagnostics, WaitDelay, robust JSON parse - Use CombinedOutput and include trimmed output in error messages for better failure diagnostics when claude -p inference fails. - Add WaitDelay of 2s so I/O goroutines are torn down after context cancels the process (handles node child processes). - Replace single-shot JSON unmarshal with forward scan from each '{' to last '}', so stray braces in prose no longer break parsing. - Add stray-brace test case to TestParseInferenceJSON. Co-Authored-By: Claude Sonnet 4.6 --- internal/ai/project_infer.go | 26 ++++++++++++++------------ internal/ai/project_infer_test.go | 6 ++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/ai/project_infer.go b/internal/ai/project_infer.go index 0c7db770..b5a6068c 100644 --- a/internal/ai/project_infer.go +++ b/internal/ai/project_infer.go @@ -37,9 +37,10 @@ func InferProjectMetadata(dir, configDir string) (ProjectMetadata, error) { cmd := exec.CommandContext(ctx, "claude", "-p", prompt) cmd.Dir = dir cmd.Env = append(os.Environ(), fmt.Sprintf("CLAUDE_CONFIG_DIR=%s", executor.ResolveClaudeConfigDir(configDir))) - out, err := cmd.Output() + cmd.WaitDelay = 2 * time.Second + out, err := cmd.CombinedOutput() if err != nil { - return ProjectMetadata{}, fmt.Errorf("claude -p inference failed: %w", err) + return ProjectMetadata{}, fmt.Errorf("claude -p inference failed: %w\noutput: %s", err, strings.TrimSpace(string(out))) } return parseInferenceJSON(string(out)) } @@ -105,20 +106,21 @@ func readmeSnippet(dir string) string { } // parseInferenceJSON extracts a ProjectMetadata from claude's output, tolerating -// surrounding prose or fences by scanning for the first {...} block. +// surrounding prose or fences by scanning forward from each '{' up to the last '}'. func parseInferenceJSON(raw string) (ProjectMetadata, error) { s := strings.TrimSpace(raw) - start := strings.Index(s, "{") end := strings.LastIndex(s, "}") - if start < 0 || end < 0 || end <= start { + if end < 0 { return ProjectMetadata{}, fmt.Errorf("no JSON object found in inference output") } - var meta ProjectMetadata - if err := json.Unmarshal([]byte(s[start:end+1]), &meta); err != nil { - return ProjectMetadata{}, fmt.Errorf("parse inference JSON: %w", err) - } - if strings.TrimSpace(meta.Name) == "" { - return ProjectMetadata{}, fmt.Errorf("inference returned empty name") + for i := 0; i < end; i++ { + if s[i] != '{' { + continue + } + var meta ProjectMetadata + if err := json.Unmarshal([]byte(s[i:end+1]), &meta); err == nil && strings.TrimSpace(meta.Name) != "" { + return meta, nil + } } - return meta, nil + return ProjectMetadata{}, fmt.Errorf("no parseable JSON object with a name in inference output") } diff --git a/internal/ai/project_infer_test.go b/internal/ai/project_infer_test.go index 6f433662..50ccb163 100644 --- a/internal/ai/project_infer_test.go +++ b/internal/ai/project_infer_test.go @@ -19,6 +19,12 @@ func TestParseInferenceJSON(t *testing.T) { if _, err := parseInferenceJSON("not json at all"); err == nil { t.Error("expected error on non-JSON") } + + // Prose containing a stray brace before the real JSON object. + meta, err = parseInferenceJSON(`note: use {curly} then {"name":"bar","alias":"b","description":"d"}`) + if err != nil || meta.Name != "bar" { + t.Errorf("stray-brace parse failed: %+v err=%v", meta, err) + } } func TestInferProjectMetadata_DegradesWhenClaudeMissing(t *testing.T) { From 982523f9792c6ecb9362c2a661553c74aeb681d8 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:54:37 -0500 Subject: [PATCH 05/17] feat(onboarding): merge inferred metadata without erasing defaults Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/project_detect.go | 19 +++++++++++++++++++ internal/ui/project_detect_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/ui/project_detect.go b/internal/ui/project_detect.go index 079afcd7..3aebc82a 100644 --- a/internal/ui/project_detect.go +++ b/internal/ui/project_detect.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/bborn/workflow/internal/ai" "github.com/bborn/workflow/internal/db" ) @@ -163,3 +164,21 @@ func uniqueProjectName(database *db.DB, name string) string { func projectSuggestionDismissedKey(path string) string { return "project_suggestion_dismissed:" + filepath.Clean(path) } + +// applyInferredMetadata overlays LLM-inferred fields onto a detected project. +// Empty inferred fields are ignored so rule-based defaults are never erased. +// The description fills Instructions only when no instructions were imported. +func applyInferredMetadata(p *db.Project, meta ai.ProjectMetadata) { + if p == nil { + return + } + if n := strings.TrimSpace(meta.Name); n != "" { + p.Name = n + } + if a := strings.TrimSpace(meta.Alias); a != "" { + p.Aliases = a + } + if d := strings.TrimSpace(meta.Description); d != "" && strings.TrimSpace(p.Instructions) == "" { + p.Instructions = d + } +} diff --git a/internal/ui/project_detect_test.go b/internal/ui/project_detect_test.go index 13de4ea1..21e859aa 100644 --- a/internal/ui/project_detect_test.go +++ b/internal/ui/project_detect_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/bborn/workflow/internal/ai" "github.com/bborn/workflow/internal/db" ) @@ -191,6 +192,34 @@ func TestProjectSuggestionDismissedKey(t *testing.T) { } } +func TestApplyInferredMetadata(t *testing.T) { + base := &db.Project{Name: "acme-rocket", Path: "/x"} + + applyInferredMetadata(base, ai.ProjectMetadata{Name: "Acme Rocket", Alias: "acme", Description: "Rust CLI"}) + if base.Name != "Acme Rocket" { + t.Errorf("name not applied: %q", base.Name) + } + if base.Aliases != "acme" { + t.Errorf("alias not applied: %q", base.Aliases) + } + if base.Instructions != "Rust CLI" { + t.Errorf("description should fill empty instructions: %q", base.Instructions) + } + + // Empty inferred fields must NOT overwrite existing values. + applyInferredMetadata(base, ai.ProjectMetadata{}) + if base.Name != "Acme Rocket" { + t.Errorf("empty inference erased name: %q", base.Name) + } + + // Description must NOT overwrite non-empty existing instructions. + withInstr := &db.Project{Name: "x", Instructions: "imported from README.md"} + applyInferredMetadata(withInstr, ai.ProjectMetadata{Description: "should be ignored"}) + if withInstr.Instructions != "imported from README.md" { + t.Errorf("description overwrote imported instructions: %q", withInstr.Instructions) + } +} + func TestIsProjectCandidate(t *testing.T) { home, _ := os.UserHomeDir() From 6a91d47154e63aafbc7b7f85ff4feb6ad89d37ae Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 08:57:06 -0500 Subject: [PATCH 06/17] feat(onboarding): fuzzy folder picker (type-to-search, git-tagged) Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/folderpicker.go | 228 +++++++++++++++++++++++++++++++ internal/ui/folderpicker_test.go | 18 +++ 2 files changed, 246 insertions(+) create mode 100644 internal/ui/folderpicker.go create mode 100644 internal/ui/folderpicker_test.go diff --git a/internal/ui/folderpicker.go b/internal/ui/folderpicker.go new file mode 100644 index 00000000..827686e7 --- /dev/null +++ b/internal/ui/folderpicker.go @@ -0,0 +1,228 @@ +package ui + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// folderEntry is one selectable folder in the picker. +type folderEntry struct { + path string + isGit bool +} + +func (e folderEntry) label() string { return e.path } + +// folderPickedMsg is emitted when the user picks a folder (enter). +type folderPickedMsg struct{ path string } + +// FolderPickerModel is a fuzzy, type-to-search folder picker. It seeds the list +// with likely project roots and lets the user filter, descend, and pick. +type FolderPickerModel struct { + input textinput.Model + all []folderEntry + filtered []folderEntry + selected int + root string + width int + height int +} + +// NewFolderPickerModel seeds the picker from common project roots. +func NewFolderPickerModel(width, height int) *FolderPickerModel { + ti := textinput.New() + ti.Placeholder = "type to search folders…" + ti.Focus() + ti.Prompt = "> " + + m := &FolderPickerModel{input: ti, width: width, height: height} + m.all = seedCandidateFolders() + m.filtered = m.all + return m +} + +// seedCandidateFolders gathers likely project dirs from common roots, git first. +func seedCandidateFolders() []folderEntry { + home, _ := os.UserHomeDir() + var roots []string + for _, r := range []string{"Projects", "src", "code", "dev", "work"} { + roots = append(roots, filepath.Join(home, r)) + } + seen := map[string]bool{} + var out []folderEntry + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + p := filepath.Join(root, e.Name()) + if seen[p] { + continue + } + seen[p] = true + out = append(out, folderEntry{path: p, isGit: dirIsGitRepo(p)}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].isGit != out[j].isGit { + return out[i].isGit + } + return out[i].path < out[j].path + }) + return out +} + +// fuzzyFilterFolders returns entries fuzzy-matching query (all if empty). +// Uses the project's own fuzzyScore (VS Code-style) from command_palette.go. +func fuzzyFilterFolders(all []folderEntry, query string) []folderEntry { + if strings.TrimSpace(query) == "" { + return all + } + q := strings.ToLower(query) + type scored struct { + entry folderEntry + score int + } + var hits []scored + for _, e := range all { + if s := fuzzyScore(strings.ToLower(e.label()), q); s >= 0 { + hits = append(hits, scored{e, s}) + } + } + // Sort by score descending (best match first). + sort.Slice(hits, func(i, j int) bool { + return hits[i].score > hits[j].score + }) + out := make([]folderEntry, 0, len(hits)) + for _, h := range hits { + out = append(out, h.entry) + } + return out +} + +func (m *FolderPickerModel) Init() tea.Cmd { return textinput.Blink } + +func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "up", "ctrl+k": + if m.selected > 0 { + m.selected-- + } + return m, nil + case "down", "ctrl+j": + if m.selected < len(m.filtered)-1 { + m.selected++ + } + return m, nil + case "right": + if len(m.filtered) > 0 { + m.descend(m.filtered[m.selected].path) + } + return m, nil + case "enter": + if len(m.filtered) > 0 { + picked := m.filtered[m.selected].path + return m, func() tea.Msg { return folderPickedMsg{path: picked} } + } + return m, nil + } + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.filtered = fuzzyFilterFolders(m.all, m.input.Value()) + if m.selected >= len(m.filtered) { + m.selected = 0 + } + return m, cmd +} + +// descend repopulates the list with the candidate children of dir. If dir has no +// sub-directories it is treated as a leaf and picked directly. +func (m *FolderPickerModel) descend(dir string) tea.Cmd { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var children []folderEntry + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + p := filepath.Join(dir, e.Name()) + children = append(children, folderEntry{path: p, isGit: dirIsGitRepo(p)}) + } + if len(children) == 0 { + return nil // leaf: user can press enter to pick it + } + m.root = dir + m.all = children + m.input.SetValue("") + m.filtered = m.all + m.selected = 0 + return nil +} + +func (m *FolderPickerModel) View() string { + var b strings.Builder + b.WriteString(Bold.Render("Set up a project — pick a folder") + "\n\n") + b.WriteString(m.input.View() + "\n\n") + + visible := m.height - 8 + if visible < 5 { + visible = 5 + } + start := 0 + if m.selected >= visible { + start = m.selected - visible + 1 + } + end := start + visible + if end > len(m.filtered) { + end = len(m.filtered) + } + for i := start; i < end; i++ { + e := m.filtered[i] + prefix := " " + if i == m.selected { + prefix = "> " + } + tag := "" + if e.isGit { + tag = lipgloss.NewStyle().Foreground(ColorPrimary).Render(" git ●") + } + line := prefix + collapseHome(e.path) + tag + if i == m.selected { + line = Bold.Render(line) + } + b.WriteString(line + "\n") + } + if len(m.filtered) == 0 { + b.WriteString(Dim.Render(" (no matches — keep typing, or esc to go back)") + "\n") + } + b.WriteString("\n" + HelpBar.Render( + HelpKey.Render("↑↓")+" "+HelpDesc.Render("select")+" "+ + HelpKey.Render("→")+" "+HelpDesc.Render("open")+" "+ + HelpKey.Render("enter")+" "+HelpDesc.Render("pick")+" "+ + HelpKey.Render("esc")+" "+HelpDesc.Render("back"))) + return b.String() +} + +// collapseHome shortens /home/u/... to ~/... for display. +func collapseHome(p string) string { + if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) { + return "~" + strings.TrimPrefix(p, home) + } + return p +} + +func (m *FolderPickerModel) SetSize(w, h int) { m.width, m.height = w, h } diff --git a/internal/ui/folderpicker_test.go b/internal/ui/folderpicker_test.go new file mode 100644 index 00000000..5b6ea66a --- /dev/null +++ b/internal/ui/folderpicker_test.go @@ -0,0 +1,18 @@ +package ui + +import "testing" + +func TestFuzzyFilterFolders(t *testing.T) { + all := []folderEntry{ + {path: "/home/u/Projects/acme-rocket", isGit: true}, + {path: "/home/u/Projects/rocket-sim", isGit: true}, + {path: "/home/u/work/notes", isGit: false}, + } + got := fuzzyFilterFolders(all, "rocket") + if len(got) != 2 { + t.Fatalf("want 2 matches, got %d (%+v)", len(got), got) + } + if len(fuzzyFilterFolders(all, "")) != 3 { + t.Errorf("empty query should return all entries") + } +} From 6985a60d9a89b5a24f7805aa4aaf7826d5d74781 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:01:00 -0500 Subject: [PATCH 07/17] fix(onboarding): folder picker cursor clamp, drop dead descend return, cache home - descend() no longer returns tea.Cmd (always was nil, discarded at call site) - m.selected clamped to len-1 after filter rather than zeroed, preserving valid cursor positions - home dir cached as FolderPickerModel.home field; collapseHome converted to method to avoid per-row os.UserHomeDir() syscall Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/folderpicker.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/ui/folderpicker.go b/internal/ui/folderpicker.go index 827686e7..db2ec401 100644 --- a/internal/ui/folderpicker.go +++ b/internal/ui/folderpicker.go @@ -32,6 +32,7 @@ type FolderPickerModel struct { root string width int height int + home string } // NewFolderPickerModel seeds the picker from common project roots. @@ -41,7 +42,8 @@ func NewFolderPickerModel(width, height int) *FolderPickerModel { ti.Focus() ti.Prompt = "> " - m := &FolderPickerModel{input: ti, width: width, height: height} + home, _ := os.UserHomeDir() + m := &FolderPickerModel{input: ti, width: width, height: height, home: home} m.all = seedCandidateFolders() m.filtered = m.all return m @@ -142,6 +144,9 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { m.input, cmd = m.input.Update(msg) m.filtered = fuzzyFilterFolders(m.all, m.input.Value()) if m.selected >= len(m.filtered) { + m.selected = len(m.filtered) - 1 + } + if m.selected < 0 { m.selected = 0 } return m, cmd @@ -149,10 +154,10 @@ func (m *FolderPickerModel) Update(msg tea.Msg) (*FolderPickerModel, tea.Cmd) { // descend repopulates the list with the candidate children of dir. If dir has no // sub-directories it is treated as a leaf and picked directly. -func (m *FolderPickerModel) descend(dir string) tea.Cmd { +func (m *FolderPickerModel) descend(dir string) { entries, err := os.ReadDir(dir) if err != nil { - return nil + return } var children []folderEntry for _, e := range entries { @@ -163,14 +168,13 @@ func (m *FolderPickerModel) descend(dir string) tea.Cmd { children = append(children, folderEntry{path: p, isGit: dirIsGitRepo(p)}) } if len(children) == 0 { - return nil // leaf: user can press enter to pick it + return // leaf: user can press enter to pick it } m.root = dir m.all = children m.input.SetValue("") m.filtered = m.all m.selected = 0 - return nil } func (m *FolderPickerModel) View() string { @@ -200,7 +204,7 @@ func (m *FolderPickerModel) View() string { if e.isGit { tag = lipgloss.NewStyle().Foreground(ColorPrimary).Render(" git ●") } - line := prefix + collapseHome(e.path) + tag + line := prefix + m.collapseHome(e.path) + tag if i == m.selected { line = Bold.Render(line) } @@ -218,9 +222,9 @@ func (m *FolderPickerModel) View() string { } // collapseHome shortens /home/u/... to ~/... for display. -func collapseHome(p string) string { - if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) { - return "~" + strings.TrimPrefix(p, home) +func (m *FolderPickerModel) collapseHome(p string) string { + if m.home != "" && strings.HasPrefix(p, m.home) { + return "~" + strings.TrimPrefix(p, m.home) } return p } From eecc355a0ab0a9cbe6c0f5aa9c1a13fb19e0cc3f Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:02:01 -0500 Subject: [PATCH 08/17] feat(onboarding): first-run Welcome fork model Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/welcome.go | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 internal/ui/welcome.go diff --git a/internal/ui/welcome.go b/internal/ui/welcome.go new file mode 100644 index 00000000..6e2965c2 --- /dev/null +++ b/internal/ui/welcome.go @@ -0,0 +1,64 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// welcomeChoice is what the user picked on the first-run Welcome fork. +type welcomeChoice int + +const ( + welcomeNone welcomeChoice = iota + welcomeSetupProject + welcomeStartTask +) + +// WelcomeModel is the first-run fork shown when there's no project to suggest: +// "Set up a project" vs "Just start a task" (in the personal project). +type WelcomeModel struct { + cursor int // 0 = setup, 1 = start task + width int + height int +} + +func NewWelcomeModel(width, height int) *WelcomeModel { + return &WelcomeModel{width: width, height: height} +} + +// MoveLeft/MoveRight/Choice drive selection; key handling lives in app.go so it +// composes with the global update loop (mirrors viewProjectDetectConfirm). +func (m *WelcomeModel) MoveLeft() { m.cursor = 0 } +func (m *WelcomeModel) MoveRight() { m.cursor = 1 } +func (m *WelcomeModel) Choice() welcomeChoice { + if m.cursor == 0 { + return welcomeSetupProject + } + return welcomeStartTask +} +func (m *WelcomeModel) SetSize(w, h int) { m.width, m.height = w, h } + +func (m *WelcomeModel) View() string { + title := lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Render("Welcome to TaskYou 👋") + body := "How do you want to start?" + + btn := func(label string, active bool) string { + s := lipgloss.NewStyle().Padding(0, 3).Margin(0, 1).Border(lipgloss.RoundedBorder()) + if active { + s = s.BorderForeground(ColorPrimary).Bold(true) + } else { + s = s.BorderForeground(lipgloss.Color("240")) + } + return s.Render(label) + } + buttons := lipgloss.JoinHorizontal(lipgloss.Top, + btn("Set up a project", m.cursor == 0), + btn("Just start a task", m.cursor == 1), + ) + help := HelpBar.Render( + HelpKey.Render("←/→") + " " + HelpDesc.Render("choose") + " " + + HelpKey.Render("enter") + " " + HelpDesc.Render("select")) + + content := lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", buttons, "", help) + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(ColorPrimary).Padding(1, 3).Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} From ef740a88684ddc1efc910b5fe26a39f9d4f1dab9 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:06:59 -0500 Subject: [PATCH 09/17] =?UTF-8?q?feat(onboarding):=20launch=20decision=20t?= =?UTF-8?q?ree,=20welcome=20fork,=20picker=E2=86=92enriched=20suggestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/app.go | 155 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 14 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 554a350b..0783aaff 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -49,6 +49,8 @@ const ( ViewChangeStatus ViewCommandPalette ViewProjectDetectConfirm // Offer to create a project for the current git repo + ViewWelcome // first-run fork: set up a project vs start a task + ViewFolderPicker // fuzzy folder picker for "set up a project" ) // KeyMap defines key bindings. @@ -458,6 +460,10 @@ type AppModel struct { detectedInstructionSource string // File the inferred instructions came from projectDetectionOffered bool // Guard so we only offer once per session + // First-run onboarding views + welcomeView *WelcomeModel + folderPicker *FolderPickerModel + // Delete confirmation state deleteConfirm *huh.Form deleteConfirmValue bool @@ -739,6 +745,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentView == ViewProjectDetectConfirm && m.projectDetectConfirm != nil { return m.updateProjectDetectConfirm(msg) } + // Folder picker: route all messages (keys + cursor blink) to the picker + // so its text input stays live. The picker emits folderPickedMsg on enter + // (handled below), and esc closes it back to the Welcome fork. + if m.currentView == ViewFolderPicker && m.folderPicker != nil { + if picked, ok := msg.(folderPickedMsg); ok { + return m.handleFolderPicked(picked.path) + } + if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "esc" || key.String() == "ctrl+c") { + m.folderPicker = nil + m.welcomeView = NewWelcomeModel(m.width, m.height) + m.currentView = ViewWelcome + return m, nil + } + var cmd tea.Cmd + m.folderPicker, cmd = m.folderPicker.Update(msg) + return m, cmd + } if m.currentView == ViewDeleteConfirm && m.deleteConfirm != nil { return m.updateDeleteConfirm(msg) } @@ -804,6 +827,36 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.commandPaletteView.Init() } + // First-run Welcome fork key handling. + if m.currentView == ViewWelcome && m.welcomeView != nil { + switch msg.String() { + case "left", "h": + m.welcomeView.MoveLeft() + return m, nil + case "right", "l": + m.welcomeView.MoveRight() + return m, nil + case "enter": + switch m.welcomeView.Choice() { + case welcomeSetupProject: + m.welcomeView = nil + m.folderPicker = NewFolderPickerModel(m.width, m.height) + m.currentView = ViewFolderPicker + return m, m.folderPicker.Init() + case welcomeStartTask: + m.welcomeView = nil + m.newTaskForm = NewFormModel(m.db, m.width, m.height, m.workingDir, m.availableExecutors) + m.previousView = ViewDashboard + m.currentView = ViewNewTask + return m, m.newTaskForm.Init() + } + case "esc", "ctrl+c": + m.welcomeView = nil + m.currentView = ViewDashboard + return m, nil + } + } + // Route to current view switch m.currentView { case ViewDashboard: @@ -832,26 +885,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tasks = msg.tasks m.err = msg.err - // First-time experience: auto-open new task form on first run when no tasks exist + // First-load onboarding routing (runs once per process start). if m.isFirstLoad { m.isFirstLoad = false m.showWelcome = len(msg.tasks) == 0 - // If we're inside a git repo that has no TaskYou project yet, offer to - // create one (inferring details from the repo). This takes priority over - // the generic onboarding form since it's more directly useful. + // 1. In a real project folder we don't yet track? Offer to set it up + // (LLM-enriched). Works on every launch, until dismissed per-path. if model, cmd, offered := m.maybeOfferProjectCreation(); offered { return model, cmd } - // If this is the very first run (no tasks, onboarding not completed), auto-open new task form - if len(msg.tasks) == 0 && m.db.IsFirstRun() && !m.onboardingShown { - m.onboardingShown = true - // Auto-open the new task form to guide users to create their first task - m.newTaskForm = NewFormModel(m.db, m.width, m.height, m.workingDir, m.availableExecutors) + // 2. No real projects yet (only "personal") and we're in a junk folder: + // show the Welcome fork instead of dumping into a task form. + if m.shouldShowWelcomeFork(msg.tasks) { + m.welcomeView = NewWelcomeModel(m.width, m.height) m.previousView = m.currentView - m.currentView = ViewNewTask - return m, m.newTaskForm.Init() + m.currentView = ViewWelcome + return m, nil } } @@ -1493,6 +1544,14 @@ func (m *AppModel) View() string { return m.viewProjectChangeConfirm() case ViewProjectDetectConfirm: return m.viewProjectDetectConfirm() + case ViewWelcome: + if m.welcomeView != nil { + return m.welcomeView.View() + } + case ViewFolderPicker: + if m.folderPicker != nil { + return m.folderPicker.View() + } case ViewDeleteConfirm: return m.viewDeleteConfirm() case ViewCloseConfirm: @@ -3147,6 +3206,55 @@ func (m *AppModel) updateProjectChangeConfirm(msg tea.Msg) (tea.Model, tea.Cmd) return m, cmd } +// shouldShowWelcomeFork reports whether to show the first-run Welcome fork: +// no real projects beyond "personal", and the cwd is not a project candidate +// (otherwise maybeOfferProjectCreation handled it). Gated on "no tasks yet". +func (m *AppModel) shouldShowWelcomeFork(tasks []*db.Task) bool { + if m.db == nil { + return false + } + if isProjectCandidate(m.workingDir) { + return false + } + if !m.onlyPersonalProject() { + return false + } + // We already checked onlyPersonalProject above; combining "no tasks" + + // "only personal" is the intended gate (per agreed design, not IsFirstRun). + return len(tasks) == 0 +} + +// onlyPersonalProject reports whether the only project is the auto-created +// "personal" one (i.e. the user hasn't set up a real project yet). +func (m *AppModel) onlyPersonalProject() bool { + projects, err := m.db.ListProjects() + if err != nil { + return false + } + for _, p := range projects { + if p.Name != "personal" { + return false + } + } + return true +} + +// handleFolderPicked builds a project from the chosen folder, enriches it via +// inference, and shows the same confirm card used for auto-detected projects. +func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { + detected, source := detectProjectFromDir(path) + if detected == nil { + // Folder had no signals; treat the chosen dir as a plain (non-worktree) project. + detected = &db.Project{Name: inferProjectName(path), Path: filepath.Clean(path), UseWorktrees: dirIsGitRepo(path)} + } + if meta, err := ai.InferProjectMetadata(path, ""); err == nil { + applyInferredMetadata(detected, meta) + } + detected.Name = uniqueProjectName(m.db, detected.Name) + m.folderPicker = nil + return m.showProjectDetectConfirm(detected, source) +} + // maybeOfferProjectCreation checks whether the current working directory is a git // repo without an associated TaskYou project and, if so, opens a confirmation // modal offering to create one (with details inferred from the repo). The third @@ -3177,6 +3285,12 @@ func (m *AppModel) maybeOfferProjectCreation() (tea.Model, tea.Cmd, bool) { if detected == nil { return m, nil, false } + + // Enrich with claude -p inference; ignore failures (graceful degrade). + if meta, err := ai.InferProjectMetadata(m.workingDir, ""); err == nil { + applyInferredMetadata(detected, meta) + } + detected.Name = uniqueProjectName(m.db, detected.Name) m.projectDetectionOffered = true @@ -3190,12 +3304,17 @@ func (m *AppModel) showProjectDetectConfirm(project *db.Project, instructionSour m.projectDetectConfirmValue = true var desc strings.Builder - desc.WriteString(fmt.Sprintf("This directory is a git repo with no TaskYou project yet.\n\nName: %s\nPath: %s\n", project.Name, project.Path)) + desc.WriteString(fmt.Sprintf("This directory looks like a project.\n\nName: %s\n", project.Name)) + if project.Aliases != "" { + desc.WriteString(fmt.Sprintf("Alias: %s\n", project.Aliases)) + } + desc.WriteString(fmt.Sprintf("Path: %s\n", project.Path)) if instructionSource != "" { desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", instructionSource)) - } else { - desc.WriteString("Instructions: none found (add later in Settings)\n") + } else if project.Instructions != "" { + desc.WriteString("Description: " + firstLine(project.Instructions) + "\n") } + desc.WriteString(fmt.Sprintf("Worktrees: %v\n", project.UseWorktrees)) desc.WriteString("\nYou can edit any of this later in Settings.") modalWidth := min(64, m.width-8) @@ -3218,6 +3337,14 @@ func (m *AppModel) showProjectDetectConfirm(project *db.Project, instructionSour return m, m.projectDetectConfirm.Init() } +// firstLine returns the first line of s (without the trailing newline). +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + func (m *AppModel) viewProjectDetectConfirm() string { if m.projectDetectConfirm == nil { return "" From d35efa4108896ef7bc6a1800f986393aee68732f Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:11:35 -0500 Subject: [PATCH 10/17] fix(onboarding): offer suggestion card for non-git candidates; drop dead field Gate maybeOfferProjectCreation on isProjectCandidate (git repo OR marker files) instead of dirIsGitRepo, closing the seam where a non-git folder with e.g. package.json/go.mod received neither the suggestion card nor the welcome fork. Remove the now-dead onboardingShown struct field (its only writer was removed earlier in this task). Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/app.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 0783aaff..5c14370c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -530,7 +530,6 @@ type AppModel struct { // First-time experience isFirstLoad bool // Track if this is the first load of tasks showWelcome bool // Show welcome message when kanban is empty - onboardingShown bool // Track if we've already shown the onboarding (to prevent double-triggering) // Version upgrade notification currentVersion string // Current binary version (e.g. "v0.1.0" or "dev") @@ -3255,19 +3254,20 @@ func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { return m.showProjectDetectConfirm(detected, source) } -// maybeOfferProjectCreation checks whether the current working directory is a git -// repo without an associated TaskYou project and, if so, opens a confirmation -// modal offering to create one (with details inferred from the repo). The third -// return value reports whether the offer was made; when false the caller should -// continue with its normal flow. +// maybeOfferProjectCreation checks whether the current working directory is a +// project candidate (git repo or recognised marker files) without an associated +// TaskYou project and, if so, opens a confirmation modal offering to create one +// (with details inferred from the directory). The third return value reports +// whether the offer was made; when false the caller should continue with its +// normal flow. func (m *AppModel) maybeOfferProjectCreation() (tea.Model, tea.Cmd, bool) { if m.projectDetectionOffered || m.db == nil || m.workingDir == "" { return m, nil, false } - // Only offer for git repos - non-git directories don't benefit from the - // worktree-based project model and aren't a clear signal of intent. - if !dirIsGitRepo(m.workingDir) { + // Offer for any project candidate (git repo OR marker files), not just git. + // Non-git candidates become non-worktree projects (git stays optional). + if !isProjectCandidate(m.workingDir) { return m, nil, false } From 6862879c6ad42b1c7be138c6266301dbaa67e8e6 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:13:18 -0500 Subject: [PATCH 11/17] fix(onboarding): preserve project-form input on validation errors Convert project-form validation early-returns from bare `m.err = ...; return m, nil` to `return m.reshowProjectFormWithError(...)` so the form is re-rendered with all user-typed values intact and an inline error, instead of losing form state. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/settings.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 0676e15e..f768e7ea 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -559,8 +559,7 @@ func (m *SettingsModel) saveProject() (*SettingsModel, tea.Cmd) { } if name == "" { - m.err = fmt.Errorf("name is required") - return m, nil + return m.reshowProjectFormWithError(fmt.Errorf("name is required")) } useWorktrees := m.projectFormUseWorktrees @@ -569,21 +568,18 @@ func (m *SettingsModel) saveProject() (*SettingsModel, tea.Cmd) { if m.editProject.ID != 0 { formPath := strings.TrimSpace(m.projectFormPath) if formPath == "" { - m.err = fmt.Errorf("directory is required") - return m, nil + return m.reshowProjectFormWithError(fmt.Errorf("directory is required")) } absPath, err := resolveProjectPath(formPath) if err != nil { - m.err = fmt.Errorf("invalid path: %w", err) - return m, nil + return m.reshowProjectFormWithError(fmt.Errorf("invalid path: %w", err)) } // When pointing an existing project at a new directory, require that // directory to already exist. This avoids silently creating (and // git-initializing) a directory at a mistyped path. if absPath != m.editProject.Path { if _, statErr := os.Stat(absPath); os.IsNotExist(statErr) { - m.err = fmt.Errorf("path does not exist: %s", absPath) - return m, nil + return m.reshowProjectFormWithError(fmt.Errorf("path does not exist: %s", absPath)) } } m.editProject.Path = absPath @@ -613,8 +609,7 @@ func (m *SettingsModel) saveProject() (*SettingsModel, tea.Cmd) { if pathExists { if !info.IsDir() { - m.err = fmt.Errorf("path is not a directory") - return m, nil + return m.reshowProjectFormWithError(fmt.Errorf("path is not a directory")) } if useWorktrees { From 5179fbc0c276059ec31e8cc017c7aecbd809d03b Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:18:13 -0500 Subject: [PATCH 12/17] test(onboarding): scripted first-run QA scenario + nested-repo note --- scripts/qa/ty-qa-firstrun.sh | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100755 scripts/qa/ty-qa-firstrun.sh diff --git a/scripts/qa/ty-qa-firstrun.sh b/scripts/qa/ty-qa-firstrun.sh new file mode 100755 index 00000000..27c4ca0d --- /dev/null +++ b/scripts/qa/ty-qa-firstrun.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Drive the FIRST-RUN onboarding experience across folder types against an +# isolated ty instance. Exercises the launch decision tree: +# - project candidate (git repo) -> enriched "New Project Detected" card +# - project candidate (non-git marker)-> card with Worktrees: false (git optional) +# - junk folder (no signals) -> Welcome fork (set up a project / start a task) +# +# Each scenario uses a FRESH isolated DB so it's a true first run. The suggestion +# card runs a real `claude -p` inference (needs claude on PATH), so allow ~15s. +# +# Usage: scripts/qa/ty-qa-firstrun.sh +# tmux attach -t task-ui- # to watch / drive manually +# scripts/qa/ty-qa-down.sh # tear down +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +ROOT="$TY_QA_ROOT/firstrun" +GITPROJ="$ROOT/acme-rocket" +MARKER="$ROOT/marker-only" +PLAIN="$ROOT/just-a-folder" +SID="$TY_UI_SESSION" + +echo "==> Building ty -> $TY_BIN" +( cd "$TY_REPO_ROOT" && go build -o "$TY_BIN" ./cmd/task ) + +echo "==> Preparing scenario folders under $ROOT" +rm -rf "$ROOT"; mkdir -p "$GITPROJ" "$MARKER" "$PLAIN" +# A) git repo with a README -> candidate, worktrees on +git -C "$GITPROJ" init -q +git -C "$GITPROJ" config user.email qa@ty.local +git -C "$GITPROJ" config user.name "ty qa" +printf '# Acme Rocket\n\nTool for launching rockets.\n' > "$GITPROJ/README.md" +git -C "$GITPROJ" add -A && git -C "$GITPROJ" commit -qm init +# B) non-git folder with a marker file -> candidate, worktrees OFF (git optional) +printf '{"name":"marker-pkg"}\n' > "$MARKER/package.json" +# C) PLAIN stays empty -> not a candidate -> Welcome fork + +launch() { # $1 = cwd + tmux kill-session -t "$SID" 2>/dev/null || true + rm -f "$WORKTREE_DB_PATH" "$TY_QA_STATE" # fresh DB => true first run + tmux new-session -d -s "$SID" -x "${TY_QA_COLS:-220}" -y "${TY_QA_ROWS:-50}" -n tui -c "$1" \ + "WORKTREE_DB_PATH='$WORKTREE_DB_PATH' WORKTREE_SESSION_ID='$WORKTREE_SESSION_ID' '$TY_BIN' --debug-state-file '$TY_QA_STATE'" +} + +cap() { tmux capture-pane -t "${SID}:tui" -p | sed 's/[[:space:]]*$//' | grep -v '^[[:space:]]*$'; } + +echo; echo "### Scenario A: git repo -> enriched suggestion card (inference, ~15s)" +launch "$GITPROJ"; sleep 16; cap | head -22 + +echo; echo "### Scenario B: non-git marker folder -> card with Worktrees: false (~15s)" +launch "$MARKER"; sleep 16; cap | head -22 + +echo; echo "### Scenario C: plain folder -> Welcome fork" +launch "$PLAIN"; sleep 6; cap | head -18 + +cat < Drive manually from here: + tmux attach -t $SID + Welcome fork: enter = Set up a project (-> fuzzy folder picker), →/enter = Just start a task + Folder picker: type to fuzzy-filter, ↑↓ select, → descend, enter pick, esc back + Suggestion card: y = Create Project, n = Not Now + Tear down: scripts/qa/ty-qa-down.sh --purge +EOF From 0f795595f58229188bd344fa55e36fa0dcc945a8 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:18:44 -0500 Subject: [PATCH 13/17] style: gofmt app.go after onboarding field removal --- internal/ui/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 5c14370c..9330a8cb 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -528,8 +528,8 @@ type AppModel struct { debugStatePath string // First-time experience - isFirstLoad bool // Track if this is the first load of tasks - showWelcome bool // Show welcome message when kanban is empty + isFirstLoad bool // Track if this is the first load of tasks + showWelcome bool // Show welcome message when kanban is empty // Version upgrade notification currentVersion string // Current binary version (e.g. "v0.1.0" or "dev") From ec0c41152b46a810ba0513c6508b26dbb2fcc9a1 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:26:02 -0500 Subject: [PATCH 14/17] fix(onboarding): picker dup-path guard, correct dismiss key, resize new views, keep permission mode Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/app.go | 39 ++++++++++++++++++++----- internal/ui/project_detect_flow_test.go | 2 +- internal/ui/settings.go | 1 + 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 9330a8cb..e700db40 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1505,6 +1505,12 @@ func (m *AppModel) applyWindowSize(width, height int) { if m.editTaskForm != nil { m.editTaskForm.SetSize(width, height) } + if m.welcomeView != nil { + m.welcomeView.SetSize(m.width, m.height) + } + if m.folderPicker != nil { + m.folderPicker.SetSize(m.width, m.height) + } } // View renders the current view. @@ -3241,6 +3247,13 @@ func (m *AppModel) onlyPersonalProject() bool { // handleFolderPicked builds a project from the chosen folder, enriches it via // inference, and shows the same confirm card used for auto-detected projects. func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { + if proj, err := m.db.GetProjectByPath(path); err == nil && proj != nil { + m.folderPicker = nil + m.notification = fmt.Sprintf("%s \"%s\" already covers that folder", IconDone(), proj.Name) + m.notifyUntil = time.Now().Add(4 * time.Second) + m.currentView = ViewDashboard + return m, m.loadTasks() + } detected, source := detectProjectFromDir(path) if detected == nil { // Folder had no signals; treat the chosen dir as a plain (non-worktree) project. @@ -3379,7 +3392,11 @@ func (m *AppModel) updateProjectDetectConfirm(msg tea.Msg) (tea.Model, tea.Cmd) switch keyMsg.String() { case "esc", "ctrl+c": // Treat dismissal like declining so we don't nag on every startup. - m.dismissProjectSuggestion() + dismissPath := m.workingDir + if m.detectedProject != nil && m.detectedProject.Path != "" { + dismissPath = m.detectedProject.Path + } + m.dismissProjectSuggestion(dismissPath) return m, nil } } @@ -3397,11 +3414,19 @@ func (m *AppModel) updateProjectDetectConfirm(msg tea.Msg) (tea.Model, tea.Cmd) m.detectedProject = nil return m, m.createDetectedProject(detected) } - m.dismissProjectSuggestion() + dismissPath := m.workingDir + if detected != nil && detected.Path != "" { + dismissPath = detected.Path + } + m.dismissProjectSuggestion(dismissPath) return m, nil } if m.projectDetectConfirm.State == huh.StateAborted { - m.dismissProjectSuggestion() + dismissPath := m.workingDir + if m.detectedProject != nil && m.detectedProject.Path != "" { + dismissPath = m.detectedProject.Path + } + m.dismissProjectSuggestion(dismissPath) return m, nil } @@ -3409,10 +3434,10 @@ func (m *AppModel) updateProjectDetectConfirm(msg tea.Msg) (tea.Model, tea.Cmd) } // dismissProjectSuggestion records that the user declined to create a project for -// the current directory and closes the modal. -func (m *AppModel) dismissProjectSuggestion() { - if m.db != nil && m.workingDir != "" { - m.db.SetSetting(projectSuggestionDismissedKey(m.workingDir), "1") +// the given path and closes the modal. +func (m *AppModel) dismissProjectSuggestion(path string) { + if m.db != nil && path != "" { + m.db.SetSetting(projectSuggestionDismissedKey(path), "1") } m.projectDetectConfirm = nil m.detectedProject = nil diff --git a/internal/ui/project_detect_flow_test.go b/internal/ui/project_detect_flow_test.go index 23e919ef..a5f0198b 100644 --- a/internal/ui/project_detect_flow_test.go +++ b/internal/ui/project_detect_flow_test.go @@ -86,7 +86,7 @@ func TestDismissProjectSuggestionPersists(t *testing.T) { mkGitRepo(t, repo) m, database := newDetectTestModel(t, repo) - m.dismissProjectSuggestion() + m.dismissProjectSuggestion(m.workingDir) v, _ := database.GetSetting(projectSuggestionDismissedKey(repo)) if v != "1" { diff --git a/internal/ui/settings.go b/internal/ui/settings.go index f768e7ea..54eef64f 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -687,6 +687,7 @@ func (m *SettingsModel) reshowProjectFormWithError(err error) (*SettingsModel, t m.editProject.Instructions = strings.TrimSpace(m.projectFormInstructions) m.editProject.ClaudeConfigDir = strings.TrimSpace(m.projectFormClaudeConfigDir) m.editProject.UseWorktrees = m.projectFormUseWorktrees + m.editProject.DefaultPermissionMode = strings.TrimSpace(m.projectFormPermissionMode) model, cmd := m.showProjectForm(m.editProject) model.err = err From fa09c1311473a6c5c9784b55068e3da90a361e59 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:29:12 -0500 Subject: [PATCH 15/17] feat(onboarding): run project inference async so the card shows instantly --- internal/ui/app.go | 76 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index e700db40..aa761aab 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -458,6 +458,7 @@ type AppModel struct { projectDetectConfirmValue bool detectedProject *db.Project // Inferred project pending user confirmation detectedInstructionSource string // File the inferred instructions came from + detectedInferencePending bool // Async claude -p inference is still in flight for detectedProject projectDetectionOffered bool // Guard so we only offer once per session // First-run onboarding views @@ -1022,6 +1023,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.refreshAllPRs()) } + case projectInferredMsg: + // Only apply if the card for this exact path is still showing. + if m.currentView == ViewProjectDetectConfirm && m.detectedProject != nil && m.detectedProject.Path == msg.path { + m.detectedInferencePending = false + if msg.err == nil { + applyInferredMetadata(m.detectedProject, msg.meta) + m.detectedProject.Name = uniqueProjectName(m.db, m.detectedProject.Name) + } + m.buildProjectDetectForm() + return m, m.projectDetectConfirm.Init() + } + return m, nil + case taskLoadedMsg: // Reset transition flag now that task is loaded m.taskTransitionInProgress = false @@ -3244,8 +3258,9 @@ func (m *AppModel) onlyPersonalProject() bool { return true } -// handleFolderPicked builds a project from the chosen folder, enriches it via -// inference, and shows the same confirm card used for auto-detected projects. +// handleFolderPicked builds a project from the chosen folder and shows the same +// confirm card used for auto-detected projects. Metadata inference runs +// asynchronously (see showProjectDetectConfirm) so the card appears instantly. func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { if proj, err := m.db.GetProjectByPath(path); err == nil && proj != nil { m.folderPicker = nil @@ -3259,9 +3274,6 @@ func (m *AppModel) handleFolderPicked(path string) (tea.Model, tea.Cmd) { // Folder had no signals; treat the chosen dir as a plain (non-worktree) project. detected = &db.Project{Name: inferProjectName(path), Path: filepath.Clean(path), UseWorktrees: dirIsGitRepo(path)} } - if meta, err := ai.InferProjectMetadata(path, ""); err == nil { - applyInferredMetadata(detected, meta) - } detected.Name = uniqueProjectName(m.db, detected.Name) m.folderPicker = nil return m.showProjectDetectConfirm(detected, source) @@ -3299,14 +3311,11 @@ func (m *AppModel) maybeOfferProjectCreation() (tea.Model, tea.Cmd, bool) { return m, nil, false } - // Enrich with claude -p inference; ignore failures (graceful degrade). - if meta, err := ai.InferProjectMetadata(m.workingDir, ""); err == nil { - applyInferredMetadata(detected, meta) - } - detected.Name = uniqueProjectName(m.db, detected.Name) m.projectDetectionOffered = true + // showProjectDetectConfirm fires the claude -p inference asynchronously and + // enriches the card in place when projectInferredMsg arrives. model, cmd := m.showProjectDetectConfirm(detected, source) return model, cmd, true } @@ -3314,16 +3323,39 @@ func (m *AppModel) maybeOfferProjectCreation() (tea.Model, tea.Cmd, bool) { func (m *AppModel) showProjectDetectConfirm(project *db.Project, instructionSource string) (tea.Model, tea.Cmd) { m.detectedProject = project m.detectedInstructionSource = instructionSource + m.detectedInferencePending = true m.projectDetectConfirmValue = true + m.previousView = m.currentView + m.currentView = ViewProjectDetectConfirm + m.buildProjectDetectForm() + + // Show the card instantly with rule-based values, then enrich it in place + // once the async claude -p inference returns (projectInferredMsg). + return m, tea.Batch(m.projectDetectConfirm.Init(), inferProjectCmd(project.Path, "")) +} + +// buildProjectDetectForm (re)builds the detect-confirm huh form from the current +// detectedProject / detectedInstructionSource / detectedInferencePending state. +// It deliberately does NOT touch previousView or currentView so it can be called +// again when async inference arrives to refresh the card in place. +func (m *AppModel) buildProjectDetectForm() { + project := m.detectedProject + if project == nil { + return + } + var desc strings.Builder + if m.detectedInferencePending { + desc.WriteString("✨ Inferring project details…\n\n") + } desc.WriteString(fmt.Sprintf("This directory looks like a project.\n\nName: %s\n", project.Name)) if project.Aliases != "" { desc.WriteString(fmt.Sprintf("Alias: %s\n", project.Aliases)) } desc.WriteString(fmt.Sprintf("Path: %s\n", project.Path)) - if instructionSource != "" { - desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", instructionSource)) + if m.detectedInstructionSource != "" { + desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", m.detectedInstructionSource)) } else if project.Instructions != "" { desc.WriteString("Description: " + firstLine(project.Instructions) + "\n") } @@ -3344,10 +3376,6 @@ func (m *AppModel) showProjectDetectConfirm(project *db.Project, instructionSour ).WithTheme(huh.ThemeDracula()). WithWidth(modalWidth - 6). WithShowHelp(true) - - m.previousView = m.currentView - m.currentView = ViewProjectDetectConfirm - return m, m.projectDetectConfirm.Init() } // firstLine returns the first line of s (without the trailing newline). @@ -4079,6 +4107,22 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// projectInferredMsg carries the result of an async claude -p inference for the +// project currently being offered in the detect-confirm card. +type projectInferredMsg struct { + path string + meta ai.ProjectMetadata + err error +} + +// inferProjectCmd runs project inference off the UI loop and reports the result. +func inferProjectCmd(path, configDir string) tea.Cmd { + return func() tea.Msg { + meta, err := ai.InferProjectMetadata(path, configDir) + return projectInferredMsg{path: path, meta: meta, err: err} + } +} + // Messages type tasksLoadedMsg struct { tasks []*db.Task From 34991e5d03b95f7a8a7734a12ca0dae889ace86d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 09:32:22 -0500 Subject: [PATCH 16/17] fix(onboarding): let projectInferredMsg reach its handler past the form router --- internal/ui/app.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index aa761aab..72a8d214 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -743,7 +743,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateProjectChangeConfirm(msg) } if m.currentView == ViewProjectDetectConfirm && m.projectDetectConfirm != nil { - return m.updateProjectDetectConfirm(msg) + // Async inference results must reach the main switch (case + // projectInferredMsg) to enrich the card in place; all other + // messages (keys) drive the confirm form. + if _, ok := msg.(projectInferredMsg); !ok { + return m.updateProjectDetectConfirm(msg) + } } // Folder picker: route all messages (keys + cursor blink) to the picker // so its text input stays live. The picker emits folderPickedMsg on enter From daa90348f868ca53537438f501c2a53f9db1712a Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 5 Jun 2026 11:02:31 -0500 Subject: [PATCH 17/17] test(qa): VHS screenshot + R2 publish helpers for one-command PR evidence - ty-qa-shoot.sh: render a ty TUI screen to PNG via VHS (correct terminal sizing; tmux capture mis-reports width and corrupts modals) - ty-qa-publish.sh: upload shots to r2-personal:qa-evidence and print the PR-ready markdown image block - README: document the up -> shoot -> publish flow --- scripts/qa/README.md | 34 +++++++++++++++++++ scripts/qa/ty-qa-publish.sh | 48 +++++++++++++++++++++++++++ scripts/qa/ty-qa-shoot.sh | 65 +++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100755 scripts/qa/ty-qa-publish.sh create mode 100755 scripts/qa/ty-qa-shoot.sh diff --git a/scripts/qa/README.md b/scripts/qa/README.md index dce0ed30..d7f3e226 100644 --- a/scripts/qa/README.md +++ b/scripts/qa/README.md @@ -92,6 +92,40 @@ scripts/qa/ty-qa-state.sh '.detail.has_panes' # => true (panes joined) scripts/qa/ty-qa-down.sh --purge ``` +## Screenshots & PR evidence (VHS + R2) + +To attach real-TUI screenshots to a PR, render with **VHS** and publish to the +public R2 evidence bucket — no manual uploads, no friction. + +```bash +scripts/qa/ty-qa-up.sh # build + isolated instance + +# Render screens (VHS sizes the terminal correctly; ty renders in-pane via TMUX env). +# A fresh DB per shot => true first-run. Extra args are VHS tape lines. +mkdir -p /tmp/ty-qa/shots +scripts/qa/ty-qa-shoot.sh "$TY_QA_PROJECTS/demo" /tmp/ty-qa/shots/01-card.png "Sleep 9s" # git-repo card (waits for claude -p) +scripts/qa/ty-qa-shoot.sh /tmp/ty-qa/plainfolder /tmp/ty-qa/shots/02-welcome.png "Sleep 5s" # welcome fork +scripts/qa/ty-qa-shoot.sh /tmp/ty-qa/plainfolder /tmp/ty-qa/shots/03-picker.png \ + "Sleep 5s" "Enter" "Sleep 1s" 'Type "ty"' "Sleep 2s" # fork -> picker -> filter + +# Upload + get the markdown image block (prefix is usually the PR number). +scripts/qa/ty-qa-publish.sh 555 /tmp/ty-qa/shots/*.png +# -> ![01-card](https://pub-...r2.dev/taskyou-qa//555-01-card.png) ... +``` + +Then paste the printed markdown into a PR comment (or `gh pr comment -F -`). + +**Why VHS, not `tmux capture-pane`:** a detached tmux session mis-reports its +width to bubbletea, so centred modals overflow and render corrupted. VHS runs +the TUI in a correctly-sized headless terminal — screenshots match real users. + +**Tooling / config:** needs `vhs` and `imagemagick` (`brew install vhs imagemagick`), +and a configured `rclone` remote. `ty-qa-publish.sh` writes to +`r2-personal:qa-evidence/taskyou-qa//` and prints public +`pub-….r2.dev` URLs. The write remote is **`r2-personal`** (the read-only `r2` +remote returns 403 on PutObject); override via `TY_QA_R2_REMOTE`/`TY_QA_R2_BUCKET`/ +`TY_QA_R2_PUBLIC`. No credentials live in the scripts — they're in the rclone remote. + ## Gotchas - The TUI must run **inside** `task-ui-` — `joinTmuxPane` attaches agent panes there. diff --git a/scripts/qa/ty-qa-publish.sh b/scripts/qa/ty-qa-publish.sh new file mode 100755 index 00000000..0e4cb039 --- /dev/null +++ b/scripts/qa/ty-qa-publish.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Upload QA screenshots/gifs to the public R2 "evidence" bucket and print a +# ready-to-paste markdown image block for a PR comment. +# +# Usage: +# ty-qa-publish.sh [file ...] +# prefix namespaces the objects, e.g. a PR number ("555") +# file... PNGs / GIFs / MP4s to upload +# +# Example: +# ty-qa-publish.sh 555 /tmp/ty-qa/shots/*.png /tmp/ty-qa/shots/walkthrough.gif +# # -> uploads, prints ![welcome](https://pub-...r2.dev/taskyou-qa//555-welcome.png) +# +# Config (override via env). NOTE: no credentials live here — the rclone remote +# holds them. `r2-personal` is the remote with WRITE access to the bucket +# (the read-only `r2` remote returns 403 on PutObject). +TY_QA_R2_REMOTE="${TY_QA_R2_REMOTE:-r2-personal}" +TY_QA_R2_BUCKET="${TY_QA_R2_BUCKET:-qa-evidence}" +TY_QA_R2_KEYPREFIX="${TY_QA_R2_KEYPREFIX:-taskyou-qa}" +TY_QA_R2_PUBLIC="${TY_QA_R2_PUBLIC:-https://pub-e209f789a78e432384c9a13a5d956e7c.r2.dev}" + +set -euo pipefail +command -v rclone >/dev/null || { echo "ty-qa: rclone not installed/configured" >&2; exit 1; } +[ "$#" -ge 2 ] || { echo "usage: ty-qa-publish.sh [file ...]" >&2; exit 1; } + +PREFIX="$1"; shift +DATE="$(date +%F)" +DEST="$TY_QA_R2_REMOTE:$TY_QA_R2_BUCKET/$TY_QA_R2_KEYPREFIX/$DATE" +PUB="$TY_QA_R2_PUBLIC/$TY_QA_R2_KEYPREFIX/$DATE" + +echo "==> uploading to $DEST" >&2 +echo "" +echo "" +for f in "$@"; do + base="$(basename "$f")" + key="$PREFIX-$base" + ct="image/png" + case "$base" in + *.gif) ct="image/gif" ;; + *.mp4) ct="video/mp4" ;; + *.jpg|*.jpeg) ct="image/jpeg" ;; + esac + # --no-check-dest + --s3-no-head: the token can PutObject but not Head/List, + # so skip rclone's existence/verify HEAD calls (they 403 otherwise). + rclone copyto "$f" "$DEST/$key" --no-check-dest --s3-no-head \ + --header-upload "Content-Type: $ct" >/dev/null + echo "![${base%.*}]($PUB/$key)" +done diff --git a/scripts/qa/ty-qa-shoot.sh b/scripts/qa/ty-qa-shoot.sh new file mode 100755 index 00000000..e154ec28 --- /dev/null +++ b/scripts/qa/ty-qa-shoot.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Render a single ty TUI screen to a PNG using VHS. +# +# Why VHS (not `tmux capture-pane`): a detached tmux session mis-reports its +# width to bubbletea, so modals/centred views overflow and render corrupted in +# captures. VHS runs the TUI in its own correctly-sized headless terminal, so +# the screenshot matches what real users see. ty renders in-pane (runLocal) +# whenever $TMUX is set, so we set a dummy TMUX and don't need a real tmux here. +# +# Usage: +# ty-qa-shoot.sh [ ...] +# cwd directory to launch ty in (drives first-run detection) +# out.png destination PNG (the final TUI frame is captured) +# vhs-line optional extra VHS tape commands run after the TUI appears, e.g. +# "Sleep 5s" "Enter" 'Type "ty"' "Sleep 2s" +# (when omitted, the script waits 6s and screenshots) +# +# Examples: +# ty-qa-shoot.sh "$TY_QA_PROJECTS/demo" /tmp/card.png "Sleep 9s" # git-repo card (waits for claude -p inference) +# ty-qa-shoot.sh /tmp/plain /tmp/welcome.png "Sleep 5s" # welcome fork +# ty-qa-shoot.sh /tmp/plain /tmp/picker.png "Sleep 5s" "Enter" "Sleep 1s" 'Type "ty"' "Sleep 2s" +# +# Requires: vhs (brew install vhs), magick (brew install imagemagick). +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +ty_qa_require_built + +CWD="$1"; OUT="$2"; shift 2 +command -v vhs >/dev/null || { echo "ty-qa: vhs not installed (brew install vhs)" >&2; exit 1; } +command -v magick >/dev/null || { echo "ty-qa: imagemagick not installed (brew install imagemagick)" >&2; exit 1; } + +W="${TY_QA_SHOT_W:-1180}" +H="${TY_QA_SHOT_H:-900}" +FS="${TY_QA_SHOT_FONTSIZE:-20}" + +TAPE="$(mktemp -t tyqa-XXXX).tape" +GIF="${OUT%.png}.gif" +{ + echo "Output \"$GIF\"" + echo "Set FontSize $FS" + echo "Set Width $W" + echo "Set Height $H" + echo "Set Padding 24" + echo 'Set Shell "bash"' + echo 'Env TMUX "vhs"' # make ty render in-pane + echo "Env WORKTREE_DB_PATH \"$WORKTREE_DB_PATH\"" + echo "Env WORKTREE_SESSION_ID \"$WORKTREE_SESSION_ID\"" + echo 'Hide' + echo "Type \"rm -f $WORKTREE_DB_PATH && cd $CWD && clear\"" # fresh DB => true first run + echo 'Enter' + echo 'Show' + echo "Type \"$TY_BIN\"" + echo 'Enter' + if [ "$#" -eq 0 ]; then echo 'Sleep 6s'; fi + for line in "$@"; do echo "$line"; done +} > "$TAPE" + +vhs "$TAPE" >/dev/null +# VHS's `Screenshot` command is unreliable across versions; the robust path is +# to coalesce the recorded gif and keep its final frame. +FRAMES="$(mktemp -d)" +magick "$GIF" -coalesce "$FRAMES/f_%04d.png" +cp "$(ls "$FRAMES"/f_*.png | tail -1)" "$OUT" +rm -rf "$FRAMES" "$TAPE" +echo "shot -> $OUT"