Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@
"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: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: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",
"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"
},
"dependencies": {
"@inkjs/ui": "^2.0.0",
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
159 changes: 159 additions & 0 deletions cli/src/build/onboarding/ai-fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* 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).
*/

// Conservative chrome reserve: outer Header + AI title + safety warning +
// the retry/skip Select with up to 2 options + blank lines/margins.
// Sized for the worst case so a small terminal still feels safe.
export const AI_RESULT_CHROME_ROWS = 20

// 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
}

/**
* 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))
}

/**
* Sum of rendered rows for a list of logical lines.
*
* Used by the scrollable viewer to figure out how many padding rows to
* add below the visible content so the frame height stays constant across
* scroll positions (constant height = Ink renders in-place, no scrollback
* growth on every keystroke).
*/
export function totalRenderedRows(lines: string[], terminalCols: number): number {
let total = 0
for (const line of lines)
total += renderedRowsForLine(line, terminalCols)
return total
}

/**
* Pick the slice of `lines` starting at `scrollOffset` that fits within
* `viewportRows` *rendered* rows on a terminal `terminalCols` wide. Returns
* fewer lines than would otherwise be sliced when individual lines wrap.
*
* 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. The user can still scroll past it; without this floor the viewer
* would render an empty body on hostile inputs.
*/
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++) {
const rows = renderedRowsForLine(lines[i], terminalCols)
if (result.length > 0 && rowsUsed + rows > viewportRows)
break
result.push(lines[i])
rowsUsed += rows
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 @@ -55,6 +55,11 @@ export type AndroidOnboardingStep
| 'ci-secrets-failed'
| '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 @@ -222,6 +227,10 @@ export const ANDROID_STEP_PROGRESS: Record<AndroidOnboardingStep, number> = {
'ci-secrets-failed': 88,
'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 @@ -279,6 +288,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