diff --git a/README.md b/README.md index 98e585a6e..1536e95f8 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ Workflows define the executable loop: inputs, stages, branches, parallelism, ret | `open-claude-design` | End-to-end design generation: interviews for output type/references, discovers your design system, generates from a prompt, refines with feedback, and exports a handoff directory. | `/workflow open-claude-design prompt="Team activity feed prototype using ./mocks/feed.png as a reference"` | | _author your own_ | Anything outside the built-ins: issue-to-PR, review-to-merge, migration, triage, release, compliance, or team-specific review pipelines. Describe the process in natural language and Atomic can scaffold a typed `workflow({...})` file with CLI inputs. | _"Create a reusable workflow that takes an issue, writes a plan, creates a branch, runs implementation and review stages, runs tests and lint, then stops for approval before final output."_ | -Run `/workflow list` to see installed workflows and `/workflow inputs ` for input schemas. `/workflow status `, `/workflow connect `, and `/workflow resume ` manage running or paused runs. Runnable references live in [`packages/coding-agent/examples/`](./packages/coding-agent/examples). +Run `/workflow list` to see installed workflows and `/workflow inputs ` for input schemas. `/workflow status `, `/workflow connect `, and `/workflow resume ` manage running or paused runs. First-run `goal`/`ralph` handoffs print the status/connect commands with the run id; use connect to watch, attach, and steer, or ask in the current chat for status or steering at any point. Runnable references live in [`packages/coding-agent/examples/`](./packages/coding-agent/examples). ### 2. Skills diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00e82336c..5c439f9eb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,8 @@ ### Added +- Added one-time first-run onboarding that explains Atomic workflows, uses an onboarding editor placeholder, lets users opt into normal chat with `/chat`, preserves other slash commands, saves a pre-login pasted task in memory only, and hands the first ready ticket/spec/task to the normal coding-agent session with `goal`/`ralph` workflow-routing guidance. +- Added first-run onboarding routing guidance that raises the parent session to high reasoning when supported, asks the coding agent to first make a text-only scope estimate from tickets/GitHub issues/specs, routes directly when the task is clearly tiny or small with high confidence, and only uses targeted read-only `codebase-locator`/`codebase-analyzer`/`codebase-pattern-finder` probing when referenced context must be read or scope is medium, large, unclear, risky, or not obviously tiny before choosing `goal` or `ralph`. - Added a first-class `search` built-in and exposed `find`/`search` in normal coding sessions ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). - Added hashline snapshot anchors across `read`, `search`, `write`, and successful `edit` results, plus hashline line-range/block/multi-section edit scripts with stale-tag safety checks and snapshot-based recovery for non-overlapping file drift, empty-replace validation, and fresh post-mutation tags for follow-up edits ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). - Added disabled-by-default `bashInterceptor.enabled` settings support with built-in shell anti-pattern rules, a `/settings` **Bash Interceptor** toggle, and optional `user_bash` extension routing, without changing the default local-execution behavior ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). @@ -17,10 +19,20 @@ ### Changed +- Updated first-run `goal`/`ralph` workflow handoff guidance so new users see `/workflow status ` and `/workflow connect ` next steps with the run id, understand connect is where they can watch, attach, and steer, and know they can ask the current chat for status or steering at any point. +- Removed the unused first-run onboarding scope-probe/routing-assessment subsystem and dead probe-only tests after normal-session prompt handoff became the active onboarding path. - Raised the bundled subagent and workflow-stage nesting budget to a hard maximum of five delegated levels, and documented the `0`-to-`5` recursion-guard range. ### Fixed +- Fixed the first-run onboarding input placeholder so it uses muted TUI text and still renders a visible cursor while empty, making the startup composer read as an editable field instead of static copy. +- Fixed `@` file-reference autocomplete in the first-run onboarding editor before the asynchronous `fd` readiness check completes by falling back to the built-in synchronous path completer while preserving `@` prefixes and quoted paths. +- Fixed workflow config/discovery isolation so `ATOMIC_CODING_AGENT_DIR` prevents home-global workflows from shadowing the bundled first-run onboarding `goal` and `ralph` targets. +- Fixed first-run onboarding returning-user detection so existing Atomic users with prior changelog state are marked onboarded and do not see the first-run CTA/placeholder when upgrading to a build that includes onboarding, while auth-only fresh installs and unfinished onboarding sessions still see the first-run flow. +- Fixed first-run onboarding so multiline absolute path seeds with `:line[:column]` plus notes are saved or handed off with the full original text instead of being mistaken for slash commands. +- Fixed successful `/import ` during first-run onboarding so the imported session exits onboarding UI/interception state instead of treating the next normal message as a fresh onboarding seed. +- Fixed first-run onboarding so a task saved before the session is ready resumes after successful `/model` selection, including the context-window follow-up step when required. +- Fixed `/new` during first-run onboarding so the replacement session remains in onboarding but drops any previously saved in-memory task seed instead of resuming stale work later. - Fixed `find` and `search` glob entries in `paths`, restored `search.skip` file-page pagination for filesystem and resource-backed matches (including SQLite text primary keys with spaces), surfaced skip pagination hints across multiple pages when filesystem, archive, SQLite, internal, explicit-path, and ranged-selector search pages are full, avoided false continuation hints when later explicit targets do not match, kept `search` line-selector context inside the requested ranges for native and non-native fallback search, searched ranged single-file selectors without dropping matches beyond the internal raw grep cap while preserving backend regex semantics such as inline `(?i)`/`(?m)`/`(?x)`, normalized copied quoted/empty path inputs including `paths: []`, split delimiter-joined glob search/find/resource paths only after preserving exact filesystem paths with spaces or delimiter characters, awaited async internal-resource find resolvers and continued to fallback resolvers when earlier async resolvers returned `undefined`, resolved `local://`/router-backed find paths before filesystem normalization, preserved trailing slashes for directory find matches, stopped exact-file and exact-limit glob find hits from reporting false limit truncation while reporting real truncation when exact files fill the page before later targets, enforced custom find backend result limits without fabricating empty directory matches, made `find.timeout` return a partial timed-out result instead of accepting an ignored option, ensured custom find backends receive/enforce `hidden:false`, and ensured `gitignore:false` plus explicit `node_modules` globs include `node_modules` in native find/search scans ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). - Fixed copied hashline output passed to `write` by stripping `[PATH#TAG]` headers, directory banners, continuation/truncation footers, and `LINE:` prefixes only for known current-session snapshots, including bounded/truncated read and search snippets, while preserving literal hashline-looking user content and rejecting unknown/stale tags from other snapshot stores; successful hashline edits now return compact refreshed-anchor metadata instead of the full post-edit file, `insert tail` now appends exactly once for trailing-newline, no-final-newline, and empty files, line-anchored edits on empty hashline snapshots no longer silently no-op, and multi-file hashline edits preflight all stale tags before writing any file ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). - Fixed additional builtin parity gaps by removing the archive selector dependency on host `python3`, bounding oversized archive/internal/URL/SQLite/local-document reads like file reads while preserving oversized-read details for collapsed renderers, truncating resource-backed and SQLite search lines, aligning archive/internal/SQLite resource search regex semantics with filesystem search, avoiding inflation of unrelated zip members for selected reads/searches/writes, routing internal-resource selectors through a session router when available, preserving custom read/find backends that do not map to local files, filtering direct MCP tool allowlists that collide with the new builtin `search`, and expanding supported internal URLs in bash command/cwd/env inputs before execution ([#1483](https://github.com/bastani-inc/atomic/issues/1483)). @@ -40,6 +52,7 @@ - Addressed the latest PR review hardening pass by counting multi-file search per-file caps by match lines instead of context lines, making native filesystem scan cache insertion generation-aware so in-flight scans cannot repopulate after invalidation, rejecting SQLite raw-query `pragma_*` table-valued functions and double-quoted internal-name splices, and bounds-checking zip central-directory offsets during selective archive writes ([#1490](https://github.com/bastani-inc/atomic/pull/1490)). - Addressed the final PR review hardening pass by restoring header-only copied-hashline writes to their snapshot content instead of emptying files, decoding and sanitizing async bash output with a streaming UTF-8 decoder, cleaning up async bash temp output files on eviction/TTL, invalidating native search caches after bash commands, keeping URL protocol validation outside the private-read escape hatch, documenting single-file search skip handling, preserving CR-only hashline edit line endings, rejecting selective zip writes that would drop data descriptors, and exposing `search` in extension `tool_call`/`tool_result` type guards like other builtins ([#1490](https://github.com/bastani-inc/atomic/pull/1490)). + ## [0.9.2] - 2026-06-23 ### Changed diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 8625d5961..6b5a2ca2a 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -80,6 +80,8 @@ See [Providers](/providers) for all supported providers, environment variables, ## First session +On a fresh install with no prior Atomic startup state, Atomic starts with a first-run workflow prompt. Returning users with prior startup state are marked onboarded automatically and continue directly into the normal chat UI; stored credentials by themselves do not skip onboarding. Paste a ticket description, GitHub issue, path to a spec, or task prompt and Atomic hands it to the normal coding-agent session. The handoff raises the selected model to high reasoning when supported and first asks the parent agent to estimate scope from the seed text alone: tickets, issues, and especially specs often list enough work items, files, tests, docs, migrations, or acceptance criteria to classify likely size without immediately inspecting the repo. That text-only pass is treated as a routing confidence signal, not final planning. If the task is clearly tiny/small and high-confidence, the parent can route directly; if the seed references context that must be read or the scope is medium, large, unclear, or risky, it inspects only the necessary issue/spec/path/repo area and can use targeted read-only subagents such as `codebase-locator`, `codebase-analyzer`, and `codebase-pattern-finder` at their normal defaults. It then chooses `goal` for focused work or `ralph` for broader/riskier work, starts the selected workflow, and continues normally. If you paste the task before logging in or selecting a usable model, Atomic keeps only an in-memory copy, asks you to run `/login`, and resumes with the latest saved task after login or `/model` selection makes the session ready; `/new` starts a fresh unresolved onboarding session and drops that saved in-memory task. If you want normal chat instead, type `/chat` or `/chat `; other slash commands such as `/login`, `/model`, and `/atomic` still work and do not dismiss onboarding. + Once Atomic starts, the fastest way to get value is to kick off a built-in workflow or invoke a skill. Atomic turns repeatable engineering loops into executable stages with inspectable evidence instead of relying on a markdown checklist the model may or may not follow. For an interactive tour any time, run `/atomic` inside the TUI; `/atomic overview`, `/atomic workflows`, and `/atomic example` walk through the same flow in more depth. @@ -92,7 +94,7 @@ Atomic ships with four workflows you can run immediately. Use `/workflow list` t |---|---|---| | `deep-research-codebase` | Broad, cross-cutting research before you decide what to change. Scout → research-history → parallel specialist waves → aggregator. | `/workflow deep-research-codebase prompt="How do payment retries work end to end?"` | | `goal` | Bounded one-off changes when you already know the work surface, exact outcome, and validation — for example tests, lint/typecheck, docs builds, or observable behavior. Keeps the run focused with a goal ledger, reviewer gates, final status `complete`, `blocked`, or `needs_human`, and optional final-stage PR creation with `create_pr=true` after approval. | `/workflow goal objective="Update the CLI docs for --json, include one example, run the docs build, and finish when the build passes"` | -| `ralph` | Planned or broad implementation work from a spec file, GitHub issue, or crisp ticket description. Ralph refines the prompt, researches as needed, delegates implementation through sub-agents, reviews, records a QA proof video for UI/full-stack changes when practical, iterates, and optionally lets only the final stage attempt PR creation with `create_pr=true`. | `/workflow ralph prompt="Implement specs/2026-03-rate-limit.md and validate burst traffic returns 429"` | +| `ralph` | Planned or broad implementation work from a spec file, GitHub issue, or crisp ticket description. Ralph researches as needed, delegates implementation through sub-agents, reviews, records a QA proof video for UI/full-stack changes when practical, iterates, and optionally lets only the final stage attempt PR creation with `create_pr=true`. | `/workflow ralph prompt="Implement specs/2026-03-rate-limit.md and validate burst traffic returns 429"` | | `open-claude-design` | UI and design-system work with separate forked generate and feedback chains; renders a live `preview.html` you can iterate against. | `/workflow open-claude-design prompt="Refresh the settings page hierarchy as a page"` |

Workflow List

