diff --git a/internal/ai/project_infer.go b/internal/ai/project_infer.go new file mode 100644 index 00000000..b5a6068c --- /dev/null +++ b/internal/ai/project_infer.go @@ -0,0 +1,126 @@ +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))) + cmd.WaitDelay = 2 * time.Second + out, err := cmd.CombinedOutput() + if err != nil { + return ProjectMetadata{}, fmt.Errorf("claude -p inference failed: %w\noutput: %s", err, strings.TrimSpace(string(out))) + } + 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 forward from each '{' up to the last '}'. +func parseInferenceJSON(raw string) (ProjectMetadata, error) { + s := strings.TrimSpace(raw) + end := strings.LastIndex(s, "}") + if end < 0 { + return ProjectMetadata{}, fmt.Errorf("no JSON object found in inference output") + } + 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 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 new file mode 100644 index 00000000..50ccb163 --- /dev/null +++ b/internal/ai/project_infer_test.go @@ -0,0 +1,36 @@ +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") + } + + // 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) { + 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") + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 554a350b..72a8d214 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. @@ -456,8 +458,13 @@ 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 + welcomeView *WelcomeModel + folderPicker *FolderPickerModel + // Delete confirmation state deleteConfirm *huh.Form deleteConfirmValue bool @@ -522,9 +529,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 - onboardingShown bool // Track if we've already shown the onboarding (to prevent double-triggering) + 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") @@ -737,7 +743,29 @@ 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 + // (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 +832,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 +890,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 } } @@ -972,6 +1028,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 @@ -1455,6 +1524,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. @@ -1493,6 +1568,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,19 +3230,74 @@ func (m *AppModel) updateProjectChangeConfirm(msg tea.Msg) (tea.Model, tea.Cmd) return m, cmd } -// 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. +// 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 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 + 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. + detected = &db.Project{Name: inferProjectName(path), Path: filepath.Clean(path), UseWorktrees: dirIsGitRepo(path)} + } + detected.Name = uniqueProjectName(m.db, detected.Name) + m.folderPicker = nil + return m.showProjectDetectConfirm(detected, source) +} + +// 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 } @@ -3177,9 +3315,12 @@ func (m *AppModel) maybeOfferProjectCreation() (tea.Model, tea.Cmd, bool) { if detected == nil { return m, nil, false } + 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 } @@ -3187,15 +3328,43 @@ 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 - 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)) - if instructionSource != "" { - desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", instructionSource)) - } else { - desc.WriteString("Instructions: none found (add later in Settings)\n") + 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 m.detectedInstructionSource != "" { + desc.WriteString(fmt.Sprintf("Instructions: imported from %s\n", m.detectedInstructionSource)) + } 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) @@ -3212,10 +3381,14 @@ 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). +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s } func (m *AppModel) viewProjectDetectConfirm() string { @@ -3252,7 +3425,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 } } @@ -3270,11 +3447,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 } @@ -3282,10 +3467,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 @@ -3927,6 +4112,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 diff --git a/internal/ui/folderpicker.go b/internal/ui/folderpicker.go new file mode 100644 index 00000000..db2ec401 --- /dev/null +++ b/internal/ui/folderpicker.go @@ -0,0 +1,232 @@ +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 + home string +} + +// 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 = "> " + + home, _ := os.UserHomeDir() + m := &FolderPickerModel{input: ti, width: width, height: height, home: home} + 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 = len(m.filtered) - 1 + } + if m.selected < 0 { + 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) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + 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 // leaf: user can press enter to pick it + } + m.root = dir + m.all = children + m.input.SetValue("") + m.filtered = m.all + m.selected = 0 +} + +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 + m.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 (m *FolderPickerModel) collapseHome(p string) string { + if m.home != "" && strings.HasPrefix(p, m.home) { + return "~" + strings.TrimPrefix(p, m.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") + } +} diff --git a/internal/ui/project_detect.go b/internal/ui/project_detect.go index d37f9627..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" ) @@ -70,20 +71,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{ + "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()) || 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 } @@ -114,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_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/project_detect_test.go b/internal/ui/project_detect_test.go index 9e8e7a05..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" ) @@ -130,6 +131,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")) @@ -162,3 +191,66 @@ func TestProjectSuggestionDismissedKey(t *testing.T) { t.Fatalf("expected cleaned paths to produce equal keys: %q vs %q", k1, k2) } } + +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() + + // 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) + } +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 0676e15e..54eef64f 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 { @@ -692,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 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) +} 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-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 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"