Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 91 additions & 94 deletions bun.lock

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,20 @@
"test:macos-signing": "bun test/test-macos-signing.mjs",
"test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs",
"test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
"test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs",
"test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs"
"test:ai-render-markdown": "bun test/test-ai-render-markdown.mjs",
"test:ai-onboarding-mode": "bun test/test-ai-onboarding-mode.mjs",
"test:ai-fit": "bun test/test-ai-fit.mjs",
"test:platform-layout": "bun test/test-platform-layout.mjs",
"test:frame-fit": "bun test/run-frame-fit.mjs",
"test:onboarding-min-size": "bun test/test-onboarding-min-size.mjs",
"test:min-size-gate": "bun test/test-min-size-gate.mjs",
"test:shell-size-gate": "bun test/test-shell-size-gate.mjs",
"test:build-log-sanitize": "bun test/test-build-log-sanitize.mjs",
"test:build-output-viewport": "bun test/test-build-output-viewport.mjs"
},
"dependencies": {
"@inkjs/ui": "^2.0.0",
Expand Down Expand Up @@ -142,6 +151,7 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@vercel/ncc": "^0.38.4",
"@xterm/headless": "^6.0.0",
"adm-zip": "^0.5.17",
"ci-info": "^4.4.0",
"commander": "^14.0.3",
Expand Down
42 changes: 41 additions & 1 deletion cli/src/ai/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, stat, writeFile } from 'node:fs/promises'
import { getAiPromptPath, getLogCapturePath } from './log-capture'
import { cleanupCapturedJobFiles, getAiPromptPath, getLogCapturePath } from './log-capture'
import { SYSTEM_PROMPT } from './prompt'

export type AnalyzeBehavior = 'show_menu' | 'ask_then_menu' | 'auto_upload' | 'skip'
Expand Down Expand Up @@ -100,3 +100,43 @@ export async function isLogTooBig(jobId: string): Promise<boolean> {
return false
}
}

export interface RunCapgoAiAnalysisInput {
apiHost: string
apikey: string
jobId: string
appId: string
}

// Reads the captured log file for a failed job, then sends it to the Capgo AI
// edge function. Used by callers (e.g. the Ink onboarding TUI) that can't show
// the interactive clack menu in `requestBuildInternal`.
export async function runCapgoAiAnalysis(input: RunCapgoAiAnalysisInput): Promise<PostAnalyzeResult> {
// Check the byte limit before the read so a multi-MB log file doesn't get
// pulled into memory just to be rejected.
if (await isLogTooBig(input.jobId))
return { kind: 'too_big' }

let logs: string
try {
logs = await readFile(getLogCapturePath(input.jobId), 'utf8')
}
catch (err) {
return { kind: 'error', message: err instanceof Error ? err.message : 'log_unavailable' }
}

return postAnalyzeRequest({
apiHost: input.apiHost,
apikey: input.apikey,
jobId: input.jobId,
appId: input.appId,
logs,
})
}

// Best-effort cleanup of captured artifacts for a job. Callers in caller-handled
// mode use this once the user has either viewed the analysis or chosen to skip,
// since `requestBuildInternal` leaves the log file in place for them.
export async function releaseCapturedLogs(jobId: string): Promise<void> {
await cleanupCapturedJobFiles(jobId, { keepAiPromptFile: false })
}
4 changes: 2 additions & 2 deletions cli/src/ai/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { sendEvent } from '../utils.js'

export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload'
export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag'
export type AiAnalysisChoice = 'capgo_ai' | 'local_ai' | 'skip' | 'auto_upload' | 'retry'
export type AiAnalysisTriggeredBy = 'menu' | 'ci_flag' | 'onboarding'
export type AiAnalysisResult = 'success' | 'already_analyzed' | 'too_big' | 'error'