@@ -117,11 +119,12 @@ For smaller one-off tasks, use `goal` with a concrete task description that name ### Monitor and steer a run -Named workflow runs execute in the background. After launch you get a run id; use it to inspect, attach, pause, or resume: +Named workflow runs execute in the background. After launch you get a run id; use it to inspect, attach, pause, or resume. First-run `goal`/`ralph` handoffs show the exact `/workflow status ` and `/workflow connect ` commands in the dispatched card, and you can also ask in the current chat for status or to steer the run at any point. ```text +/workflow status # inspect one run's progress /workflow status # list this session's active and terminal runs -/workflow connect # open the graph viewer (F2 also opens the latest) +/workflow connect # watch, attach to stages, or steer (F2 also opens latest) /workflow attach # chat with one stage /workflow interrupt # pause resumably /workflow resume "go" # send a steer message and resume @@ -213,7 +216,7 @@ Restart Atomic, or run `/reload`, after changing context files. ### Reference files -Type `@` in the editor to fuzzy-search files, or pass files on the command line: +Type `@` in any interactive editor, including first-run onboarding, to fuzzy-search files; or pass files on the command line: ```bash atomic @README.md "Summarize this" diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 7325b815c..405664b3a 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -73,6 +73,8 @@ Use `/fast` in interactive mode to edit these settings. Atomic applies fast mode | `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | | `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | +| `firstRunOnboardingStartedVersion` | string | - | Internal first-run onboarding start marker used when no prior Atomic startup state identifies the user as returning | +| `onboardedVersion` | string | - | Internal one-time first-run onboarding completion marker. Returning-user detection from prior startup state, `/chat`, or a successful first-run seed handoff into the normal agent session sets it | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 4bc3a6ddf..8c86430b2 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -19,7 +19,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by | Feature | How | |---------|-----| -| File reference | Type `@` to fuzzy-search project files | +| File reference | Type `@` to fuzzy-search project files, including during first-run onboarding | | Path completion | Press Tab to complete paths | | Multi-line input | SHIFT+Enter, or CTRL+Enter on Windows Terminal | | Images | Paste with CTRL+V, ALT+V on Windows, or drag into the terminal | diff --git a/packages/coding-agent/docs/workflows.md b/packages/coding-agent/docs/workflows.md index bb528bd44..74f9f1be3 100644 --- a/packages/coding-agent/docs/workflows.md +++ b/packages/coding-agent/docs/workflows.md @@ -55,6 +55,8 @@ Use a workflow when a task should be repeatable, inspectable, resumable, or spli ## Quick Start +On a fresh first run with no prior Atomic startup state, Atomic can route your first pasted ticket, issue, spec path, or task prompt into normal workflow-driven agent work. Returning users with prior startup state are marked onboarded automatically and continue directly into the normal chat UI; stored credentials by themselves do not skip onboarding. If you are not logged in or configured yet, Atomic keeps the latest seed in memory only, asks you to run `/login`, and resumes after login succeeds. Once ready, onboarding raises the selected model to high reasoning when supported and hands the seed to the normal coding-agent session with instructions to first estimate likely scope from the seed text alone. Tickets, GitHub issues, and especially specs often list enough work items, files, tests, docs, migrations, or acceptance criteria to make an initial tiny/small/medium/large/unknown call without immediately inspecting the repo. That text-only estimate is only a routing confidence signal, not final implementation planning. If the seed makes the task clearly tiny or small and high-confidence, the parent can route directly; if referenced context must be read or the scope is medium, large, unclear, risky, or not obviously tiny, the parent inspects only the necessary issue/spec/path/repo area and can use targeted read-only subagents such as `codebase-locator`, `codebase-analyzer`, and `codebase-pattern-finder` at their normal defaults before making the high-reasoning routing decision: `goal` for small fixes/quick fixes, `ralph` for non-trivial work around 2k+ changed lines or many files/areas. It then starts the selected workflow and continues normally. Type `/chat` before handoff to skip this one-time routing and use Atomic as a normal coding-agent chat. + The fastest way to get a workflow running is to **describe it in natural language** and let Atomic write it for you. If you'd rather write the TypeScript yourself, jump to [Or hand-write the TypeScript](#or-hand-write-the-typescript) below. ### Just describe it @@ -105,7 +107,7 @@ Then list and run it like any other workflow: /workflow key=value ... ``` -Named workflow runs are background-oriented. After launch, expect a run id and monitor it with `/workflow status`, F2, or `/workflow connect `. +Named workflow runs are background-oriented. After launch, expect a run id and monitor it with `/workflow status `, F2, or `/workflow connect `. First-run `goal`/`ralph` handoffs print those exact commands in the dispatched card; use connect to watch, attach, and steer, or ask in the current chat for status or steering at any point. ### Or hand-write the TypeScript diff --git a/packages/coding-agent/src/core/settings-manager-basic-accessors.ts b/packages/coding-agent/src/core/settings-manager-basic-accessors.ts index ea88c7d18..d8b4a8e4d 100644 --- a/packages/coding-agent/src/core/settings-manager-basic-accessors.ts +++ b/packages/coding-agent/src/core/settings-manager-basic-accessors.ts @@ -8,6 +8,10 @@ import type { ContextWindowSetting, TransportSetting } from "./settings-types.ts interface SettingsManagerBasicAccessors { getLastChangelogVersion(): string | undefined; setLastChangelogVersion(version: string): void; + getFirstRunOnboardingStartedVersion(): string | undefined; + setFirstRunOnboardingStartedVersion(version: string): void; + getOnboardedVersion(): string | undefined; + setOnboardedVersion(version: string): void; getSessionDir(): string | undefined; getDefaultProvider(): string | undefined; getDefaultModel(): string | undefined; @@ -83,6 +87,28 @@ const basicAccessors: SettingsManagerBasicAccessors = { state.save(); }, + getFirstRunOnboardingStartedVersion() { + return settingsInternals(this).settings.firstRunOnboardingStartedVersion; + }, + + setFirstRunOnboardingStartedVersion(version) { + const state = settingsInternals(this); + state.globalSettings.firstRunOnboardingStartedVersion = version; + state.markModified("firstRunOnboardingStartedVersion"); + state.save(); + }, + + getOnboardedVersion() { + return settingsInternals(this).settings.onboardedVersion; + }, + + setOnboardedVersion(version) { + const state = settingsInternals(this); + state.globalSettings.onboardedVersion = version; + state.markModified("onboardedVersion"); + state.save(); + }, + getSessionDir() { const sessionDir = settingsInternals(this).settings.sessionDir; return sessionDir ? normalizePath(sessionDir) : sessionDir; diff --git a/packages/coding-agent/src/core/settings-types.ts b/packages/coding-agent/src/core/settings-types.ts index 32b4fc4c7..aa4497cf4 100644 --- a/packages/coding-agent/src/core/settings-types.ts +++ b/packages/coding-agent/src/core/settings-types.ts @@ -88,6 +88,8 @@ export interface BashInterceptorSettings { export interface Settings { lastChangelogVersion?: string; + firstRunOnboardingStartedVersion?: string; + onboardedVersion?: string; defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 970985a7e..410ca14e6 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,5 +1,6 @@ -import { Editor, type EditorOptions, type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, Editor, type EditorOptions, type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts"; +import { theme } from "../theme/theme.ts"; export interface CustomEditorOptions extends EditorOptions { promptPrefix?: string; @@ -66,7 +67,7 @@ export class CustomEditor extends Editor { const prefix = showPrompt ? this.promptPrefix : " ".repeat(promptWidth); let content = line; if (showPrompt && placeholder && this.getText() === "") { - content = truncateToWidth(placeholder, editorWidth, "..."); + content = this.renderPlaceholder(placeholder, editorWidth); } if (inPromptBox) { promptShown = true; @@ -75,6 +76,13 @@ export class CustomEditor extends Editor { }); } + private renderPlaceholder(placeholder: string, editorWidth: number): string { + const cursor = `${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[0m`; + const placeholderWidth = Math.max(0, editorWidth - 1); + const text = truncateToWidth(placeholder, placeholderWidth, "..."); + return `${cursor}${theme.fg("muted", text)}`; + } + private isEditorBorderLine(line: string): boolean { const plain = line.replace(ANSI_ESCAPE_PATTERN, "").trim(); return plain.includes("─") && BORDER_LINE_PATTERN.test(plain); diff --git a/packages/coding-agent/src/modes/interactive/interactive-auth-login.ts b/packages/coding-agent/src/modes/interactive/interactive-auth-login.ts index f3dbc02fb..b2d96a6ef 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-auth-login.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-auth-login.ts @@ -59,6 +59,8 @@ InteractiveModeBase.prototype.completeProviderAuthentication = async function(th void this.maybeWarnAboutAnthropicSubscriptionAuth(); } } + + await this.resumePendingFirstRunOnboardingSeed(); }; InteractiveModeBase.prototype.showBedrockSetupDialog = function(this: InteractiveModeBase, providerId: string, providerName: string): void { diff --git a/packages/coding-agent/src/modes/interactive/interactive-autocomplete.ts b/packages/coding-agent/src/modes/interactive/interactive-autocomplete.ts index af46ba0d7..4efa64cb1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-autocomplete.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-autocomplete.ts @@ -1,7 +1,111 @@ import { InteractiveModeBase } from "./interactive-mode-base.ts"; -import { type Api, type Model, type AutocompleteItem, type AutocompleteProvider, type SlashCommand, type ExtensionRunner, type ResourceDiagnostic, type SourceInfo, CombinedAutocompleteProvider, fuzzyFilter, hasSupportedCodexFastModeModel, BUILTIN_SLASH_COMMANDS, parseGitUrl, getModelSearchText } from "./interactive-mode-deps.ts"; +import { type Api, type Model, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, type ExtensionRunner, type ResourceDiagnostic, type SourceInfo, CombinedAutocompleteProvider, fuzzyFilter, hasSupportedCodexFastModeModel, BUILTIN_SLASH_COMMANDS, parseGitUrl, getModelSearchText } from "./interactive-mode-deps.ts"; import { BUILTIN_SLASH_COMMAND_NAMES } from "./interactive-mode-helpers.ts"; +const AT_MENTION_PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); + +function findLastAtMentionDelimiter(text: string): number { + for (let index = text.length - 1; index >= 0; index -= 1) { + if (AT_MENTION_PATH_DELIMITERS.has(text[index] ?? "")) return index; + } + return -1; +} + +function isAtMentionTokenStart(text: string, index: number): boolean { + return index === 0 || AT_MENTION_PATH_DELIMITERS.has(text[index - 1] ?? ""); +} + +function findUnclosedAtMentionQuoteStart(text: string): number | null { + let quoteStart = -1; + let inQuotes = false; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === '"') { + inQuotes = !inQuotes; + if (inQuotes) quoteStart = index; + } + } + return inQuotes ? quoteStart : null; +} + +function extractAtMentionPrefix(textBeforeCursor: string): string | null { + const quoteStart = findUnclosedAtMentionQuoteStart(textBeforeCursor); + if (quoteStart !== null && quoteStart > 0 && textBeforeCursor[quoteStart - 1] === "@") { + return isAtMentionTokenStart(textBeforeCursor, quoteStart - 1) + ? textBeforeCursor.slice(quoteStart - 1) + : null; + } + const lastDelimiterIndex = findLastAtMentionDelimiter(textBeforeCursor); + const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + return textBeforeCursor[tokenStart] === "@" ? textBeforeCursor.slice(tokenStart) : null; +} + +function atMentionPrefixToPathPrefix(atPrefix: string): string { + return atPrefix.startsWith('@"') ? `"${atPrefix.slice(2)}` : atPrefix.slice(1); +} + +function toAtMentionCompletion(item: AutocompleteItem): AutocompleteItem { + return { + ...item, + value: item.value.startsWith("@") ? item.value : `@${item.value}`, + }; +} + +class AtMentionFallbackAutocompleteProvider implements AutocompleteProvider { + private readonly primary: AutocompleteProvider; + private readonly pathFallback: AutocompleteProvider; + + constructor(primary: AutocompleteProvider, pathFallback: AutocompleteProvider) { + this.primary = primary; + this.pathFallback = pathFallback; + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const primarySuggestions = await this.primary.getSuggestions(lines, cursorLine, cursorCol, options); + if (primarySuggestions || options.signal.aborted) return primarySuggestions; + + const currentLine = lines[cursorLine] ?? ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + const atPrefix = extractAtMentionPrefix(textBeforeCursor); + if (!atPrefix) return null; + + const pathPrefix = atMentionPrefixToPathPrefix(atPrefix); + const prefixStart = cursorCol - atPrefix.length; + const fallbackLines = [...lines]; + fallbackLines[cursorLine] = `${currentLine.slice(0, prefixStart)}${pathPrefix}${currentLine.slice(cursorCol)}`; + const fallbackSuggestions = await this.pathFallback.getSuggestions( + fallbackLines, + cursorLine, + prefixStart + pathPrefix.length, + { ...options, force: true }, + ); + if (!fallbackSuggestions) return null; + + return { + prefix: atPrefix, + items: fallbackSuggestions.items.map(toAtMentionCompletion), + }; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + return this.primary.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + return this.primary.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + } +} + InteractiveModeBase.prototype.getAutocompleteSourceTag = function(this: InteractiveModeBase, sourceInfo?: SourceInfo): string | undefined { if (!sourceInfo) { return undefined; @@ -161,15 +265,16 @@ InteractiveModeBase.prototype.createBaseAutocompleteProvider = function(this: In } } - return new CombinedAutocompleteProvider( - [ - ...slashCommands, - ...templateCommands, - ...extensionCommands, - ...skillCommandList, - ], - this.sessionManager.getCwd(), - this.fdPath, + const commands = [ + ...slashCommands, + ...templateCommands, + ...extensionCommands, + ...skillCommandList, + ]; + const cwd = this.sessionManager.getCwd(); + return new AtMentionFallbackAutocompleteProvider( + new CombinedAutocompleteProvider(commands, cwd, this.fdPath), + new CombinedAutocompleteProvider(commands, cwd, null), ); }; diff --git a/packages/coding-agent/src/modes/interactive/interactive-hotkeys-debug.ts b/packages/coding-agent/src/modes/interactive/interactive-hotkeys-debug.ts index cef3db5f1..914183406 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-hotkeys-debug.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-hotkeys-debug.ts @@ -155,6 +155,9 @@ InteractiveModeBase.prototype.handleClearCommand = async function(this: Interact if (result.cancelled) { return; } + if (this.firstRunOnboardingActive) { + this.clearPendingFirstRunOnboardingSeed(); + } this.renderCurrentSessionState(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( diff --git a/packages/coding-agent/src/modes/interactive/interactive-input-handling.ts b/packages/coding-agent/src/modes/interactive/interactive-input-handling.ts index 8df228fa8..3e9a45a37 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-input-handling.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-input-handling.ts @@ -1,5 +1,6 @@ import { InteractiveModeBase } from "./interactive-mode-base.ts"; import { pasteClipboardImageToEditor } from "./interactive-mode-deps.ts"; +import { NORMAL_CHAT_TRANSITION_COPY, isCwdLocalExistingPathSeed, isExistingAbsolutePathSeed } from "./interactive-onboarding.ts"; InteractiveModeBase.prototype.setupKeyHandlers = function(this: InteractiveModeBase): void { // Set up handlers on defaultEditor - they use this.editor for text access @@ -100,10 +101,54 @@ InteractiveModeBase.prototype.handleClipboardImagePaste = async function(this: I }; InteractiveModeBase.prototype.setupEditorSubmitHandler = function(this: InteractiveModeBase): void { + const submitFirstRunOnboardingSeed = async (text: string): Promise => { + if (this.firstRunOnboardingSeedInFlight) return; + this.editor.addToHistory?.(text); + this.editor.setText(""); + const readyForHandoff = typeof this.isFirstRunOnboardingReadyForHandoff === "function" + ? this.isFirstRunOnboardingReadyForHandoff() + : true; + if (!readyForHandoff) { + this.stashFirstRunOnboardingSeed(text); + return; + } + this.firstRunOnboardingSeedInFlight = true; + try { + await this.handleOnboardingWorkflowSeed(text); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.showError(errorMessage); + this.editor.setText(text); + } finally { + this.firstRunOnboardingSeedInFlight = false; + } + }; + this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); if (!text) return; + if (this.firstRunOnboardingActive && (text === "/chat" || text.startsWith("/chat "))) { + const chatMessage = text.slice(5).trim(); + this.completeFirstRunOnboarding(); + this.showStatus(NORMAL_CHAT_TRANSITION_COPY); + this.editor.setText(""); + if (!chatMessage) return; + this.flushPendingBashComponents(); + if (this.onInputCallback) { + this.onInputCallback(chatMessage); + } else { + this.pendingUserInputs.push(chatMessage); + } + this.editor.addToHistory?.(text); + return; + } + + if (this.firstRunOnboardingActive && text.startsWith("/") && (isCwdLocalExistingPathSeed(text, this.sessionManager.getCwd()) || isExistingAbsolutePathSeed(text))) { + await submitFirstRunOnboardingSeed(text); + return; + } + // Handle commands if (text === "/settings") { this.showSettingsSelector(); @@ -270,6 +315,11 @@ InteractiveModeBase.prototype.setupEditorSubmitHandler = function(this: Interact } } + if (this.firstRunOnboardingActive && !text.startsWith("/")) { + await submitFirstRunOnboardingSeed(text); + return; + } + // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode-base.ts b/packages/coding-agent/src/modes/interactive/interactive-mode-base.ts index aaea046b0..8c75fce42 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode-base.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode-base.ts @@ -114,6 +114,21 @@ export class InteractiveModeBase { anthropicSubscriptionWarningShown = false; + firstRunOnboardingActive = false; + + + firstRunOnboardingSeedInFlight = false; + + + pendingFirstRunOnboardingSeed: string | undefined = undefined; + + + hadLastChangelogVersionAtStartup = false; + + + firstRunOnboardingHeaderComponents: Component[] = []; + + autoTrustOnReloadCwd: string | undefined; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode-deps.ts b/packages/coding-agent/src/modes/interactive/interactive-mode-deps.ts index 53ee5e96a..712561456 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode-deps.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode-deps.ts @@ -6,7 +6,7 @@ export { fs, os, path }; export { default as chalk } from "chalk"; export type { AgentMessage } from "@earendil-works/pi-agent-core"; export { getProviders, type Api, type AssistantMessage, type ImageContent, type Message, type Model, type OAuthProviderId, type OAuthSelectPrompt } from "@earendil-works/pi-ai"; -export type { AutocompleteItem, AutocompleteProvider, EditorComponent, Keybinding, KeyId, MarkdownTheme, OverlayHandle, OverlayOptions, SlashCommand } from "@earendil-works/pi-tui"; +export type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, EditorComponent, Keybinding, KeyId, MarkdownTheme, OverlayHandle, OverlayOptions, SlashCommand } from "@earendil-works/pi-tui"; export { CombinedAutocompleteProvider, type Component, Container, fuzzyFilter, getCapabilities, hyperlink, Loader, type LoaderIndicatorOptions, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth } from "@earendil-works/pi-tui"; export { spawn, spawnSync } from "child_process"; export { APP_NAME, APP_TITLE, CHANGELOG_URL, ENV_OFFLINE, getEnvValue, setCodexFastModeEnvironmentSettings, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, getShareViewerUrl, VERSION } from "../../config.ts"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode-surface.ts b/packages/coding-agent/src/modes/interactive/interactive-mode-surface.ts index e7f4b21d8..c89c9d0a6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode-surface.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode-surface.ts @@ -11,6 +11,18 @@ declare module "./interactive-mode-base.ts" { createBaseAutocompleteProvider(): AutocompleteProvider; setupAutocompleteProvider(): void; showStartupNoticesIfNeeded(): void; + hadLastChangelogVersionAtStartup: boolean; + firstRunOnboardingHeaderComponents: Component[]; + firstRunOnboardingSeedInFlight: boolean; + pendingFirstRunOnboardingSeed: string | undefined; + isFirstRunOnboardingEligible(): boolean; + isFirstRunOnboardingReadyForHandoff(): boolean; + stashFirstRunOnboardingSeed(seed: string): void; + resumePendingFirstRunOnboardingSeed(): Promise; + clearPendingFirstRunOnboardingSeed(): void; + clearFirstRunOnboardingUi(): void; + completeFirstRunOnboarding(): void; + handleOnboardingWorkflowSeed(seed: string): Promise; init(): Promise; updateTerminalTitle(): void; run(): Promise; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 69a6314a1..06cb99fc6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4,6 +4,7 @@ */ import "./interactive-autocomplete.ts"; +import "./interactive-onboarding.ts"; import "./interactive-startup.ts"; import "./interactive-resource-paths.ts"; import "./interactive-resource-disclosure.ts"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-model-routing.ts b/packages/coding-agent/src/modes/interactive/interactive-model-routing.ts index edaa62ce8..2b55eaf1f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-model-routing.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-model-routing.ts @@ -17,6 +17,7 @@ InteractiveModeBase.prototype.handleModelCommand = async function(this: Interact this.showStatus(`Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); + await this.resumePendingFirstRunOnboardingSeed(); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } @@ -139,6 +140,7 @@ InteractiveModeBase.prototype.showModelSelector = function(this: InteractiveMode this.showContextWindowSelector(model); } else { this.showStatus(`Model: ${model.id}`); + await this.resumePendingFirstRunOnboardingSeed(); } } catch (error) { done(); @@ -166,7 +168,7 @@ InteractiveModeBase.prototype.showContextWindowSelector = function(this: Interac model.name ?? model.id, availableContextWindows, currentContextWindow, - (contextWindow) => { + async (contextWindow) => { try { this.session.setContextWindow(contextWindow, { persistDefault: true, @@ -178,6 +180,7 @@ InteractiveModeBase.prototype.showContextWindowSelector = function(this: Interac this.showStatus( `Model: ${model.id} \u00b7 ${formatContextWindow(contextWindow)} context`, ); + await this.resumePendingFirstRunOnboardingSeed(); } catch (error) { done(); this.showError( diff --git a/packages/coding-agent/src/modes/interactive/interactive-onboarding.ts b/packages/coding-agent/src/modes/interactive/interactive-onboarding.ts new file mode 100644 index 000000000..017eb1a04 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/interactive-onboarding.ts @@ -0,0 +1,225 @@ +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, resolve, relative } from "node:path"; +import { InteractiveModeBase } from "./interactive-mode-base.ts"; +import type { Api, Model } from "./interactive-mode-deps.ts"; +import { isUnknownModel } from "./interactive-mode-helpers.ts"; + +export const ONBOARDING_PLACEHOLDER = "Paste a ticket, issue, path to a spec, or task prompt…"; + +export const ONBOARDING_COPY = [ + "Atomic runs agent loops as workflows you can watch and trust:", + "implement a ticket, research a codebase, design a UI, or build", + "your own loop.", + "", + "Paste a ticket description, GitHub issue, path to a spec, or task prompt to start.", + "/chat to chat normally · /atomic for guides", + "If you have not logged in yet, first run /login.", +].join("\n"); + +export const NORMAL_CHAT_TRANSITION_COPY = [ + "You're in a normal coding-agent session now. Atomic can chat and edit like other", + "coding agents, but it also runs loops and workflows. Ask Atomic to build any loop,", + "or run a built-in workflow like `goal` for small focused changes or `ralph` for", + "larger, cross-cutting work. Run `/workflow list` to see built-ins, and use `/atomic`", + "for help running or building your own loops and workflows.", +].join("\n"); + +export const ONBOARDING_SEED_STASHED_COPY = [ + "Task saved for after login. Run /login to connect a provider; once login finishes,", + "Atomic will continue with your saved task.", +].join("\n"); + +export const ONBOARDING_SEED_REPLACED_COPY = [ + "Latest task saved for after login, replacing the previous saved task. Run /login to", + "connect a provider; once login finishes, Atomic will continue with the latest task.", +].join("\n"); + +export const ONBOARDING_HANDOFF_NOTICE = "Handing your task to the normal coding-agent session."; +export const ONBOARDING_ROUTING_THINKING_LEVEL = "high"; + +function isInside(baseDir: string, candidate: string): boolean { + const rel = relative(baseDir, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function pathCandidatesWithOptionalLocationSuffix(pathText: string): string[] { + const candidates = [pathText, pathText.replace(/:\d+:\d+$/, ""), pathText.replace(/:\d+$/, "")]; + return candidates.filter((candidate, index) => candidate && candidates.indexOf(candidate) === index); +} + +function firstSeedLine(seed: string): string { return seed.trim().split(/\r?\n/, 1)[0]?.trim() ?? ""; } + +function getContainedExistingPath(pathText: string, cwd: string): string | undefined { + if (!pathText || /\r|\n/.test(pathText) || /^[a-z]+:\/\//i.test(pathText)) return undefined; + const root = resolve(cwd); + let rootReal: string; + try { rootReal = realpathSync(root); } catch { return undefined; } + for (const candidate of pathCandidatesWithOptionalLocationSuffix(pathText)) { + const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(cwd, candidate); + if (!isInside(root, absolute)) continue; + try { + lstatSync(absolute); + const real = realpathSync(absolute); + if (isInside(rootReal, real)) return real; + } catch {} + } + return undefined; +} + +export function isCwdLocalExistingPathSeed(seed: string, cwd: string): boolean { + const trimmed = firstSeedLine(seed); + if (!trimmed || !isAbsolute(trimmed)) return false; + const real = getContainedExistingPath(trimmed, cwd); + if (!real) return false; + try { + const stat = statSync(real); + return stat.isFile() || stat.isDirectory(); + } catch { + return false; + } +} + +export function isExistingAbsolutePathSeed(seed: string): boolean { + const trimmed = firstSeedLine(seed); + if (!trimmed || /\r|\n/.test(trimmed) || /^[a-z]+:\/\//i.test(trimmed) || !isAbsolute(trimmed)) return false; + for (const candidate of pathCandidatesWithOptionalLocationSuffix(trimmed)) { + if (!isAbsolute(candidate)) continue; + try { + const stat = statSync(candidate); + if (stat.isFile() || stat.isDirectory()) return true; + } catch {} + } + return false; +} + +function sameModel(a: Model, b: Model): boolean { + return a.provider === b.provider && a.id === b.id; +} + +function backtickFenceFor(seed: string): string { + let longest = 0; + for (const match of seed.matchAll(/`+/g)) longest = Math.max(longest, match[0].length); + return "`".repeat(Math.max(3, longest + 1)); +} + +export function buildOnboardingHandoffPrompt(seed: string): string { + const fence = backtickFenceFor(seed); + return [ + "First-run onboarding handoff: continue as a normal Atomic coding-agent session.", + "", + "Original task seed:", + `${fence}text`, + seed, + fence, + "", + "Perform a quick scope-routing pass before acting. Atomic has switched the selected model", + "to high reasoning for this routing decision when the model supports it. First estimate the", + "likely scope from the seed text alone: tickets, GitHub issues, and especially specs often", + "name enough work items, files, systems, tests, docs, migrations, or acceptance criteria to", + "classify the task without immediately inspecting the repo. Treat that text-only estimate as", + "an initial confidence signal for routing, not as final implementation planning.", + "", + "If the seed makes the task clearly tiny or small and the routing choice is high-confidence,", + "you may route directly without codebase probing. If the seed references a local path, issue,", + "spec, or repo area that must be read to understand the task, inspect only that targeted", + "context. When the scope is medium, large, unclear, risky, or otherwise not obviously tiny,", + "gather quick read-only codebase context with targeted subagents such as `codebase-locator`,", + "`codebase-analyzer`, and `codebase-pattern-finder`. Use those subagents with their normal", + "default model/thinking settings; do not override their models just for routing. Do only the", + "probing needed to route scope; do not turn this into an open-ended research project.", + "", + "Then make the final choice in this high-reasoning parent session using the existing Atomic", + "workflow guidance: choose `goal` for clearly small, focused fixes or quick fixes; choose `ralph`", + "for non-trivial, broad, cross-cutting, risky, unclear, or around-2K+-changed-line work.", + "Start the selected workflow with the original seed. After the `goal` or `ralph` run is", + "dispatched, show the new developer the workflow id and succinct next steps:", + "- `/workflow status ` checks progress.", + "- `/workflow connect ` opens the graph viewer to watch, attach, and steer.", + "- They can ask in this chat for status or to steer the run at any point.", + "Then continue normally in this session. Slash commands should behave like normal", + "coding-agent slash commands from here on.", + ].join("\n"); +} + +InteractiveModeBase.prototype.isFirstRunOnboardingEligible = function(this: InteractiveModeBase): boolean { + const hasInitialInput = Boolean(this.options.initialMessage) || Boolean(this.options.initialMessages?.length); + return this.session.state.messages.length === 0 + && !hasInitialInput + && !this.settingsManager.getOnboardedVersion() + && Boolean(this.settingsManager.getFirstRunOnboardingStartedVersion()); +}; + +InteractiveModeBase.prototype.isFirstRunOnboardingReadyForHandoff = function(this: InteractiveModeBase): boolean { + const registry = this.session.modelRegistry; + if (!registry) return true; + const model = this.session.model; + if (!model || isUnknownModel(model)) return false; + if (typeof registry.hasConfiguredAuth === "function") { + return registry.hasConfiguredAuth(model); + } + if (typeof registry.getAvailable === "function") { + return registry.getAvailable().some((availableModel) => sameModel(availableModel, model)); + } + return true; +}; + +InteractiveModeBase.prototype.stashFirstRunOnboardingSeed = function(this: InteractiveModeBase, seed: string): void { + const replaced = Boolean(this.pendingFirstRunOnboardingSeed); + this.pendingFirstRunOnboardingSeed = seed; + this.showStatus(replaced ? ONBOARDING_SEED_REPLACED_COPY : ONBOARDING_SEED_STASHED_COPY); +}; + +InteractiveModeBase.prototype.resumePendingFirstRunOnboardingSeed = async function(this: InteractiveModeBase): Promise { + if (!this.firstRunOnboardingActive || !this.pendingFirstRunOnboardingSeed) return; + if (!this.isFirstRunOnboardingReadyForHandoff()) return; + const seed = this.pendingFirstRunOnboardingSeed; + this.pendingFirstRunOnboardingSeed = undefined; + try { + await this.handleOnboardingWorkflowSeed(seed); + } catch (error: unknown) { + this.pendingFirstRunOnboardingSeed = seed; + const errorMessage = error instanceof Error ? error.message : String(error); + this.showError(errorMessage); + } +}; + +InteractiveModeBase.prototype.clearPendingFirstRunOnboardingSeed = function(this: InteractiveModeBase): void { + this.firstRunOnboardingSeedInFlight = false; + this.pendingFirstRunOnboardingSeed = undefined; +}; + +InteractiveModeBase.prototype.clearFirstRunOnboardingUi = function(this: InteractiveModeBase): void { + this.firstRunOnboardingActive = false; + InteractiveModeBase.prototype.clearPendingFirstRunOnboardingSeed.call(this); + this.defaultEditor.setPlaceholder(undefined); + if (this.firstRunOnboardingHeaderComponents.length > 0) { + this.headerContainer.children = this.headerContainer.children.filter( + (child) => !this.firstRunOnboardingHeaderComponents.includes(child), + ); + this.firstRunOnboardingHeaderComponents = []; + } + this.ui.requestRender(); +}; + +InteractiveModeBase.prototype.completeFirstRunOnboarding = function(this: InteractiveModeBase): void { + this.settingsManager.setOnboardedVersion(this.version); + InteractiveModeBase.prototype.clearFirstRunOnboardingUi.call(this); +}; + +InteractiveModeBase.prototype.handleOnboardingWorkflowSeed = async function( + this: InteractiveModeBase, + seed: string, +): Promise { + const handoffPrompt = buildOnboardingHandoffPrompt(seed); + this.session.setThinkingLevel(ONBOARDING_ROUTING_THINKING_LEVEL); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.flushPendingBashComponents(); + if (this.onInputCallback) { + this.onInputCallback(handoffPrompt); + } else { + this.pendingUserInputs.push(handoffPrompt); + } + this.showStatus(ONBOARDING_HANDOFF_NOTICE); + this.completeFirstRunOnboarding(); +}; diff --git a/packages/coding-agent/src/modes/interactive/interactive-session-routing.ts b/packages/coding-agent/src/modes/interactive/interactive-session-routing.ts index 85540e428..32bc265d2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-session-routing.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-session-routing.ts @@ -269,6 +269,9 @@ InteractiveModeBase.prototype.handleResumeSession = async function(this: Interac return result; } this.renderCurrentSessionState(); + if (this.firstRunOnboardingActive) { + this.clearFirstRunOnboardingUi(); + } this.showStatus("Resumed session"); return result; } catch (error: unknown) { @@ -287,6 +290,9 @@ InteractiveModeBase.prototype.handleResumeSession = async function(this: Interac return result; } this.renderCurrentSessionState(); + if (this.firstRunOnboardingActive) { + this.clearFirstRunOnboardingUi(); + } this.showStatus("Resumed session in current cwd"); return result; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-slash-commands.ts b/packages/coding-agent/src/modes/interactive/interactive-slash-commands.ts index 080766c77..26b8e29df 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-slash-commands.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-slash-commands.ts @@ -161,6 +161,14 @@ InteractiveModeBase.prototype.handleImportCommand = async function(this: Interac return; } + const finishSuccessfulImport = () => { + this.renderCurrentSessionState(); + if (this.firstRunOnboardingActive) { + this.clearFirstRunOnboardingUi(); + } + this.showStatus(`Session imported from: ${inputPath}`); + }; + try { if (this.loadingAnimation) { this.loadingAnimation.stop(); @@ -172,8 +180,7 @@ InteractiveModeBase.prototype.handleImportCommand = async function(this: Interac this.showStatus("Import cancelled"); return; } - this.renderCurrentSessionState(); - this.showStatus(`Session imported from: ${inputPath}`); + finishSuccessfulImport(); } catch (error: unknown) { if (error instanceof MissingSessionCwdError) { const selectedCwd = await this.promptForMissingSessionCwd(error); @@ -189,8 +196,7 @@ InteractiveModeBase.prototype.handleImportCommand = async function(this: Interac this.showStatus("Import cancelled"); return; } - this.renderCurrentSessionState(); - this.showStatus(`Session imported from: ${inputPath}`); + finishSuccessfulImport(); return; } if (error instanceof SessionImportFileNotFoundError) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-startup.ts b/packages/coding-agent/src/modes/interactive/interactive-startup.ts index b978f3cd2..154fe7b63 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-startup.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-startup.ts @@ -1,6 +1,7 @@ import { InteractiveModeBase } from "./interactive-mode-base.ts"; import { type MarkdownTheme, os, path, Markdown, Spacer, Text, spawn, APP_NAME, APP_TITLE, ENV_OFFLINE, getEnvValue, getAgentDir, VERSION, formatCodexFastModeModelLabel, shouldApplyCodexFastMode, DefaultPackageManager, isInstallTelemetryEnabled, getChangelogPath, getEntriesForVersion, getNewEntries, normalizeChangelogLinks, parseChangelog, getCwdRelativePath, getPiUserAgent, recordTimeSinceReset, ensureTool, checkForNewPiVersion, renderAtomicAnsiBanner, DynamicBorder, getMarkdownTheme, onThemeChange, theme } from "./interactive-mode-deps.ts"; import { ExpandableText } from "./interactive-mode-helpers.ts"; +import { ONBOARDING_COPY, ONBOARDING_PLACEHOLDER } from "./interactive-onboarding.ts"; InteractiveModeBase.prototype.showStartupNoticesIfNeeded = function(this: InteractiveModeBase): void { if (this.startupNoticesShown) { @@ -47,7 +48,20 @@ InteractiveModeBase.prototype.init = async function(this: InteractiveModeBase): this.registerSignalHandlers(); // Load changelog (only show new entries, skip for resumed sessions) + this.hadLastChangelogVersionAtStartup = Boolean(this.settingsManager.getLastChangelogVersion()); + const hadFirstRunOnboardingStarted = Boolean(this.settingsManager.getFirstRunOnboardingStartedVersion()); this.changelogMarkdown = this.getChangelogForDisplay(); + if ( + this.session.state.messages.length === 0 + && !hadFirstRunOnboardingStarted + && !this.settingsManager.getOnboardedVersion() + ) { + if (this.hadLastChangelogVersionAtStartup) { + this.settingsManager.setOnboardedVersion(this.version); + } else { + this.settingsManager.setFirstRunOnboardingStartedVersion(this.version); + } + } // Add header container as first child. Populate it after theme initialization. this.ui.addChild(this.headerContainer); @@ -74,6 +88,11 @@ InteractiveModeBase.prototype.init = async function(this: InteractiveModeBase): this.setupKeyHandlers(); this.setupEditorSubmitHandler(); + this.firstRunOnboardingActive = this.isFirstRunOnboardingEligible(); + if (this.firstRunOnboardingActive) { + this.defaultEditor.setPlaceholder(ONBOARDING_PLACEHOLDER); + } + // Start the UI before initializing extensions so session_start handlers can use interactive dialogs. // fd/rg readiness is intentionally checked after first paint because ensureTool may spawn // or download tools on cold machines. @@ -102,6 +121,17 @@ InteractiveModeBase.prototype.init = async function(this: InteractiveModeBase): this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); } + if (this.firstRunOnboardingActive) { + this.firstRunOnboardingHeaderComponents = [ + new DynamicBorder(), + new Text(ONBOARDING_COPY, 1, 0), + new DynamicBorder(), + new Spacer(1), + ]; + for (const component of this.firstRunOnboardingHeaderComponents) { + this.headerContainer.addChild(component); + } + } this.ui.requestRender(); void Promise.all([ensureTool("fd"), ensureTool("rg")]) diff --git a/packages/coding-agent/test/custom-editor-placeholder.test.ts b/packages/coding-agent/test/custom-editor-placeholder.test.ts new file mode 100644 index 000000000..a922422e2 --- /dev/null +++ b/packages/coding-agent/test/custom-editor-placeholder.test.ts @@ -0,0 +1,55 @@ +import { CURSOR_MARKER, type TUI, visibleWidth } from "@earendil-works/pi-tui"; +import { beforeEach, describe, expect, it } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { CustomEditor } from "../src/modes/interactive/components/custom-editor.ts"; +import { getEditorTheme, initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; + +function createTui(): TUI { + return { + terminal: { rows: 24 }, + requestRender: () => {}, + } as Partial as TUI; +} + +function createEditor(placeholder: string): CustomEditor { + return new CustomEditor(createTui(), getEditorTheme(), new KeybindingsManager(), { + promptPrefix: "› ", + placeholder, + }); +} + +function findPlaceholderLine(lines: string[], placeholder: string): string { + const line = lines.find((candidate) => candidate.includes(placeholder)); + if (!line) { + throw new Error("Expected placeholder line to render"); + } + return line; +} + +describe("CustomEditor placeholder", () => { + beforeEach(() => { + initTheme("dark"); + }); + + it("renders the empty-editor placeholder in muted text with a visible cursor", () => { + const placeholder = "Paste a ticket, issue, path to a spec, or task prompt…"; + const editor = createEditor(placeholder); + const line = findPlaceholderLine(editor.render(90), placeholder); + const cursor = "\x1b[7m \x1b[0m"; + const styledPlaceholder = theme.fg("muted", placeholder); + + expect(line).toContain(cursor); + expect(line).toContain(styledPlaceholder); + expect(line.indexOf(cursor)).toBeLessThan(line.indexOf(styledPlaceholder)); + expect(visibleWidth(line)).toBe(90); + }); + + it("keeps the hardware cursor marker when focused", () => { + const placeholder = "Paste a task prompt…"; + const editor = createEditor(placeholder); + editor.focused = true; + const line = findPlaceholderLine(editor.render(80), placeholder); + + expect(line).toContain(`${CURSOR_MARKER}\x1b[7m \x1b[0m`); + }); +}); diff --git a/packages/coding-agent/test/first-run-onboarding-new-session.test.ts b/packages/coding-agent/test/first-run-onboarding-new-session.test.ts new file mode 100644 index 000000000..abfab4069 --- /dev/null +++ b/packages/coding-agent/test/first-run-onboarding-new-session.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +initTheme("dark"); + +function installSubmitHandler(host: Record): (text: string) => Promise { + const setup = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as (this: Record) => void; + setup.call(host); + return (host.defaultEditor as { onSubmit: (text: string) => Promise }).onSubmit; +} + +describe("first-run onboarding /new", () => { + it("clears a stashed onboarding seed without completing onboarding", async () => { + const settingsManager = SettingsManager.inMemory(); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: true, + pendingFirstRunOnboardingSeed: "Implement the old ticket", + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + sessionManager: { getCwd: vi.fn(() => process.cwd()) }, + loadingAnimation: undefined, + statusContainer: { clear: vi.fn() }, + runtimeHost: { newSession: vi.fn().mockResolvedValue({ cancelled: false }) }, + handleClearCommand: Reflect.get(InteractiveMode.prototype, "handleClearCommand"), + clearPendingFirstRunOnboardingSeed: Reflect.get(InteractiveMode.prototype, "clearPendingFirstRunOnboardingSeed"), + renderCurrentSessionState: vi.fn(), + chatContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + handleFatalRuntimeError: vi.fn(), + settingsManager, + }; + const submit = installSubmitHandler(host); + + await submit("/new"); + + expect(host.runtimeHost.newSession).toHaveBeenCalledTimes(1); + expect(host.pendingFirstRunOnboardingSeed).toBeUndefined(); + expect(host.firstRunOnboardingSeedInFlight).toBe(false); + expect(host.firstRunOnboardingActive).toBe(true); + expect(settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(host.renderCurrentSessionState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/first-run-onboarding-round4.test.ts b/packages/coding-agent/test/first-run-onboarding-round4.test.ts new file mode 100644 index 000000000..9dca51760 --- /dev/null +++ b/packages/coding-agent/test/first-run-onboarding-round4.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +function installSubmitHandler(host: Record): (text: string) => Promise { + const setup = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as (this: Record) => void; + setup.call(host); + return (host.defaultEditor as { onSubmit: (text: string) => Promise }).onSubmit; +} + +describe("first-run onboarding round 4 regressions", () => { + it("clears active onboarding UI after resuming a non-fresh session without marking onboarded", async () => { + const cta = [{ name: "border" }, { name: "copy" }]; + const host = { + loadingAnimation: undefined, + statusContainer: { clear: vi.fn() }, + runtimeHost: { switchSession: vi.fn().mockResolvedValue({ cancelled: false }) }, + renderCurrentSessionState: vi.fn(function(this: { session: { state: { messages: string[] } } }) { + this.session.state.messages = ["old message"]; + }), + showStatus: vi.fn(), + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + firstRunOnboardingHeaderComponents: cta, + clearFirstRunOnboardingUi: Reflect.get(InteractiveMode.prototype, "clearFirstRunOnboardingUi"), + isFirstRunOnboardingEligible: vi.fn(() => false), + headerContainer: { children: [{ name: "top" }, ...cta, { name: "bottom" }] }, + defaultEditor: { setPlaceholder: vi.fn() }, + ui: { requestRender: vi.fn() }, + settingsManager: { setOnboardedVersion: vi.fn() }, + session: { state: { messages: [] } }, + createProjectTrustContext: vi.fn(), + handleFatalRuntimeError: vi.fn(), + }; + const resume = Reflect.get(InteractiveMode.prototype, "handleResumeSession") as ( + this: typeof host, + sessionPath: string, + ) => Promise<{ cancelled: boolean }>; + + await expect(resume.call(host, "session.jsonl")).resolves.toEqual({ cancelled: false }); + + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.firstRunOnboardingHeaderComponents).toEqual([]); + expect(host.headerContainer.children).toEqual([{ name: "top" }, { name: "bottom" }]); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(undefined); + expect(host.settingsManager.setOnboardedVersion).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith("Resumed session"); + }); + + it("clears active onboarding UI after importing a session without marking onboarded", async () => { + const cta = [{ name: "border" }, { name: "copy" }]; + const host = { + loadingAnimation: undefined, + statusContainer: { clear: vi.fn() }, + runtimeHost: { importFromJsonl: vi.fn().mockResolvedValue({ cancelled: false }) }, + renderCurrentSessionState: vi.fn(function(this: { session: { state: { messages: string[] } } }) { + this.session.state.messages = ["imported message"]; + }), + showStatus: vi.fn(), + showError: vi.fn(), + showExtensionConfirm: vi.fn().mockResolvedValue(true), + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: true, + pendingFirstRunOnboardingSeed: "stashed seed", + firstRunOnboardingHeaderComponents: cta, + clearFirstRunOnboardingUi: Reflect.get(InteractiveMode.prototype, "clearFirstRunOnboardingUi"), + headerContainer: { children: [{ name: "top" }, ...cta, { name: "bottom" }] }, + defaultEditor: { setPlaceholder: vi.fn() }, + ui: { requestRender: vi.fn() }, + settingsManager: { setOnboardedVersion: vi.fn() }, + session: { state: { messages: [] } }, + promptForMissingSessionCwd: vi.fn(), + handleFatalRuntimeError: vi.fn(), + getPathCommandArgument: Reflect.get(InteractiveMode.prototype, "getPathCommandArgument"), + }; + const importCommand = Reflect.get(InteractiveMode.prototype, "handleImportCommand") as ( + this: typeof host, + text: string, + ) => Promise; + + await importCommand.call(host, "/import imported.jsonl"); + + expect(host.runtimeHost.importFromJsonl).toHaveBeenCalledWith("imported.jsonl"); + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.firstRunOnboardingSeedInFlight).toBe(false); + expect(host.pendingFirstRunOnboardingSeed).toBeUndefined(); + expect(host.firstRunOnboardingHeaderComponents).toEqual([]); + expect(host.headerContainer.children).toEqual([{ name: "top" }, { name: "bottom" }]); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(undefined); + expect(host.settingsManager.setOnboardedVersion).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith("Session imported from: imported.jsonl"); + }); + + it("ignores duplicate onboarding seed submits while routing is in flight", async () => { + let resolveSeed!: () => void; + const firstSeed = new Promise((resolve) => { + resolveSeed = resolve; + }); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + handleOnboardingWorkflowSeed: vi.fn().mockReturnValue(firstSeed), + showError: vi.fn(), + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + const first = submit("Implement ticket ABC"); + const second = submit("Implement ticket ABC"); + + expect(host.handleOnboardingWorkflowSeed).toHaveBeenCalledTimes(1); + expect(host.editor.addToHistory).toHaveBeenCalledTimes(1); + resolveSeed(); + await Promise.all([first, second]); + expect(host.firstRunOnboardingSeedInFlight).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/first-run-onboarding-round7.test.ts b/packages/coding-agent/test/first-run-onboarding-round7.test.ts new file mode 100644 index 000000000..b1fbc015d --- /dev/null +++ b/packages/coding-agent/test/first-run-onboarding-round7.test.ts @@ -0,0 +1,227 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { + ONBOARDING_PLACEHOLDER, + isExistingAbsolutePathSeed, +} from "../src/modes/interactive/interactive-onboarding.ts"; + +function installSubmitHandler(host: Record): (text: string) => Promise { + const setup = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as (this: Record) => void; + setup.call(host); + return (host.defaultEditor as { onSubmit: (text: string) => Promise }).onSubmit; +} + +async function initHostWithOptions( + options: { initialMessage?: string; initialMessages?: string[] }, + configureSettings?: (settingsManager: SettingsManager) => void, + storedAuthProviders: string[] = [], +) { + const settingsManager = SettingsManager.inMemory(); + settingsManager.setQuietStartup(true); + configureSettings?.(settingsManager); + const host = { + isInitialized: false, + options, + version: "9.9.9-test", + session: { + state: { messages: [] }, + modelRegistry: { + getError: () => undefined, + authStorage: { list: () => storedAuthProviders }, + }, + }, + settingsManager, + registerSignalHandlers: vi.fn(), + getChangelogForDisplay: Reflect.get(InteractiveMode.prototype, "getChangelogForDisplay"), + reportInstallTelemetry: vi.fn(), + ui: { addChild: vi.fn(), setFocus: vi.fn(), start: vi.fn(), requestRender: vi.fn() }, + headerContainer: { addChild: vi.fn(), children: [] }, + chatContainer: { children: [], addChild: vi.fn() }, + pendingMessagesContainer: {}, + statusContainer: {}, + renderWidgets: vi.fn(), + widgetContainerAbove: {}, + usageMeter: {}, + editorContainer: {}, + footer: {}, + widgetContainerBelow: {}, + editor: {}, + setupKeyHandlers: vi.fn(), + setupEditorSubmitHandler: vi.fn(), + isFirstRunOnboardingEligible: Reflect.get(InteractiveMode.prototype, "isFirstRunOnboardingEligible"), + firstRunOnboardingActive: false, + firstRunOnboardingHeaderComponents: [], + defaultEditor: { setPlaceholder: vi.fn() }, + themeController: { applyFromSettings: vi.fn().mockResolvedValue(undefined) }, + rebindCurrentSession: vi.fn().mockResolvedValue(undefined), + renderInitialMessages: vi.fn(), + footerDataProvider: { onBranchChange: vi.fn() }, + updateAvailableProviderCount: vi.fn().mockResolvedValue(undefined), + setupAutocompleteProvider: vi.fn(), + }; + const init = Reflect.get(InteractiveMode.prototype, "init") as (this: typeof host) => Promise; + await init.call(host); + return host; +} + +describe("first-run onboarding round 7 regressions", () => { + it("starts onboarding for a fresh install with no returning-user evidence", async () => { + const host = await initHostWithOptions({}); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBe("9.9.9-test"); + expect(host.settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(host.firstRunOnboardingActive).toBe(true); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(ONBOARDING_PLACEHOLDER); + }); + + it("marks copied settings with lastChangelogVersion as an onboarded returning user", async () => { + const host = await initHostWithOptions({}, (settingsManager) => { + settingsManager.setLastChangelogVersion("0.9.1"); + }); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBeUndefined(); + expect(host.settingsManager.getOnboardedVersion()).toBe("9.9.9-test"); + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.defaultEditor.setPlaceholder).not.toHaveBeenCalled(); + }); + + it("still starts onboarding when stored auth is the only prior state", async () => { + const host = await initHostWithOptions({}, undefined, ["github-copilot"]); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBe("9.9.9-test"); + expect(host.settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(host.firstRunOnboardingActive).toBe(true); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(ONBOARDING_PLACEHOLDER); + }); + + it("keeps unfinished onboarding active even after auth and changelog state exist", async () => { + const host = await initHostWithOptions({}, (settingsManager) => { + settingsManager.setLastChangelogVersion("9.9.9-test"); + settingsManager.setFirstRunOnboardingStartedVersion("9.9.9-test"); + }, ["github-copilot"]); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBe("9.9.9-test"); + expect(host.settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(host.firstRunOnboardingActive).toBe(true); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(ONBOARDING_PLACEHOLDER); + }); + + it("keeps completed onboarding skipped even when copied settings have lastChangelogVersion", async () => { + const host = await initHostWithOptions({}, (settingsManager) => { + settingsManager.setLastChangelogVersion("0.9.1"); + settingsManager.setOnboardedVersion("0.9.1"); + }); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBeUndefined(); + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.defaultEditor.setPlaceholder).not.toHaveBeenCalled(); + }); + + it("persists the start marker during initial-message runs without activating onboarding", async () => { + for (const options of [{ initialMessage: "run this once" }, { initialMessages: ["run this once"] }]) { + const host = await initHostWithOptions(options); + + expect(host.settingsManager.getFirstRunOnboardingStartedVersion()).toBe("9.9.9-test"); + expect(host.settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.defaultEditor.setPlaceholder).not.toHaveBeenCalled(); + expect(host.isFirstRunOnboardingEligible.call({ + ...host, + options: {}, + session: { state: { messages: [] } }, + })).toBe(true); + } + }); + + it("clears active onboarding after resuming an empty existing session without marking onboarded", async () => { + const cta = [{ name: "border" }, { name: "copy" }]; + const host = { + loadingAnimation: undefined, + statusContainer: { clear: vi.fn() }, + runtimeHost: { switchSession: vi.fn().mockResolvedValue({ cancelled: false }) }, + renderCurrentSessionState: vi.fn(function(this: { session: { state: { messages: string[] } } }) { + this.session.state.messages = []; + }), + showStatus: vi.fn(), + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + firstRunOnboardingHeaderComponents: cta, + clearFirstRunOnboardingUi: Reflect.get(InteractiveMode.prototype, "clearFirstRunOnboardingUi"), + isFirstRunOnboardingEligible: vi.fn(() => true), + headerContainer: { children: [{ name: "top" }, ...cta, { name: "bottom" }] }, + defaultEditor: { setPlaceholder: vi.fn() }, + ui: { requestRender: vi.fn() }, + settingsManager: { setOnboardedVersion: vi.fn() }, + session: { state: { messages: [] } }, + createProjectTrustContext: vi.fn(), + handleFatalRuntimeError: vi.fn(), + }; + const resume = Reflect.get(InteractiveMode.prototype, "handleResumeSession") as ( + this: typeof host, + sessionPath: string, + ) => Promise<{ cancelled: boolean }>; + + await expect(resume.call(host, "empty-session.jsonl")).resolves.toEqual({ cancelled: false }); + + expect(host.isFirstRunOnboardingEligible).not.toHaveBeenCalled(); + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.firstRunOnboardingHeaderComponents).toEqual([]); + expect(host.headerContainer.children).toEqual([{ name: "top" }, { name: "bottom" }]); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(undefined); + expect(host.settingsManager.setOnboardedVersion).not.toHaveBeenCalled(); + }); + + it("completes onboarding when the seed is handed to the normal agent session", async () => { + const onInputCallback = vi.fn(); + const host = { + pendingUserInputs: [], + onInputCallback, + flushPendingBashComponents: vi.fn(), + showStatus: vi.fn(), + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + session: { setThinkingLevel: vi.fn() }, + completeFirstRunOnboarding: vi.fn(), + }; + const handleSeed = Reflect.get(InteractiveMode.prototype, "handleOnboardingWorkflowSeed") as ( + this: typeof host, + seed: string, + ) => Promise; + + await expect(handleSeed.call(host, "Fix a small bug")).resolves.toBeUndefined(); + + expect(onInputCallback.mock.calls[0]?.[0]).toContain("Original task seed"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("Fix a small bug"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("Start the selected workflow"); + expect(host.session.setThinkingLevel).toHaveBeenCalledWith("high"); + expect(host.completeFirstRunOnboarding).toHaveBeenCalledTimes(1); + }); + + it("routes outside-cwd absolute paths as raw seeds without reading them as specs", async () => { + const cwd = mkdtempSync(join(tmpdir(), "atomic-onboarding-cwd-")); + const outside = mkdtempSync(join(tmpdir(), "atomic-onboarding-outside-")); + const outsideSpec = join(outside, "outside spec.md"); + writeFileSync(outsideSpec, "OUTSIDE SECRET SPEC BODY"); + const submitHost = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + handleOnboardingWorkflowSeed: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + sessionManager: { getCwd: () => cwd }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(submitHost); + + await submit(outsideSpec); + + expect(isExistingAbsolutePathSeed(outsideSpec)).toBe(true); + expect(submitHost.handleOnboardingWorkflowSeed).toHaveBeenCalledWith(outsideSpec); + expect(submitHost.session.prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/first-run-onboarding-round8.test.ts b/packages/coding-agent/test/first-run-onboarding-round8.test.ts new file mode 100644 index 000000000..1b7d163e5 --- /dev/null +++ b/packages/coding-agent/test/first-run-onboarding-round8.test.ts @@ -0,0 +1,500 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { + ONBOARDING_HANDOFF_NOTICE, + ONBOARDING_ROUTING_THINKING_LEVEL, + ONBOARDING_SEED_REPLACED_COPY, + ONBOARDING_SEED_STASHED_COPY, + buildOnboardingHandoffPrompt, + isCwdLocalExistingPathSeed, +} from "../src/modes/interactive/interactive-onboarding.ts"; + +function installSubmitHandler(host: Record): (text: string) => Promise { + const setup = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as (this: Record) => void; + setup.call(host); + return (host.defaultEditor as { onSubmit: (text: string) => Promise }).onSubmit; +} + +const unknownModel = { provider: "unknown", id: "unknown", api: "unknown" }; +const readyModel = { provider: "openai", id: "gpt-5", api: "openai" }; +initTheme("dark"); + +describe("first-run onboarding pending seed handoff", () => { + it("stashes a pasted seed in memory when no model/provider is ready", async () => { + const settingsManager = SettingsManager.inMemory(); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + showStatus: vi.fn(), + stashFirstRunOnboardingSeed: Reflect.get(InteractiveMode.prototype, "stashFirstRunOnboardingSeed"), + isFirstRunOnboardingReadyForHandoff: Reflect.get(InteractiveMode.prototype, "isFirstRunOnboardingReadyForHandoff"), + handleOnboardingWorkflowSeed: vi.fn(), + settingsManager, + session: { + model: unknownModel, + modelRegistry: { hasConfiguredAuth: vi.fn(() => false) }, + isBashRunning: false, + isCompacting: false, + isStreaming: false, + prompt: vi.fn(), + }, + }; + const submit = installSubmitHandler(host); + + await submit("Implement ticket ABC"); + + expect(host.pendingFirstRunOnboardingSeed).toBe("Implement ticket ABC"); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_SEED_STASHED_COPY); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + expect(settingsManager.getOnboardedVersion()).toBeUndefined(); + expect(JSON.stringify(settingsManager)).not.toContain("Implement ticket ABC"); + }); + + it("stashes a slash-start absolute path:line seed before login without dropping the suffix", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-onboarding-")); + try { + const specPath = join(dir, "spec.md"); + const seed = `${specPath}:12`; + await writeFile(specPath, "# Spec\n"); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + showStatus: vi.fn(), + stashFirstRunOnboardingSeed: Reflect.get(InteractiveMode.prototype, "stashFirstRunOnboardingSeed"), + isFirstRunOnboardingReadyForHandoff: vi.fn(() => false), + handleOnboardingWorkflowSeed: vi.fn(), + sessionManager: { getCwd: vi.fn(() => dir) }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(seed); + + expect(host.pendingFirstRunOnboardingSeed).toBe(seed); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_SEED_STASHED_COPY); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("stashes a multiline slash-start absolute path seed before login without dropping notes", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-onboarding-")); + try { + const specPath = join(dir, "spec.md"); + const seed = `${specPath}:12\nNotes: keep the original text`; + await writeFile(specPath, "# Spec\n"); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + showStatus: vi.fn(), + stashFirstRunOnboardingSeed: Reflect.get(InteractiveMode.prototype, "stashFirstRunOnboardingSeed"), + isFirstRunOnboardingReadyForHandoff: vi.fn(() => false), + handleOnboardingWorkflowSeed: vi.fn(), + sessionManager: { getCwd: vi.fn(() => dir) }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(seed); + + expect(host.pendingFirstRunOnboardingSeed).toBe(seed); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_SEED_STASHED_COPY); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("does not classify a cwd-local symlink resolving outside cwd as contained", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-onboarding-")); + try { + const cwd = join(dir, "workspace"); + const outsidePath = join(dir, "outside-spec.md"); + const linkPath = join(cwd, "linked-spec.md"); + await mkdir(cwd); + await writeFile(outsidePath, "# Outside\n"); + try { + await symlink(outsidePath, linkPath); + } catch { + return; + } + + expect(isCwdLocalExistingPathSeed(linkPath, cwd)).toBe(false); + expect(isCwdLocalExistingPathSeed(`${linkPath}:12:3`, cwd)).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("hands off a ready slash-start absolute path:line:column seed without dropping the suffix", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-onboarding-")); + try { + const specPath = join(dir, "spec.md"); + const seed = `${specPath}:12:3`; + const onInputCallback = vi.fn(); + await writeFile(specPath, "# Spec\n"); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + pendingUserInputs: [], + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + isFirstRunOnboardingReadyForHandoff: vi.fn(() => true), + handleOnboardingWorkflowSeed: Reflect.get(InteractiveMode.prototype, "handleOnboardingWorkflowSeed"), + flushPendingBashComponents: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + completeFirstRunOnboarding: vi.fn(function(this: { firstRunOnboardingActive: boolean }) { + this.firstRunOnboardingActive = false; + }), + onInputCallback, + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + sessionManager: { getCwd: vi.fn(() => dir) }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn(), setThinkingLevel: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(seed); + + expect(onInputCallback).toHaveBeenCalledWith(buildOnboardingHandoffPrompt(seed)); + expect(onInputCallback.mock.calls[0]?.[0]).toContain(seed); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_HANDOFF_NOTICE); + expect(host.firstRunOnboardingActive).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("hands off a ready multiline slash-start absolute path seed without dropping notes", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-onboarding-")); + try { + const specPath = join(dir, "spec.md"); + const seed = `${specPath}:12:3\nNotes: preserve this handoff context`; + const onInputCallback = vi.fn(); + await writeFile(specPath, "# Spec\n"); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + pendingUserInputs: [], + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + isFirstRunOnboardingReadyForHandoff: vi.fn(() => true), + handleOnboardingWorkflowSeed: Reflect.get(InteractiveMode.prototype, "handleOnboardingWorkflowSeed"), + flushPendingBashComponents: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + completeFirstRunOnboarding: vi.fn(function(this: { firstRunOnboardingActive: boolean }) { + this.firstRunOnboardingActive = false; + }), + onInputCallback, + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + sessionManager: { getCwd: vi.fn(() => dir) }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn(), setThinkingLevel: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(seed); + + expect(onInputCallback).toHaveBeenCalledWith(buildOnboardingHandoffPrompt(seed)); + expect(onInputCallback.mock.calls[0]?.[0]).toContain(seed); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_HANDOFF_NOTICE); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("does not treat slash commands with notes as path seeds", async () => { + for (const command of ["/login\nnotes", "/settings\nnotes", "/atomic\nnotes"]) { + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + stashFirstRunOnboardingSeed: vi.fn(), + isFirstRunOnboardingReadyForHandoff: vi.fn(() => false), + handleOnboardingWorkflowSeed: vi.fn(), + flushPendingBashComponents: vi.fn(), + pendingUserInputs: [], + sessionManager: { getCwd: vi.fn(() => tmpdir()) }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(command); + + expect(host.stashFirstRunOnboardingSeed).not.toHaveBeenCalled(); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + expect(host.pendingFirstRunOnboardingSeed).toBeUndefined(); + } + }); + + it("uses a dynamic handoff fence that survives embedded Markdown fences", () => { + const seed = "Before\n```ts\nconsole.log('seed');\n```\nAfter"; + const prompt = buildOnboardingHandoffPrompt(seed); + + expect(prompt).toContain("````text\n" + seed + "\n````"); + expect(prompt.split("\n")).not.toContain("```text"); + }); + + it("asks the parent to show first-run workflow control commands after dispatch", () => { + const prompt = buildOnboardingHandoffPrompt("Implement the ticket"); + + expect(prompt).toContain("`/workflow status ` checks progress."); + expect(prompt).toContain("`/workflow connect ` opens the graph viewer to watch, attach, and steer"); + expect(prompt).toContain("ask in this chat for status or to steer the run at any point"); + }); + + it("replaces a pending seed before login with the latest seed", async () => { + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + showStatus: vi.fn(), + stashFirstRunOnboardingSeed: Reflect.get(InteractiveMode.prototype, "stashFirstRunOnboardingSeed"), + isFirstRunOnboardingReadyForHandoff: Reflect.get(InteractiveMode.prototype, "isFirstRunOnboardingReadyForHandoff"), + handleOnboardingWorkflowSeed: vi.fn(), + session: { + model: unknownModel, + modelRegistry: { hasConfiguredAuth: vi.fn(() => false) }, + isBashRunning: false, + isCompacting: false, + isStreaming: false, + prompt: vi.fn(), + }, + }; + const submit = installSubmitHandler(host); + + await submit("First task"); + await submit("Second task"); + + expect(host.pendingFirstRunOnboardingSeed).toBe("Second task"); + expect(host.showStatus).toHaveBeenLastCalledWith(ONBOARDING_SEED_REPLACED_COPY); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + }); + + it("resumes a pending seed after authentication makes the session ready", async () => { + const host = { + firstRunOnboardingActive: true, + pendingFirstRunOnboardingSeed: "Saved task", + isFirstRunOnboardingReadyForHandoff: vi.fn(() => true), + handleOnboardingWorkflowSeed: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + }; + const resume = Reflect.get(InteractiveMode.prototype, "resumePendingFirstRunOnboardingSeed") as (this: typeof host) => Promise; + + await resume.call(host); + + expect(host.handleOnboardingWorkflowSeed).toHaveBeenCalledWith("Saved task"); + expect(host.pendingFirstRunOnboardingSeed).toBeUndefined(); + }); + + it("keeps a pending seed if post-login handoff cannot start", async () => { + const host = { + firstRunOnboardingActive: true, + pendingFirstRunOnboardingSeed: "Saved task", + isFirstRunOnboardingReadyForHandoff: vi.fn(() => true), + handleOnboardingWorkflowSeed: vi.fn().mockRejectedValue(new Error("not ready")), + showError: vi.fn(), + }; + const resume = Reflect.get(InteractiveMode.prototype, "resumePendingFirstRunOnboardingSeed") as (this: typeof host) => Promise; + + await resume.call(host); + + expect(host.pendingFirstRunOnboardingSeed).toBe("Saved task"); + expect(host.showError).toHaveBeenCalledWith("not ready"); + }); + + it("hands a ready seed to normal input and completes onboarding at enqueue time", async () => { + const onInputCallback = vi.fn(); + const host = { + firstRunOnboardingActive: true, + pendingFirstRunOnboardingSeed: undefined, + pendingUserInputs: [], + onInputCallback, + flushPendingBashComponents: vi.fn(), + showStatus: vi.fn(), + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + session: { setThinkingLevel: vi.fn() }, + completeFirstRunOnboarding: vi.fn(function(this: { firstRunOnboardingActive: boolean }) { + this.firstRunOnboardingActive = false; + }), + }; + const handleSeed = Reflect.get(InteractiveMode.prototype, "handleOnboardingWorkflowSeed") as (this: typeof host, seed: string) => Promise; + + await handleSeed.call(host, "Original ticket text"); + + expect(onInputCallback).toHaveBeenCalledWith(buildOnboardingHandoffPrompt("Original ticket text")); + expect(host.session.setThinkingLevel).toHaveBeenCalledWith(ONBOARDING_ROUTING_THINKING_LEVEL); + expect(host.footer.invalidate).toHaveBeenCalledTimes(1); + expect(host.updateEditorBorderColor).toHaveBeenCalledTimes(1); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("high reasoning"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("seed text alone"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("initial confidence signal"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("route directly without codebase probing"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("do not turn this into an open-ended research project"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("codebase-locator"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("codebase-analyzer"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("codebase-pattern-finder"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("choose `goal`"); + expect(onInputCallback.mock.calls[0]?.[0]).toContain("choose `ralph`"); + expect(host.showStatus).toHaveBeenCalledWith(ONBOARDING_HANDOFF_NOTICE); + expect(host.completeFirstRunOnboarding).toHaveBeenCalledTimes(1); + expect(host.firstRunOnboardingActive).toBe(false); + }); + + it("does not treat /chat specially after a successful handoff", async () => { + const onInputCallback = vi.fn(); + const host = { + firstRunOnboardingActive: true, + firstRunOnboardingSeedInFlight: false, + pendingFirstRunOnboardingSeed: undefined, + pendingUserInputs: [], + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + isFirstRunOnboardingReadyForHandoff: vi.fn(() => true), + handleOnboardingWorkflowSeed: Reflect.get(InteractiveMode.prototype, "handleOnboardingWorkflowSeed"), + flushPendingBashComponents: vi.fn(), + showStatus: vi.fn(), + completeFirstRunOnboarding: vi.fn(function(this: { firstRunOnboardingActive: boolean }) { + this.firstRunOnboardingActive = false; + }), + onInputCallback, + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn(), setThinkingLevel: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit("Implement the ticket"); + await submit("/chat explain the next step"); + + expect(onInputCallback).toHaveBeenNthCalledWith(1, buildOnboardingHandoffPrompt("Implement the ticket")); + expect(onInputCallback).toHaveBeenNthCalledWith(2, "/chat explain the next step"); + }); + + it("completeProviderAuthentication attempts pending seed resume after UI refresh", async () => { + const host = { + session: { + model: readyModel, + modelRegistry: { + refresh: vi.fn(), + getAvailable: vi.fn(() => [readyModel]), + hasConfiguredAuth: vi.fn(() => true), + }, + setModel: vi.fn(), + }, + updateAvailableProviderCount: vi.fn().mockResolvedValue(undefined), + setupAutocompleteProvider: vi.fn(), + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + resumePendingFirstRunOnboardingSeed: vi.fn().mockResolvedValue(undefined), + }; + const complete = Reflect.get(InteractiveMode.prototype, "completeProviderAuthentication") as ( + this: typeof host, + providerId: string, + providerName: string, + authType: "oauth" | "api_key", + previousModel: typeof readyModel, + ) => Promise; + + await complete.call(host, "openai", "OpenAI", "api_key", readyModel); + + expect(host.updateAvailableProviderCount.mock.invocationCallOrder[0]).toBeLessThan(host.resumePendingFirstRunOnboardingSeed.mock.invocationCallOrder[0] ?? 0); + expect(host.setupAutocompleteProvider.mock.invocationCallOrder[0]).toBeLessThan(host.resumePendingFirstRunOnboardingSeed.mock.invocationCallOrder[0] ?? 0); + expect(host.resumePendingFirstRunOnboardingSeed).toHaveBeenCalledTimes(1); + }); + + it("resumes pending seeds at successful /model readiness points", async () => { + const exactHost = { + session: { setModel: vi.fn().mockResolvedValue(undefined) }, + findExactModelMatch: vi.fn().mockResolvedValue(readyModel), + showModelSelector: vi.fn(), + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + showStatus: vi.fn(), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + checkDaxnutsEasterEgg: vi.fn(), + resumePendingFirstRunOnboardingSeed: vi.fn().mockResolvedValue(undefined), + }; + const handleModel = Reflect.get(InteractiveMode.prototype, "handleModelCommand") as (this: typeof exactHost, searchTerm?: string) => Promise; + await handleModel.call(exactHost, "openai/gpt-5"); + expect(exactHost.resumePendingFirstRunOnboardingSeed).toHaveBeenCalledTimes(1); + + const showModelSelector = Reflect.get(InteractiveMode.prototype, "showModelSelector") as (this: Record) => void; + const selectModel = async (needsContextWindow: boolean) => { + let selector: { onSelectCallback: (model: typeof readyModel) => Promise } | undefined; + const host = { + ui: { requestRender: vi.fn() }, + settingsManager: SettingsManager.inMemory(), + session: { + model: unknownModel, + modelRegistry: { refresh: vi.fn(), getError: vi.fn(() => undefined), getAvailable: vi.fn().mockResolvedValue([readyModel]) }, + scopedModels: [], + setModel: vi.fn().mockResolvedValue(undefined), + supportsContextWindowSelection: vi.fn(() => needsContextWindow), + }, + showSelector: vi.fn((factory: (done: () => void) => { component: unknown }) => { selector = factory(vi.fn()).component as typeof selector; }), + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + showStatus: vi.fn(), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + checkDaxnutsEasterEgg: vi.fn(), + showContextWindowSelector: vi.fn(), + resumePendingFirstRunOnboardingSeed: vi.fn().mockResolvedValue(undefined), + }; + showModelSelector.call(host); + await selector?.onSelectCallback(readyModel); + return host; + }; + + const noContextHost = await selectModel(false); + expect(noContextHost.showContextWindowSelector).not.toHaveBeenCalled(); + expect(noContextHost.resumePendingFirstRunOnboardingSeed).toHaveBeenCalledTimes(1); + + const needsContextHost = await selectModel(true); + expect(needsContextHost.showContextWindowSelector).toHaveBeenCalledWith(readyModel); + expect(needsContextHost.resumePendingFirstRunOnboardingSeed).not.toHaveBeenCalled(); + + let contextSelector: { onSelectCallback: (contextWindow: number) => Promise } | undefined; + const contextHost = { + showSelector: vi.fn((factory: (done: () => void) => { component: unknown }) => { contextSelector = factory(vi.fn()).component as typeof contextSelector; }), + session: { model: readyModel, getAvailableContextWindows: vi.fn(() => [200000, 1000000]), setContextWindow: vi.fn() }, + footer: { invalidate: vi.fn() }, + usageMeter: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + showStatus: vi.fn(), + resumePendingFirstRunOnboardingSeed: vi.fn().mockResolvedValue(undefined), + }; + const showContextWindowSelector = Reflect.get(InteractiveMode.prototype, "showContextWindowSelector") as (this: typeof contextHost, model: typeof readyModel) => void; + showContextWindowSelector.call(contextHost, readyModel); + await contextSelector?.onSelectCallback(1000000); + expect(contextHost.session.setContextWindow).toHaveBeenCalledWith(1000000, { persistDefault: true }); + expect(contextHost.resumePendingFirstRunOnboardingSeed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/first-run-onboarding.test.ts b/packages/coding-agent/test/first-run-onboarding.test.ts new file mode 100644 index 000000000..ae9c96b06 --- /dev/null +++ b/packages/coding-agent/test/first-run-onboarding.test.ts @@ -0,0 +1,193 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { + NORMAL_CHAT_TRANSITION_COPY, + ONBOARDING_COPY, + ONBOARDING_PLACEHOLDER, +} from "../src/modes/interactive/interactive-onboarding.ts"; + +function installSubmitHandler(host: Record): (text: string) => Promise { + const setup = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as (this: Record) => void; + setup.call(host); + return (host.defaultEditor as { onSubmit: (text: string) => Promise }).onSubmit; +} + +describe("first-run onboarding", () => { + it("stores onboarding start and completion separately from lastChangelogVersion", () => { + const manager = SettingsManager.inMemory(); + manager.setLastChangelogVersion("0.1.0"); + manager.setFirstRunOnboardingStartedVersion("0.2.0"); + manager.setOnboardedVersion("0.3.0"); + + expect(manager.getLastChangelogVersion()).toBe("0.1.0"); + expect(manager.getFirstRunOnboardingStartedVersion()).toBe("0.2.0"); + expect(manager.getOnboardedVersion()).toBe("0.3.0"); + }); + + it("gates first-run onboarding on an empty started session and missing onboardedVersion", () => { + const isEligible = Reflect.get(InteractiveMode.prototype, "isFirstRunOnboardingEligible") as (this: { + session: { state: { messages: string[] } }; + settingsManager: { + getFirstRunOnboardingStartedVersion: () => string | undefined; + getOnboardedVersion: () => string | undefined; + }; + options: { initialMessage?: string; initialMessages?: string[] }; + }) => boolean; + const settingsManager = { + getFirstRunOnboardingStartedVersion: () => "0.2.0", + getOnboardedVersion: () => undefined, + }; + + expect(ONBOARDING_COPY).toContain("Paste a ticket description"); + expect(ONBOARDING_COPY).toContain("first run /login"); + expect(ONBOARDING_PLACEHOLDER).toContain("Paste a ticket"); + expect(isEligible.call({ session: { state: { messages: [] } }, settingsManager, options: {} })).toBe(true); + expect(isEligible.call({ session: { state: { messages: ["old"] } }, settingsManager, options: {} })).toBe(false); + expect(isEligible.call({ session: { state: { messages: [] } }, settingsManager: { ...settingsManager, getOnboardedVersion: () => "0.1.0" }, options: {} })).toBe(false); + expect(isEligible.call({ session: { state: { messages: [] } }, settingsManager: { ...settingsManager, getFirstRunOnboardingStartedVersion: () => undefined }, options: {} })).toBe(false); + expect(isEligible.call({ session: { state: { messages: [] } }, settingsManager, options: { initialMessage: "run once" } })).toBe(false); + expect(isEligible.call({ session: { state: { messages: [] } }, settingsManager, options: { initialMessages: ["one"] } })).toBe(false); + }); + + it("uses a separate onboarding-start marker after changelog startup records the current version", () => { + const manager = SettingsManager.inMemory(); + const host = { + session: { state: { messages: [] } }, + settingsManager: manager, + reportInstallTelemetry: vi.fn(), + hadLastChangelogVersionAtStartup: Boolean(manager.getLastChangelogVersion()), + }; + const getChangelogForDisplay = Reflect.get(InteractiveMode.prototype, "getChangelogForDisplay") as (this: typeof host) => string | undefined; + const isEligible = Reflect.get(InteractiveMode.prototype, "isFirstRunOnboardingEligible") as (this: typeof host) => boolean; + + expect(getChangelogForDisplay.call(host)).toBeUndefined(); + expect(manager.getLastChangelogVersion()).toBeTruthy(); + expect(isEligible.call({ ...host, options: {} })).toBe(false); + manager.setFirstRunOnboardingStartedVersion("0.2.0"); + expect(isEligible.call({ ...host, options: {} })).toBe(true); + + const upgraded = SettingsManager.inMemory(); + upgraded.setLastChangelogVersion("0.1.0"); + expect(isEligible.call({ ...host, settingsManager: upgraded, hadLastChangelogVersionAtStartup: true, options: {} })).toBe(false); + }); + + it("removes rendered onboarding header components when onboarding completes", () => { + const first = { name: "first" }; + const cta = [{ name: "border" }, { name: "copy" }, { name: "bottom" }]; + const last = { name: "last" }; + const host = { + version: "0.2.0", + firstRunOnboardingActive: true, + firstRunOnboardingHeaderComponents: cta, + headerContainer: { children: [first, ...cta, last] }, + settingsManager: { setOnboardedVersion: vi.fn() }, + defaultEditor: { setPlaceholder: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + const complete = Reflect.get(InteractiveMode.prototype, "completeFirstRunOnboarding") as (this: typeof host) => void; + + complete.call(host); + + expect(host.firstRunOnboardingActive).toBe(false); + expect(host.headerContainer.children).toEqual([first, last]); + expect(host.firstRunOnboardingHeaderComponents).toEqual([]); + expect(host.settingsManager.setOnboardedVersion).toHaveBeenCalledWith("0.2.0"); + expect(host.defaultEditor.setPlaceholder).toHaveBeenCalledWith(undefined); + expect(host.ui.requestRender).toHaveBeenCalledTimes(1); + }); + + it("/chat exits onboarding and sends a message through normal input", async () => { + const onInputCallback = vi.fn(); + const host = { + firstRunOnboardingActive: true, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + completeFirstRunOnboarding: vi.fn(), + showStatus: vi.fn(), + flushPendingBashComponents: vi.fn(), + onInputCallback, + pendingUserInputs: [], + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit("/chat please explain the repo"); + + expect(host.completeFirstRunOnboarding).toHaveBeenCalledTimes(1); + expect(host.showStatus).toHaveBeenCalledWith(NORMAL_CHAT_TRANSITION_COPY); + expect(onInputCallback).toHaveBeenCalledWith("please explain the repo"); + expect(host.session.prompt).not.toHaveBeenCalled(); + }); + + it("slash commands other than /chat pass through without completing onboarding", async () => { + const onInputCallback = vi.fn(); + const host = { + firstRunOnboardingActive: true, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + completeFirstRunOnboarding: vi.fn(), + handleOnboardingWorkflowSeed: vi.fn(), + showOAuthSelector: vi.fn(), + flushPendingBashComponents: vi.fn(), + onInputCallback, + pendingUserInputs: [], + sessionManager: { getCwd: () => process.cwd() }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit("/login"); + await submit("/workflow status"); + + expect(host.showOAuthSelector).toHaveBeenCalledWith("login"); + expect(onInputCallback).toHaveBeenCalledWith("/workflow status"); + expect(host.completeFirstRunOnboarding).not.toHaveBeenCalled(); + expect(host.handleOnboardingWorkflowSeed).not.toHaveBeenCalled(); + }); + + it("treats cwd-local absolute spec paths with spaces as onboarding seeds instead of slash commands", async () => { + const cwd = mkdtempSync(join(tmpdir(), "atomic onboarding ")); + const specPath = join(cwd, "spec with spaces.md"); + writeFileSync(specPath, "# Local spec\n\nFix the local onboarding route."); + const host = { + firstRunOnboardingActive: true, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + handleOnboardingWorkflowSeed: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + sessionManager: { getCwd: () => cwd }, + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit(specPath); + + expect(host.handleOnboardingWorkflowSeed).toHaveBeenCalledWith(specPath); + expect(host.session.prompt).not.toHaveBeenCalled(); + expect(host.editor.setText).toHaveBeenCalledWith(""); + }); + + it("does not complete onboarding when handoff fails", async () => { + const host = { + firstRunOnboardingActive: true, + defaultEditor: {}, + editor: { setText: vi.fn(), addToHistory: vi.fn() }, + handleOnboardingWorkflowSeed: vi.fn().mockRejectedValue(new Error("no auth")), + completeFirstRunOnboarding: vi.fn(), + showError: vi.fn(), + session: { isBashRunning: false, isCompacting: false, isStreaming: false, prompt: vi.fn() }, + }; + const submit = installSubmitHandler(host); + + await submit("Implement ticket ABC"); + + expect(host.handleOnboardingWorkflowSeed).toHaveBeenCalledWith("Implement ticket ABC"); + expect(host.completeFirstRunOnboarding).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledWith("no auth"); + expect(host.editor.setText).toHaveBeenLastCalledWith("Implement ticket ABC"); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status-autocomplete.suite.ts b/packages/coding-agent/test/interactive-mode-status-autocomplete.suite.ts index 51b9ed45d..bb2c52862 100644 --- a/packages/coding-agent/test/interactive-mode-status-autocomplete.suite.ts +++ b/packages/coding-agent/test/interactive-mode-status-autocomplete.suite.ts @@ -1,6 +1,9 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { type Api, type Model } from "@earendil-works/pi-ai"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { @@ -256,6 +259,80 @@ describe("InteractiveMode /fast autocomplete", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function createAutocompleteProvider(cwd: string, fdPath: string | null = null): AutocompleteProvider { + type AutocompleteHost = { + session: { + scopedModels: []; + modelRegistry: { getAvailable: () => [] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: string | null; + }; + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: AutocompleteHost): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const fakeThis: AutocompleteHost = { + session: { + scopedModels: [], + modelRegistry: { getAvailable: () => [] }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => cwd }, + fdPath, + }; + Object.setPrototypeOf(fakeThis, (InteractiveMode as unknown as { prototype: object }).prototype); + return createBaseAutocompleteProvider.call(fakeThis); + } + + test("falls back to path completion for @ file mentions before fd is ready", async () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-at-autocomplete-")); + tempDirs.push(dir); + writeFileSync(join(dir, "spec.md"), "# Spec\n"); + writeFileSync(join(dir, "other.txt"), "Other\n"); + const provider = createAutocompleteProvider(dir); + + const suggestions = await provider.getSuggestions(["@sp"], 0, 3, { + signal: new AbortController().signal, + }); + + expect(suggestions?.prefix).toBe("@sp"); + expect(suggestions?.items.map((item) => item.value)).toEqual(["@spec.md"]); + }); + + test("preserves @ quoting when fallback completion targets paths with spaces", async () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-at-autocomplete-")); + tempDirs.push(dir); + writeFileSync(join(dir, "task brief.md"), "# Task\n"); + const provider = createAutocompleteProvider(dir); + const line = 'please read @"task'; + + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.prefix).toBe('@"task'); + expect(suggestions?.items.map((item) => item.value)).toEqual(['@"task brief.md"']); + }); + test("matches model command arguments across provider/model order", async () => { type TestModel = { id: string; provider: string; name: string }; type FakeInteractiveMode = { diff --git a/packages/coding-agent/test/workflow-agent-dir-isolation.test.ts b/packages/coding-agent/test/workflow-agent-dir-isolation.test.ts new file mode 100644 index 000000000..e06593bdf --- /dev/null +++ b/packages/coding-agent/test/workflow-agent-dir-isolation.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadWorkflowConfig, toScopedDiscoveryConfig } from "../../workflows/src/extension/config-loader.ts"; +import { discoverWorkflows } from "../../workflows/src/extension/discovery.ts"; + +const previousEnv = new Map(); +const envKeys = ["HOME", "ATOMIC_CODING_AGENT_DIR", "PI_CODING_AGENT_DIR"] as const; +const tempRoots: string[] = []; + +function rememberEnv(): void { + for (const key of envKeys) previousEnv.set(key, process.env[key]); +} + +function restoreEnv(): void { + for (const key of envKeys) { + const value = previousEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + previousEnv.clear(); + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +} + +function setupIsolatedAgentDir(): { root: string; cwd: string; homeAgentDir: string; isolatedAgentDir: string } { + rememberEnv(); + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-agent-dir-isolation-")); + tempRoots.push(root); + const cwd = join(root, "repo"); + const home = join(root, "home"); + const homeAgentDir = join(home, ".atomic", "agent"); + const isolatedAgentDir = join(root, "isolated-agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(homeAgentDir, { recursive: true }); + mkdirSync(isolatedAgentDir, { recursive: true }); + process.env.HOME = home; + process.env.ATOMIC_CODING_AGENT_DIR = isolatedAgentDir; + delete process.env.PI_CODING_AGENT_DIR; + return { root, cwd, homeAgentDir, isolatedAgentDir }; +} + +function writeShadowWorkflow(agentDir: string, name: "goal" | "ralph"): void { + const workflowsDir = join(agentDir, "workflows"); + mkdirSync(workflowsDir, { recursive: true }); + writeFileSync( + join(workflowsDir, `${name}.ts`), + [ + 'import { workflow } from "@bastani/workflows";', + "export default workflow({", + ` name: "${name}",`, + ` description: "stale home-global ${name}",`, + " outputs: {},", + " async run() { return {}; },", + "});", + "", + ].join("\n"), + ); +} + +afterEach(restoreEnv); + +describe("workflow agent dir isolation", () => { + it("loads workflow config from ATOMIC_CODING_AGENT_DIR without reading home-global diagnostics", async () => { + const { cwd, homeAgentDir, isolatedAgentDir } = setupIsolatedAgentDir(); + mkdirSync(join(homeAgentDir, "extensions", "workflow"), { recursive: true }); + writeFileSync(join(homeAgentDir, "extensions", "workflow", "config.json"), "{ not json"); + mkdirSync(join(isolatedAgentDir, "extensions", "workflow"), { recursive: true }); + writeFileSync( + join(isolatedAgentDir, "extensions", "workflow", "config.json"), + JSON.stringify({ maxDepth: 7, workflows: { custom: { path: "workflows/custom.ts" } } }), + ); + + const result = await loadWorkflowConfig({ projectRoot: cwd }); + const discoveryConfig = toScopedDiscoveryConfig(result.globalConfig ?? null, result.projectConfig ?? null, { projectRoot: cwd }); + + expect(result.config?.maxDepth).toBe(7); + expect(discoveryConfig.globalWorkflows?.custom).toBe(join(isolatedAgentDir, "workflows", "custom.ts")); + expect(result.diagnostics.some((diagnostic) => diagnostic.source?.startsWith(homeAgentDir))).toBe(false); + }); + + it("does not let home-global goal or ralph workflows shadow bundled onboarding targets", async () => { + const { cwd, homeAgentDir } = setupIsolatedAgentDir(); + writeShadowWorkflow(homeAgentDir, "goal"); + writeShadowWorkflow(homeAgentDir, "ralph"); + + const result = await discoverWorkflows({ cwd }); + const goalSource = result.sources.find((source) => source.id === "goal"); + const ralphSource = result.sources.find((source) => source.id === "ralph"); + + expect(goalSource?.kind).toBe("bundled"); + expect(ralphSource?.kind).toBe("bundled"); + expect(result.errors.some((diagnostic) => diagnostic.source?.startsWith(homeAgentDir))).toBe(false); + }); +}); diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 735630384..1af79ca7a 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Fixed the builtin `open-claude-design` continuation loop so generator stages fork only from prior generator sessions and user-feedback stages fork only from prior feedback sessions; the first feedback stage now starts its own chain instead of inheriting generator context. +- Fixed workflow config loading and user-global discovery to honor `ATOMIC_CODING_AGENT_DIR` isolation instead of reading `~/.atomic/agent` when an isolated agent dir is configured. ## [0.9.2] - 2026-06-23 @@ -31,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Removed the initial `prompt-refinement` stage and shared prompt-refinement helper from the builtin `goal` and `ralph` workflows so both now use the raw objective/prompt as the operative task text for their first downstream stages; the now-obsolete refined/original trace outputs were also removed. - Updated builtin `goal` and `ralph` reviewer prompts to inspect referenced QA end-to-end video evidence before treating it as proof of user-visible behavior. - Aligned the workflows package peer dependency with upstream pi TUI `^0.79.10`; no workflow source changes were needed for this metadata sync. + ## [0.9.1] - 2026-06-23 ### Changed diff --git a/packages/workflows/src/extension/config-loader.ts b/packages/workflows/src/extension/config-loader.ts index 69d7d3cb1..7a9749b0c 100644 --- a/packages/workflows/src/extension/config-loader.ts +++ b/packages/workflows/src/extension/config-loader.ts @@ -8,7 +8,7 @@ * Project-local: * /.atomic/extensions/workflow/config.json * User-global: - * /.atomic/agent/extensions/workflow/config.json + * /extensions/workflow/config.json * Invalid JSON or invalid shape → CONFIG_INVALID diagnostic (not silent success). * Missing file → silently skipped (not an error). * @@ -17,8 +17,7 @@ */ import { join, isAbsolute } from "node:path"; -import { homedir } from "node:os"; -import { CONFIG_DIR_NAME, CONFIG_DIR_NAMES, getProjectConfigPaths } from "@bastani/atomic"; +import { CONFIG_DIR_NAME, CONFIG_DIR_NAMES, getAgentDir, getAgentDirs, getProjectConfigPaths } from "@bastani/atomic"; import type { WorkflowLifecycleNoticeKind } from "./lifecycle-notifications.js"; import { loadConfigFile } from "./config-file-loader.js"; @@ -82,7 +81,7 @@ export interface ConfigLoadResult { */ readonly config: WorkflowExtensionConfig | null; /** - * Pre-merge global config (from /.atomic/agent/extensions/workflow/config.json). + * Pre-merge global config (from /extensions/workflow/config.json). * null when the global file is absent or invalid. Absent on results from callers * that constructed ConfigLoadResult before this field was added. */ @@ -108,10 +107,15 @@ export interface LoadWorkflowConfigOpts { */ readonly projectRoot?: string; /** - * User home directory. Defaults to os.homedir(). - * Global config is resolved relative to this path. + * User home directory. When set, preserves legacy test/compat resolution + * relative to /.atomic/agent and /.pi/agent. */ readonly homeDir?: string; + /** + * User agent config directories in precedence order. Defaults to Atomic's + * configured agent directories, honoring ATOMIC_CODING_AGENT_DIR. + */ + readonly agentDirs?: readonly string[]; } // --------------------------------------------------------------------------- @@ -242,10 +246,15 @@ export interface ScopedDiscoveryConfigOpts { */ readonly projectRoot: string; /** - * User home directory. Relative paths in globalConfig.workflows are - * resolved relative to /.atomic/agent. + * User home directory. When set, relative paths in globalConfig.workflows + * resolve relative to /.atomic/agent. + */ + readonly homeDir?: string; + /** + * User agent config directory. Defaults to Atomic's configured agent dir, + * honoring ATOMIC_CODING_AGENT_DIR. */ - readonly homeDir: string; + readonly agentDir?: string; } export interface ScopedDiscoveryConfig { @@ -275,12 +284,21 @@ function resolveWorkflowPaths( ); } +function workflowAgentDirs(opts: Pick): readonly string[] { + if (opts.agentDirs !== undefined) return opts.agentDirs; + if (opts.homeDir !== undefined) { + const homeDir = opts.homeDir; + return CONFIG_DIR_NAMES.map((name) => join(homeDir, name, "agent")); + } + return getAgentDirs(); +} + /** * Build a scope-aware DiscoveryConfig from the pre-merge global and project configs. * * Scope rules: * - globalConfig.workflows entries → DiscoveryConfig.globalWorkflows - * Relative paths are resolved under /.atomic/agent. + * Relative paths are resolved under the configured Atomic agent dir. * - projectConfig.workflows entries → DiscoveryConfig.projectWorkflows * Relative paths are resolved under projectRoot. * - When both configs define the same workflow key, the project entry wins @@ -294,7 +312,8 @@ export function toScopedDiscoveryConfig( projectConfig: WorkflowExtensionConfig | null, opts: ScopedDiscoveryConfigOpts, ): ScopedDiscoveryConfig { - const globalBase = join(opts.homeDir, CONFIG_DIR_NAME, "agent"); + const globalBase = opts.agentDir + ?? (opts.homeDir === undefined ? getAgentDir() : join(opts.homeDir, CONFIG_DIR_NAME, "agent")); const projectBase = opts.projectRoot; const result: ScopedDiscoveryConfig = {}; @@ -328,7 +347,7 @@ export function toScopedDiscoveryConfig( * * Candidate paths (in resolution order): * Global (lowest priority): - * /.atomic/agent/extensions/workflow/config.json + * /extensions/workflow/config.json * Project-local (highest priority, first existing wins): * /.atomic/extensions/workflow/config.json * Merge: project-local overrides global. Key-level merge for `workflows` map. @@ -338,12 +357,13 @@ export async function loadWorkflowConfig( opts: LoadWorkflowConfigOpts = {}, ): Promise { const projectRoot = opts.projectRoot ?? process.cwd(); - const home = opts.homeDir ?? homedir(); const diagnostics: ConfigDiagnostic[] = []; - // Global config paths (primary Atomic first, then legacy pi) - const globalCandidates = CONFIG_DIR_NAMES.map((name) => join(home, name, "agent", "extensions", "workflow", "config.json")); + // Global config paths (primary Atomic first, then legacy pi/defaults). + const globalCandidates = workflowAgentDirs(opts).map((agentDir) => + join(agentDir, "extensions", "workflow", "config.json") + ); // Project-local config paths (primary Atomic first, then legacy pi) const projectCandidates: string[] = getProjectConfigPaths(projectRoot, "extensions", "workflow", "config.json"); diff --git a/packages/workflows/src/extension/discovery.ts b/packages/workflows/src/extension/discovery.ts index 0528f469c..2a026c4eb 100644 --- a/packages/workflows/src/extension/discovery.ts +++ b/packages/workflows/src/extension/discovery.ts @@ -11,17 +11,17 @@ * 1. settings-project — paths listed in config.projectWorkflows * 2. project-local — {cwd}/.atomic/workflows/*.{ts,js,mjs,cjs} * 3. settings-global — paths listed in config.globalWorkflows - * 4. user-global — {homeDir}/.atomic/agent/workflows/*.{ts,js,mjs,cjs} + * 4. user-global — {agentDir}/workflows/*.{ts,js,mjs,cjs} * 5. package — workflow files supplied by Atomic/pi packages * 6. bundled — shipped workflows (skipped when includeBundled=false) * * Usage: * // Full discovery (all sources): - * const result = await discoverWorkflows({ cwd: process.cwd(), homeDir: os.homedir() }); + * const result = await discoverWorkflows({ cwd: process.cwd() }); */ import { join } from "node:path"; -import { CONFIG_DIR_NAMES, getProjectConfigPaths } from "@bastani/atomic"; +import { CONFIG_DIR_NAMES, getAgentDirs, getProjectConfigPaths } from "@bastani/atomic"; import type { WorkflowDefinition } from "../shared/types.js"; import { createRegistry } from "../workflows/registry.js"; import type { WorkflowRegistry } from "../workflows/registry.js"; @@ -38,7 +38,7 @@ import { loadFromDir, loadFromPaths, type WorkflowModuleCandidateRecord } from " * * bundled — shipped with the workflows package * project-local — found in {cwd}/.atomic/workflows/ - * user-global — found in {homeDir}/.atomic/agent/workflows/ + * user-global — found in {agentDir}/workflows/ * settings-project — listed in DiscoveryConfig.projectWorkflows * settings-global — listed in DiscoveryConfig.globalWorkflows * package — supplied by Atomic/pi package workflow resources @@ -121,8 +121,10 @@ export interface DiscoveryConfig { export interface DiscoveryOptions { /** Working directory; used as root for project-local discovery. Default: process.cwd() */ cwd: string; - /** User's home directory; used as root for user-global discovery. Default: os.homedir() */ + /** User's home directory; when set, preserves legacy test/compat user-global discovery roots. */ homeDir: string; + /** User agent config directories in precedence order. Defaults to Atomic's configured agent directories. */ + agentDirs?: readonly string[]; /** Optional extra paths from project/global config. */ config?: DiscoveryConfig; /** Workflow files supplied by installed Atomic/pi packages. */ @@ -145,6 +147,15 @@ export interface DiscoveryResult { // Internal helpers // --------------------------------------------------------------------------- +function workflowAgentDirs(options: Partial | undefined): readonly string[] { + if (options?.agentDirs !== undefined) return options.agentDirs; + if (options?.homeDir !== undefined) { + const homeDir = options.homeDir; + return CONFIG_DIR_NAMES.map((name) => join(homeDir, name, "agent")); + } + return getAgentDirs(); +} + /** * Validate DiscoveryConfig shape. * Returns null when valid, or a description of the problem. @@ -273,7 +284,7 @@ function applyBatchShapeOnly( * 1. settings-project — config.projectWorkflows paths * 2. project-local — {cwd}/.atomic/workflows/*.{ts,js,mjs,cjs} * 3. settings-global — config.globalWorkflows paths - * 4. user-global — {homeDir}/.atomic/agent/workflows/*.{ts,js,mjs,cjs} + * 4. user-global — {agentDir}/workflows/*.{ts,js,mjs,cjs} * 5. package — package-supplied workflow files * 6. bundled — shipped workflows (omitted when includeBundled=false) */ @@ -282,6 +293,7 @@ export async function discoverWorkflows( ): Promise { const cwd = options?.cwd ?? process.cwd(); const homeDir = options?.homeDir ?? (await defaultHomeDir()); + const agentDirs = workflowAgentDirs(options); const config = options?.config; const packageWorkflowPaths = options?.packageWorkflowPaths; const includeBundled = options?.includeBundled !== false; @@ -332,8 +344,8 @@ export async function discoverWorkflows( } } - // 4. user-global — canonical Atomic path plus legacy pi path - for (const dir of CONFIG_DIR_NAMES.map((name) => join(homeDir, name, "agent", "workflows")).reverse()) { + // 4. user-global — configured Atomic agent dir plus legacy/defaults when applicable. + for (const dir of agentDirs.map((agentDir) => join(agentDir, "workflows")).reverse()) { const candidates = await loadFromDir(dir, "user-global", diagnostics); registry = await applyBatch(candidates, registry, sources, diagnostics); } diff --git a/packages/workflows/src/extension/extension-runtime-state.ts b/packages/workflows/src/extension/extension-runtime-state.ts index 890c311ef..e73cbf862 100644 --- a/packages/workflows/src/extension/extension-runtime-state.ts +++ b/packages/workflows/src/extension/extension-runtime-state.ts @@ -201,11 +201,10 @@ export function createWorkflowExtensionRuntimeState( } const configResult = await loadWorkflowConfig(); configLoadRef.current = configResult; - const { homedir } = await import("node:os"); const hasGlobal = configResult.globalConfig != null; const hasProject = configResult.projectConfig != null; const discoveryConfig = hasGlobal || hasProject - ? toScopedDiscoveryConfig(configResult.globalConfig ?? null, configResult.projectConfig ?? null, { projectRoot: process.cwd(), homeDir: homedir() }) + ? toScopedDiscoveryConfig(configResult.globalConfig ?? null, configResult.projectConfig ?? null, { projectRoot: process.cwd() }) : undefined; const result = await discoverWorkflows({ config: discoveryConfig, packageWorkflowPaths: await loadPackageWorkflowPaths() }); discoveryRef.current = result; diff --git a/packages/workflows/src/tui/dispatch-confirm.ts b/packages/workflows/src/tui/dispatch-confirm.ts index 4e51bf815..836859570 100644 --- a/packages/workflows/src/tui/dispatch-confirm.ts +++ b/packages/workflows/src/tui/dispatch-confirm.ts @@ -7,7 +7,8 @@ * - One status-coloured rounded run card: * title: runId8 · workflowName · ● running * body: compact `k=v · k=v · +N more` input summary when present - * - One hint row: ▸ /workflow connect attach & watch + * - One compact next-step hint: + * ▸ /workflow connect watch, attach & steer * * What we deliberately do NOT emit (was in the legacy 7-row layout): * - the `✓ submitted · /workflow ` echo line — pi already shows @@ -19,9 +20,9 @@ * an 8-char hex string visually communicates "identifier"; * - the `status starting…` body row — the `● running` badge on row 1 * occupies the same semantic slot; - * - the second hint row `▸ /workflow status` — that is a separate - * intent (list other in-flight runs), already discoverable from a - * bare `/workflow` invocation and the picker's confirm panel. + * - the second hint row `▸ /workflow status` — that is a separate intent + * (list other in-flight runs), already discoverable from a bare `/workflow` + * invocation and the picker's confirm panel. * * Plain mode drops ANSI; the rounded panel/card layout shape is preserved. * @@ -70,8 +71,7 @@ export interface RenderDispatchConfirmOpts { /** * Render the post-dispatch confirmation: one rounded panel containing a * rounded run card with runId, workflow name, inputs summary, and a - * `● running` status badge, followed by one hint row pointing at - * `/workflow connect `. + * `● running` status badge, followed by a workflow-control hint. */ export function renderDispatchConfirm(opts: RenderDispatchConfirmOpts): string { const width = effectiveWidth(opts.width); @@ -142,10 +142,11 @@ export function renderDispatchConfirm(opts: RenderDispatchConfirmOpts): string { : [` ${titleSuffix ?? "started in background"} `]; const titleLine = ` ● ${tag} ${opts.workflowName} ${trailing.text} `; - const hints = renderHintRows( - [{ command: `/workflow connect ${tag}`, hint: "attach & watch" }], - theme, - ).split("\n").map((line) => ` ${line} `); + const hints = renderHintRows([ + { command: `/workflow connect ${tag}`, hint: "watch, attach & steer" }, + ], theme) + .split("\n") + .map((line) => ` ${line} `); return renderRoundedBox({ title: "DISPATCHED", diff --git a/specs/2026-06-20-first-run-onboarding-workflow-routing.md b/specs/2026-06-20-first-run-onboarding-workflow-routing.md new file mode 100644 index 000000000..d2febe1d7 --- /dev/null +++ b/specs/2026-06-20-first-run-onboarding-workflow-routing.md @@ -0,0 +1,258 @@ +# First-Run Onboarding: Normal-Session Workflow Handoff + +- **Date:** 2026-06-20 +- **Status:** Implemented direction after review +- **Area:** `packages/coding-agent` interactive TUI (first-run experience) +- **Pattern:** Capture-and-handoff (capture the user's first work item, hand it to the normal coding-agent session with workflow-routing guidance, and then let the regular agent/workflow paths take over) + +## Summary + +A first-time Atomic user previously landed on a blank chat. The startup screen showed +the banner, version, model, and cwd, then an empty input box with no guidance. On a +*fresh install* even the changelog is suppressed, so nothing communicated the product's +core value: Atomic can run agent loops as workflows. + +This spec replaces that blank slate with a single, opinionated first action. On a true +fresh install, Atomic asks the user to paste a ticket description, GitHub issue, path +to a spec, or task prompt. When the user submits that seed, onboarding does **not** run +a separate internal scope-probe subsystem or launch workflows directly. Instead, it +wraps the seed in a normal-session handoff prompt that tells the coding agent to make a +quick scope-routing pass using Atomic's existing workflow guidance, choose `goal` for +small focused work or `ralph` for larger/cross-cutting work, start the selected +workflow with the original seed, and then continue normally. + +The routing rule is intentionally easy to teach and derives from the same workflow +guidance Atomic already injects into the agent system prompt +(`packages/workflows/src/extension/workflow-prompts.ts`): + +- Prefer `goal` for small fixes / quick fixes. +- Prefer `ralph` for non-trivial tasks, especially work estimated at **over ~2k lines + of changed implementation/test/docs code**. +- Use estimated changed lines and the number of unique files/touched areas as scoping + signals. + +Onboarding itself only owns the first-run CTA, `/chat` escape hatch, seed capture, +pre-login in-memory stashing, and handoff to the regular session. The selected +workflow or regular agent path owns research, execution, validation, review, status, +and completion reporting. + +## Goals + +- Replace the fresh-install blank slate with a clear value statement and one action. +- Teach that Atomic can run observable workflows, not just chat. +- Let first-time users paste real work immediately without learning workflow syntax. +- Hand the first ready ticket/spec/task to the normal coding-agent session with clear + `goal` vs. `ralph` routing guidance. +- Preserve ordinary slash-command behavior during onboarding. +- Provide `/chat` as an explicit escape hatch for users who want a normal chat first. +- Avoid a separate onboarding-only probe, workflow launcher, completion UI, or auth + subsystem. + +## Non-goals + +- No internal onboarding scope probe, model-backed assessment, or durable research + artifact. +- No direct workflow launch from onboarding; the normal agent/session flow receives the + handoff prompt and decides what to do next. +- No full implementation planning in the onboarding layer. +- No clarifying questions in the onboarding layer; the normal agent/workflow can ask + questions when needed. +- No auth handling beyond the CTA reminder and in-memory stashing of a seed until the + configured model is ready for handoff. +- No completion UI in onboarding; workflows already report progress and completion. +- No auto-mounting of the workflow graph overlay. +- No new "loops for all" tagline or branding copy. + +## Background: current behavior anchors + +- Startup identity (banner + version + provider/model + cwd): + `packages/coding-agent/src/modes/interactive/interactive-mode.ts`. +- Fresh-install detection and startup state: `interactive-startup.ts`. +- First-run onboarding copy, path seed helpers, seed stashing, and handoff prompt: + `packages/coding-agent/src/modes/interactive/interactive-onboarding.ts`. +- Input handling for `/chat`, slash commands, absolute path seeds, and normal seeds: + `packages/coding-agent/src/modes/interactive/interactive-input-handling.ts`. +- Auth completion resumes a pending in-memory seed: + `packages/coding-agent/src/modes/interactive/interactive-auth-login.ts`. +- Onboarding settings mirror the existing settings accessor pattern: + `settings-manager-basic-accessors.ts` and `settings-types.ts`. +- Workflow launch/status UX already exists in the workflow extension; onboarding should + not duplicate it. + +## UX flow + +### 1. Startup screen (fresh install only) + +```txt + [ ∀ banner ] Atomic v0.x + (anthropic) claude-opus-4.8 · ~/acme/app + + ─────────────────────────────────────────────────────────── + Atomic runs agent loops as workflows you can watch and trust: + implement a ticket, research a codebase, design a UI, or build + your own loop. + + Paste a ticket description, GitHub issue, path to a spec, or task prompt to start. + /chat to chat normally · /atomic for guides + If you have not logged in yet, first run /login. + ─────────────────────────────────────────────────────────── +``` + +The existing Atomic editor remains the only input box. Its placeholder while empty is: +`Paste a ticket, issue, path to a spec, or task prompt…` + +### 2. User either pastes work or chooses normal chat + +While onboarding is active, the first substantive non-slash input is treated as a work +seed. Users who want ordinary chat instead use `/chat`. + +Behavior: + +- `/chat ` removes the CTA, marks onboarding complete, prints the normal-chat + transition copy, then sends `` through the normal chat path. +- `/chat` with no message removes the CTA, marks onboarding complete, prints the same + transition copy, restores the normal editor placeholder, and waits. +- Other slash commands (`/login`, `/model`, `/atomic`, etc.) pass through untouched and + do not mark onboarding complete. +- Existing absolute filesystem paths, including path-like inputs that start with `/`, + are treated as seeds rather than slash commands. Cwd-local path checks use realpath + containment for safety; outside-cwd absolute paths can still be handed off as raw + seed text, but onboarding does not read them. +- Empty/trivial input is ignored. + +Suggested `/chat` transition copy: + +```txt +You're in a normal coding-agent session now. Atomic can chat and edit like other +coding agents, but it also runs loops and workflows. Ask Atomic to build any loop, +or run a built-in workflow like `goal` for small focused changes or `ralph` for +larger, cross-cutting work. Run `/workflow list` to see built-ins, and use `/atomic` +for help running or building your own loops and workflows. +``` + +### 3. Atomic hands the seed to the normal session + +For the first ready seed, Atomic creates a handoff prompt like: + +~~~txt +First-run onboarding handoff: continue as a normal Atomic coding-agent session. + +Original task seed: +```text + +``` + +Perform a quick scope-routing pass before acting. Use the existing Atomic workflow guidance: +choose `goal` for small focused fixes or quick fixes; choose `ralph` for non-trivial, +broad, cross-cutting, or around-2K+-changed-line work. Start the selected workflow with +the original seed, then continue normally in this session. Slash commands should behave +like normal coding-agent slash commands from here on. +~~~ + +The raw seed is fenced with enough backticks to preserve arbitrary user text. Atomic +then sends this prompt through the same normal input callback/pending-input path used +by regular chat, shows `Handing your task to the normal coding-agent session.`, and +marks onboarding complete. + +### 4. Normal agent/workflow behavior takes over + +After handoff, onboarding never speaks again. The normal coding-agent session can run a +quick scope-routing pass, start `goal` or `ralph`, continue in chat, ask clarifying +questions, surface auth/model errors, show workflow status, or connect the workflow +graph using the existing runtime behavior. + +## Onboarding state model + +Atomic distinguishes these states: + +1. **Not onboarded:** `onboardedVersion` is unset. On a true fresh install with no + initial messages, startup records `firstRunOnboardingStartedVersion`, shows the CTA, + sets the onboarding placeholder, and enables first-seed interception. +2. **Onboarding in progress:** The same fresh session is still waiting for either a + substantive seed or `/chat`. Slash commands can pass through without ending + onboarding, so users can run `/login`, `/model`, or `/atomic` and then return to the + CTA. +3. **Pending seed before login/model readiness:** If the first seed arrives before the + selected model is ready for handoff, Atomic stores only the latest seed in memory, + prompts the user to run `/login`, and resumes the handoff after provider + authentication completes. This seed is not persisted to settings or disk. +4. **Already onboarded:** `onboardedVersion` is set. Atomic never shows this first-run + CTA or intercepts the first message again unless a future release intentionally + changes the onboarding version policy. + +The onboarding flag is a product-experience flag, not a changelog flag. It is stored +separately from `lastChangelogVersion`. `firstRunOnboardingStartedVersion` keeps +fresh-install onboarding separate from changelog state and allows copied settings to +re-arm onboarding by deleting the onboarding markers. + +## Technical design + +1. **Settings flags.** Add `firstRunOnboardingStartedVersion` and `onboardedVersion` to + settings with typed accessors. Set `onboardedVersion` only after `/chat` or a + successful handoff to the normal session. +2. **First-run screen.** When onboarding is active, render the value statement + CTA + beneath the startup identity and set the onboarding placeholder. +3. **Input handling.** While onboarding is active: + - `/chat` exits onboarding and optionally sends the trailing message as normal chat. + - Other slash commands pass through and keep onboarding active. + - Existing absolute paths can be submitted as seeds, even when they start with `/`. + - Non-slash substantive input is submitted as a seed. +4. **Readiness and stashing.** Before handoff, check whether the current model appears + ready. If not, stash the latest seed in memory and let the existing login/model UX + run. After provider authentication completes, resume the pending seed if onboarding + is still active and the model is ready. +5. **Handoff prompt.** Build a normal-session prompt that includes the exact raw seed + and the durable `goal`/`ralph` guidance. Use a dynamic Markdown fence so embedded + backticks do not corrupt the seed. +6. **No probe subsystem.** Do not keep dead `OnboardingRoutingAssessment`, + scope-probe, follow-up probe, direct workflow-launch, or probe-only test code. If a + future design wants onboarding-owned routing again, it should be reintroduced as a + deliberate feature with wired production callers. +7. **Standard runtime ownership.** After handoff, normal coding-agent/workflow behavior + owns scope decisions, workflow launch/status/connect UI, auth errors, validation, + and completion reporting. + +## Edge cases + +- **Slash command as first input:** handled normally; onboarding stays available until a + seed is submitted or the user enters normal chat with `/chat`. +- **Logged-out first run:** the CTA says to run `/login`. If a seed is submitted first, + Atomic stores it in memory and resumes after successful provider authentication. + Onboarding does not write the seed to disk or replace normal auth errors. +- **User wants ordinary chat:** `/chat` or `/chat ` marks onboarding complete, + removes the CTA, restores the normal placeholder, and continues normally. +- **Empty/trivial input:** ignored. +- **Handoff failure:** show the error via existing interactive error handling, restore + the seed in the editor, and do not mark onboarding complete. +- **Resumed/non-fresh sessions:** onboarding does not render. If a resume command makes + the current onboarding session ineligible, clear the onboarding UI without setting + `onboardedVersion`. +- **Initial messages:** startup should not activate onboarding for `initialMessage` or + non-empty `initialMessages`, but should record the started marker so a later true + fresh interactive launch can still show the CTA if onboarding was not completed. +- **`NO_COLOR` / narrow terminal:** copy must degrade to plain text and respect the + existing sidebar-collapse width rules. + +## Acceptance criteria + +- On a true fresh install, the startup screen shows the value statement, paste-a-task + CTA, login hint, `/chat` and `/atomic` hints, and onboarding placeholder. +- During onboarding, the first substantive non-slash, non-`/chat` input is treated as a + seed and handed to the normal coding-agent session with `goal`/`ralph` routing + guidance. +- The handoff prompt preserves the original raw seed exactly, including multiline text + and backticks. +- `/chat ` exits onboarding, prints the normal-chat transition copy, and sends + `` through the regular chat path; `/chat` exits onboarding and waits. +- `/atomic`, `/login`, `/model`, and other slash commands entered first are not treated + as tickets and do not dismiss onboarding. +- Existing absolute path seeds can be handed off even when they start with `/`; cwd path + detection remains symlink-safe and does not read outside-cwd files. +- Seeds submitted before login/model readiness are stashed in memory only and resumed + after authentication completes. +- Onboarding renders at most once after completion, guarded by `onboardedVersion`. +- Resumed sessions, non-empty sessions, and initial-message launches do not show the + first-run CTA. +- No dead internal scope-probe/routing-assessment code or probe-only tests remain. +- User-facing docs and changelog describe normal-session handoff, not a separate + onboarding-owned scope probe or direct workflow launch. diff --git a/test/unit/chat-surface.test.ts b/test/unit/chat-surface.test.ts index 378dba798..86a19dda8 100644 --- a/test/unit/chat-surface.test.ts +++ b/test/unit/chat-surface.test.ts @@ -230,7 +230,7 @@ describe("renderHintRows", () => { const theme = deriveGraphTheme({}); const out = renderHintRows( [ - { command: "/workflow connect 0391c9c1", hint: "attach & watch" }, + { command: "/workflow connect 0391c9c1", hint: "watch, attach & steer" }, { command: "/workflow status", hint: "list retained runs" }, ], theme, diff --git a/test/unit/dispatch-confirm-render.test.ts b/test/unit/dispatch-confirm-render.test.ts index d05743e2f..bd1f1f8bf 100644 --- a/test/unit/dispatch-confirm-render.test.ts +++ b/test/unit/dispatch-confirm-render.test.ts @@ -8,12 +8,11 @@ * badge. * - Inputs wrap to a second body row only when row 1's interior cannot * hold them; the body row uses the same overflow rules. - * - One hint row: `▸ /workflow connect attach & watch`. + * - Runs keep one hint row: `▸ /workflow connect watch, attach & steer`. * * Explicitly removed (was in the legacy 7-row layout): the `✓ submitted` * echo, the `[ DISPATCHED ]` band, the `run id` muted caption beside the - * tag, the `status starting…` body row, and the second `▸ /workflow - * status` hint row. + * tag, and the `status starting…` body row. * * cross-ref: src/tui/dispatch-confirm.ts · src/tui/chat-surface.ts * · ui/dispatch-mockup.html @@ -49,8 +48,8 @@ describe("renderDispatchConfirm — themed", () => { assert.match(plain, /prompt="map the codebase"/); assert.match(plain, /max_partitions=4/); - // One hint row, connect-only. - assert.match(plain, /▸ \/workflow connect 0391c9c1\s+attach & watch/); + // Non-onboarding workflows keep one hint row, connect-only. + assert.match(plain, /▸ \/workflow connect 0391c9c1\s+watch, attach & steer/); // Legacy chrome MUST be gone. assert.doesNotMatch(plain, /✓ submitted/); @@ -168,15 +167,16 @@ describe("renderDispatchConfirm — plain", () => { // Inputs present (inline on wide terminal). assert.match(out, /prompt="hello"/); - // One hint, connect-only. - assert.match(out, /▸ \/workflow connect abc12345\s+attach & watch/); + // All workflows keep the dispatch card focused on attach/steer. + assert.match(out, /▸ \/workflow connect abc12345\s+watch, attach & steer/); + assert.doesNotMatch(out, /▸ \/workflow status abc12345/); + assert.doesNotMatch(out, /Ask here anytime for status or to steer this run\./); // Legacy chrome MUST be gone in plain mode too. assert.doesNotMatch(out, /✓ submitted/); assert.doesNotMatch(out, /\[ DISPATCHED \]/); assert.doesNotMatch(out, /\brun id\b/); assert.doesNotMatch(out, /\bstarting…/); - assert.doesNotMatch(out, /▸ \/workflow status/); }); test("zero inputs in plain mode renders just identity row + hint", () => {