export interface TrackAiAnalysisChoiceInput {
Expand Down
196 changes: 196 additions & 0 deletions cli/src/build/onboarding/ai-fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Fit estimation for the AI analysis result step in the onboarding TUI.
*
* The on-failure AI flow can return a multi-screen markdown diagnosis. If
* that text doesn't fit in the user's current terminal viewport we MUST
* route it through the scrollable `FullscreenAiViewer` — otherwise the
* earlier lines scroll out of view and the onboarding wizard ends up in
* an unreadable state.
*
* The estimator deliberately errs on the side of "doesn't fit": a
* false-positive scroll is fine (just one more keystroke for the user),
* but a false-negative inline render is bad UX (text disappears off the
* top of the screen).
*/

// Rows the inline ai-analysis-result frame spends on chrome AROUND the
// analysis text: compact Header + outer padding + "AI analysis" title + the
// "AI can make mistakes" caution + the retry/skip Select. We route to the
// fullscreen scroll viewer only when the analysis won't fit inline even after
// the frame collapses to its dense (compact) form — so the inline path shows
// the WHOLE analysis whenever the terminal has room. 20 was far too
// conservative: it scrolled even on tall terminals where everything fit. The
// dense + too-small safety net catches anything that still overflows once
// rendered inline, so a tight reserve here is safe.
export const AI_RESULT_CHROME_ROWS = 10

// ESC sequence used by `renderMarkdown` and `kleur`/`chalk` to color text.
// The escape byte (0x1B) lives in a private-use region so the regex below
// is exact even for input that includes literal '[' or 'm' bytes.
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*m/g

/** Strip ANSI SGR escape codes so length matches what the user actually sees. */
export function stripAnsi(text: string): string {
return text.replace(ANSI_RE, '')
}

/**
* Estimate how many terminal rows a multi-line, possibly ANSI-styled string
* will occupy when rendered by Ink at the given column width.
*
* Each logical line (split on '\n') becomes `ceil(visibleLen / cols)` rows,
* with a floor of 1 to account for empty lines that still consume a row.
*/
export function estimateRenderedRows(text: string, terminalCols: number): number {
if (!text)
return 0
const cols = Math.max(1, Math.floor(terminalCols))
const lines = text.split('\n')
let total = 0
for (const line of lines) {
const visibleLen = stripAnsi(line).length
total += Math.max(1, Math.ceil(visibleLen / cols))
}
return total
}

/**
* Decide whether the AI analysis text should be routed through the
* scrollable fullscreen viewer. Conservative — prefers true (scroll) when
* the estimate is close to the available row budget.
*
* @param text The AI analysis markdown (already rendered to ANSI).
* @param terminalRows Total terminal rows from `useStdout().stdout?.rows`.
* @param terminalCols Total terminal cols from `useStdout().stdout?.columns`.
* @param chromeRows Reserved rows for the surrounding wizard chrome.
* Defaults to `AI_RESULT_CHROME_ROWS`.
*/
export function isAiAnalysisTooTall(
text: string,
terminalRows: number,
terminalCols: number,
chromeRows: number = AI_RESULT_CHROME_ROWS,
): boolean {
if (!text)
return false
const availableRows = Math.max(1, terminalRows - chromeRows)
const estimated = estimateRenderedRows(text, terminalCols)
return estimated > availableRows
}

// The two AI-analysis-result steps. Both wizards (iOS + Android) use these same
// literal step names, so the routing decision below is platform-agnostic.
export type AiResultStep = 'ai-analysis-result' | 'ai-analysis-result-scroll'

/**
* Decide which AI-result step should be active for the CURRENT terminal size.
*
* Routing is BIDIRECTIONAL and driven by the single `isAiAnalysisTooTall`
* predicate, so it settles deterministically at any size — at a given size
* exactly one outcome is stable, so it can't oscillate:
* - inline + now too tall (terminal shrank) → scroll
* - scroll + now fits (terminal grew) → inline ← the missing case
*
* Before, only the inline→scroll direction existed: once the viewer opened
* (e.g. after shrinking), growing the terminal never returned to the inline
* render — the user was stuck in the scroll viewer showing "all N lines" with
* empty space.
*
* `viewedFull` (the user manually dismissed the viewer with Esc/Enter) pins the
* inline step so a later resize can't shove a dismissed analysis back into the
* viewer. It only gates the inline→scroll direction; leaving the viewer when it
* fits is always allowed.
*
* @returns the step to switch to, or `null` when the current step is already
* correct (so the caller can skip a no-op `setStep`).
*/
export function resolveAiResultRoute(params: {
current: AiResultStep
text: string | null
viewedFull: boolean
terminalRows: number
terminalCols: number
}): AiResultStep | null {
const { current, text, viewedFull, terminalRows, terminalCols } = params
if (!text)
return null
const tooTall = isAiAnalysisTooTall(text, terminalRows, terminalCols)
if (current === 'ai-analysis-result' && tooTall && !viewedFull)
return 'ai-analysis-result-scroll'
if (current === 'ai-analysis-result-scroll' && !tooTall)
return 'ai-analysis-result'
return null
}

/**
* Wrap-aware rendered-row count for a single logical line.
* Treats blank/empty lines as one row (Ink still occupies a row for them).
*/
function renderedRowsForLine(line: string, terminalCols: number): number {
const cols = Math.max(1, Math.floor(terminalCols))
const visibleLen = stripAnsi(line).length
return Math.max(1, Math.ceil(visibleLen / cols))
}

/**
* Pick the slice of `lines` starting at `scrollOffset` that PACKS the
* `viewportRows` rendered rows of a terminal `terminalCols` wide.
*
* Packs lines until the cumulative wrapped row count reaches or exceeds
* `viewportRows`, INCLUDING the line that crosses the boundary. That last line
* may render past the viewport; the viewer clips it with `overflow: hidden` so
* the visible area is always FULL of text when more lines remain. (Stopping
* before the boundary line — the old behaviour — left the unused rows as an
* empty gap when a long line couldn't fully fit.)
*
* Always returns at least one line if the input is non-empty and the
* `scrollOffset` is in-range — even if that line wraps to more rows than the
* viewport.
*/
export function pickVisibleLines(
lines: string[],
scrollOffset: number,
viewportRows: number,
terminalCols: number,
): string[] {
if (lines.length === 0 || scrollOffset >= lines.length)
return []
const result: string[] = []
let rowsUsed = 0
for (let i = scrollOffset; i < lines.length; i++) {
result.push(lines[i])
rowsUsed += renderedRowsForLine(lines[i], terminalCols)
if (rowsUsed >= viewportRows)
break
}
return result
}

/**
* Compute the largest `scrollOffset` that still keeps content visible at the
* bottom of the viewport — i.e. the offset where the LAST line is rendered
* within the viewport. Walks backwards from the end, packing as many tail
* lines as fit (accounting for wrap), and returns the offset of the first
* fully-visible tail line.
*/
export function computeMaxScrollOffset(
lines: string[],
viewportRows: number,
terminalCols: number,
): number {
if (lines.length === 0)
return 0
let rowsUsed = 0
let kFromEnd = 0
for (let i = lines.length - 1; i >= 0; i--) {
const rows = renderedRowsForLine(lines[i], terminalCols)
if (kFromEnd > 0 && rowsUsed + rows > viewportRows)
break
rowsUsed += rows
kFromEnd += 1
if (rowsUsed >= viewportRows)
break
}
return Math.max(0, lines.length - kFromEnd)
}
14 changes: 14 additions & 0 deletions cli/src/build/onboarding/android/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export type AndroidOnboardingStep
| 'writing-workflow-file'
| 'ask-build'
| 'requesting-build'
// AI debug — only entered when the build fails and logs were captured
| 'ai-analysis-prompt'
| 'ai-analysis-running'
| 'ai-analysis-result'
| 'ai-analysis-result-scroll'
| 'build-complete'
| 'error'

Expand Down Expand Up @@ -254,6 +259,10 @@ export const ANDROID_STEP_PROGRESS: Record<AndroidOnboardingStep, number> = {
'writing-workflow-file': 98,
'ask-build': 90,
'requesting-build': 95,
'ai-analysis-prompt': 96,
'ai-analysis-running': 98,
'ai-analysis-result-scroll': 98,
'ai-analysis-result': 99,
'build-complete': 100,
'error': 0,
}
Expand Down Expand Up @@ -323,6 +332,11 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string {
case 'ask-build':
case 'requesting-build':
return 'Step 4 of 4 · Save & Build'
case 'ai-analysis-prompt':
case 'ai-analysis-running':
case 'ai-analysis-result':
case 'ai-analysis-result-scroll':
return 'AI debug'
case 'build-complete':
return 'Complete'
case 'error':
Expand Down
Loading
Loading