fix(cli): improve onboarding and terminal responsiveness (#4518)

This commit is contained in:
notowen333
2026-09-22 16:07:08 -04:00
committed by GitHub
parent 880c5bd412
commit 2db4074317
29 changed files with 591 additions and 223 deletions
+3
View File
@@ -10,6 +10,9 @@
"workspaces": [
"strands-ts"
],
"bin": {
"strands-dev": "strands-cli/bin/strands.js"
},
"devDependencies": {
"husky": "^9.1.7",
"prettier": "^3.9.4"
+4
View File
@@ -2,6 +2,9 @@
"name": "strands",
"version": "0.0.0",
"private": true,
"bin": {
"strands-dev": "strands-cli/bin/strands.js"
},
"workspaces": [
"strands-ts"
],
@@ -12,6 +15,7 @@
"scripts": {
"prepare": "husky",
"build": "npm run build -w strands-ts",
"setup": "node strands-cli/scripts/setup-source.js",
"test": "npm run test -w strands-ts",
"test:coverage": "npm run test:coverage -w strands-ts",
"test:all": "npm run test:all -w strands-ts",
+3 -3
View File
@@ -32,11 +32,11 @@ strands-cli/
## Development
Install from the repo root so the workspace links `@strands-agents/harness`:
Install from the repo root:
```bash
npm run setup # install and link the source-aware `strands` command
strands # builds on first run and whenever source changes
npm run setup # install and link the source-aware `strands-dev` command
strands-dev # builds on first run and whenever source changes
```
Run the checks (from `strands-cli/`, or via `-w strands-cli` from the root):
+6 -6
View File
@@ -71,19 +71,19 @@ Run from the repository root with:
```bash
npm run setup
strands
strands-dev
```
`npm run setup` installs the workspace and links the `strands` command to this checkout. The linked
launcher fingerprints `harness-ts` and `strands-cli`, so switching branches or editing source causes one
silent rebuild before the CLI starts:
`npm run setup` installs the CLI dependencies and links the `strands-dev` command to this checkout. The
linked launcher fingerprints the CLI source, so switching branches or editing it causes one silent rebuild
before the CLI starts:
```bash
git switch main
strands
strands-dev
git switch another-branch
strands
strands-dev
```
If a branch changes dependencies, run `npm run setup` again. A packaged npm installation contains
+8 -14
View File
@@ -21,17 +21,15 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const BUILD_INPUT_DIRECTORIES = ['strands-cli/src', 'harness-ts/src', 'harness-py/src']
const BUILD_INPUT_DIRECTORIES = ['strands-cli/src', 'harness-py/src']
const BUILD_INPUT_FILES = [
'package.json',
'package-lock.json',
'strands-cli/package.json',
'strands-cli/scripts/bundle-python.js',
'strands-cli/scripts/copy-python-runtime.js',
'strands-cli/scripts/setup-source.js',
'strands-cli/tsconfig.base.json',
'strands-cli/src/tsconfig.json',
'harness-ts/package.json',
'harness-ts/tsconfig.base.json',
'harness-ts/src/tsconfig.json',
'harness-py/pyproject.toml',
'harness-py/README.md',
'harness-py/LICENSE',
@@ -41,10 +39,7 @@ const BUILD_LOCK_TIMEOUT_MS = 120_000
const BUILD_LOCK_STALE_MS = 300_000
export function isSourceCheckout(packageRoot = PACKAGE_ROOT) {
return (
existsSync(join(packageRoot, 'src', 'main.ts')) &&
existsSync(join(packageRoot, '..', 'harness-ts', 'src', 'index.ts'))
)
return existsSync(join(packageRoot, 'src', 'main.ts'))
}
export function sourceBuildFingerprint(repositoryRoot) {
@@ -109,7 +104,6 @@ export async function ensureSourceBuild(packageRoot = PACKAGE_ROOT) {
}
for (let attempt = 0; attempt < 3; attempt++) {
rmSync(join(repositoryRoot, 'harness-ts', 'dist'), { recursive: true, force: true })
rmSync(join(repositoryRoot, 'strands-cli', 'dist'), { recursive: true, force: true })
runSourceBuild(repositoryRoot)
@@ -119,9 +113,7 @@ export async function ensureSourceBuild(packageRoot = PACKAGE_ROOT) {
throw new Error(`Build completed without creating ${entrypoint}.`)
}
const paths = []
for (const directory of ['harness-ts/dist', 'strands-cli/dist']) {
collectFiles(join(repositoryRoot, directory), paths)
}
collectFiles(join(repositoryRoot, 'strands-cli', 'dist'), paths)
const outputs = paths.map((path) => relative(repositoryRoot, path))
const temporary = `${stateFile}.${process.pid}.tmp`
writeFileSync(temporary, `${JSON.stringify({ fingerprint: completedFingerprint, outputs })}\n`)
@@ -165,7 +157,9 @@ function collectFiles(directory, paths) {
function runSourceBuild(repositoryRoot) {
const windows = process.platform === 'win32'
const command = windows ? (process.env.ComSpec ?? 'cmd.exe') : 'npm'
const args = windows ? ['/d', '/s', '/c', 'npm run build'] : ['run', 'build']
const args = windows
? ['/d', '/s', '/c', 'npm run build --prefix strands-cli']
: ['run', 'build', '--prefix', 'strands-cli']
const result = spawnSync(command, args, {
cwd: repositoryRoot,
encoding: 'utf8',
+22
View File
@@ -0,0 +1,22 @@
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const packageRoot = resolve(repositoryRoot, 'strands-cli')
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
run(packageRoot, ['ci'])
run(packageRoot, ['run', 'build'])
run(repositoryRoot, ['link', '--ignore-scripts', '--no-save', '--package-lock=false'])
function run(cwd, args) {
const result = spawnSync(npm, args, { cwd, stdio: 'inherit' })
if (result.status === 0) {
return
}
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
}
+22 -3
View File
@@ -35,6 +35,8 @@ export interface ProviderModelDiscovery {
available: boolean
complete?: boolean
knownModelIds?: readonly string[]
error?: string
credentialRejected?: boolean
}
export interface ProviderModel {
@@ -119,8 +121,19 @@ export async function discoverProviderModels(
}
const models = await listProviderModels(provider, environment, ollama)
return { models: cleanModels(models), available: true }
} catch {
return { models: [], available: false }
} catch (error) {
const credentialRejected =
['anthropic', 'openai', 'google'].includes(provider) &&
error instanceof ModelCatalogRequestError &&
[400, 401, 403].includes(error.status)
return {
models: [],
available: false,
error: credentialRejected
? `API key was rejected (HTTP ${error.status})`
: sanitizeTerminalText(error instanceof Error ? error.message : String(error)),
...(credentialRejected ? { credentialRejected: true } : {}),
}
}
}
@@ -334,7 +347,7 @@ async function fetchResponse(url: string, headers: Record<string, string>): Prom
async function fetchJson(url: string, headers: Record<string, string> = {}): Promise<Record<string, unknown>> {
const response = await fetchResponse(url, headers)
if (!response.ok) {
throw new Error(`Model catalog request failed with status ${response.status}`)
throw new ModelCatalogRequestError(response.status)
}
const value = (await response.json()) as unknown
if (!isRecord(value)) {
@@ -343,6 +356,12 @@ async function fetchJson(url: string, headers: Record<string, string> = {}): Pro
return value
}
class ModelCatalogRequestError extends Error {
constructor(readonly status: number) {
super(`Model catalog request failed with status ${status}`)
}
}
function requiredEnvironmentValue(
environment: DetectedProviderEnvironment,
key: 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY' | 'GEMINI_API_KEY'
+2
View File
@@ -229,6 +229,8 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
stderr: errorOutput,
exitOnCtrlC: false,
patchConsole: true,
// Full-frame repaints flash on terminals that ignore or lack synchronized output.
incrementalRendering: true,
maxFps: 30,
})
const setupExit = await setupTask
+1
View File
@@ -123,6 +123,7 @@ export function ChatRoot({
<ThemeProvider settings={settings}>
<DnaVortexIntro
ready={setup || controller !== undefined}
setup={setup}
theme={settings.frogTheme}
customBase={settings.customTheme.base}
onComplete={finishIntro}
+64 -35
View File
@@ -63,9 +63,21 @@ const GLYPHS = {
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
} as const
const SMALL_GLYPHS = {
A: ['▄▀█', '█▀█'],
D: ['█▀▄', '█▄▀'],
N: ['█▄ █', '█ ▀█'],
R: ['█▀█', '█▀▄'],
S: ['█▀▀', '▀▀█'],
T: ['▀█▀', ' █ '],
} as const
const BRAND_WORD = wordShape('STRANDS')
const SMALL_BRAND_WORD = wordShape('STRANDS', SMALL_GLYPHS)
const BRAND_WORD_HEIGHT = GLYPHS.S.length
export const FROG_BRAND_EASTER_EGG_DURATION_MS = 3_200
export const FROG_FULL_LOCKUP_MIN_WIDTH = BRAND_WORD.width + 26
interface WordPoint extends Point {
character: string
@@ -106,14 +118,18 @@ export function renderFrogSpiralFrame(
elapsedMs: number,
color = false,
theme: FrogTheme = 'green',
options: FrogRenderOptions = {}
options: FrogRenderOptions = {},
artHeight = frogStartupHeight(Math.max(1, width), Math.max(1, height) - 6),
lockupLeft = 0
): string {
const canvas = new Canvas(Math.max(1, width), Math.max(1, height), theme, false, 0, options)
const artHeight = frogStartupHeight(canvas.width, canvas.height - 6)
const top = artHeight >= 12 ? 2 : 1
const top = artHeight > 2 ? 2 : 1
const value = clamp(progress)
if (artHeight < 12 || value >= 1) {
const artwork = renderFrogStartupLockup(canvas.width, color, 0, theme, false, options, artHeight).split('\n')
const indent = ' '.repeat(lockupLeft)
const artwork = renderFrogStartupLockup(canvas.width - lockupLeft, color, 0, theme, false, options, artHeight)
.split('\n')
.map((row) => indent + row)
return canvas
.rows(color)
.map((row, index) => artwork[index - top] ?? row)
@@ -125,7 +141,7 @@ export function renderFrogSpiralFrame(
return canvas.rows(color).join('\n')
}
const local = (value - 0.23) / 0.77
drawDanglingLetterAct(canvas, scene, local, elapsedMs)
drawDanglingLetterAct(canvas, scene, local, elapsedMs, lockupLeft)
const wide = canvas.width >= BRAND_WORD.width + 26
const shift = Math.round((scene.wordY - (wide ? 7 : 9)) * smoothStep((local - 0.51) / 0.17))
const rows = canvas.rows(color)
@@ -142,8 +158,8 @@ export function renderFrogStartupLockup(
height = lockupLayout(width).height
): string {
const canvas = new Canvas(Math.max(1, width), Math.max(1, height), theme, party, elapsedMs, options)
if (canvas.width < 74 || canvas.height < lockupLayout(canvas.width).height) {
drawCompactLockup(canvas, elapsedMs)
if (canvas.width < FROG_FULL_LOCKUP_MIN_WIDTH || canvas.height < lockupLayout(canvas.width).height) {
drawCompactLockup(canvas)
return canvas.rows(color).join('\n')
}
drawFinalLockup(canvas, elapsedMs)
@@ -199,9 +215,9 @@ export function renderSetupGuideTransitionFrame(
const layout = lockupLayout(canvas.width)
const landing = { x: landingX, y: landingY ?? canvas.height * 2 - 12 }
if (canvas.width < 74 || canvas.height < 12) {
if (canvas.width < FROG_FULL_LOCKUP_MIN_WIDTH || canvas.height < 12) {
if (value < 0.16) {
drawCompactLockup(canvas, elapsedMs)
drawCompactLockup(canvas)
return canvas.rows(color).join('\n')
}
} else {
@@ -263,8 +279,8 @@ export function renderFrogBrandEasterEggFrame(
height = lockupLayout(width).height
): string {
const canvas = new Canvas(Math.max(1, width), Math.max(1, height), theme, party, partyElapsedMs, options)
if (canvas.width < 74 || canvas.height < lockupLayout(canvas.width).height) {
drawCompactLockup(canvas, elapsedMs)
if (canvas.width < FROG_FULL_LOCKUP_MIN_WIDTH || canvas.height < lockupLayout(canvas.width).height) {
drawCompactLockup(canvas)
return canvas.rows(color).join('\n')
}
drawFrogBrandEasterEgg(canvas, clamp(progress), elapsedMs)
@@ -279,16 +295,26 @@ export interface FrogStartupHitbox {
}
export function frogStartupHeight(width: number, availableHeight: number): number {
if (width >= 74 && availableHeight >= 26) {
if (width >= FROG_FULL_LOCKUP_MIN_WIDTH && availableHeight >= 26) {
return lockupLayout(width).height
}
return width >= 26 && availableHeight >= 8 ? 4 : 1
if (width >= BRAND_WORD.width && availableHeight >= wordOnlyHeight(width) + 8) {
return wordOnlyHeight(width)
}
return width >= SMALL_BRAND_WORD.width && availableHeight >= 8 ? 2 : 1
}
export function frogStartupHitbox(width: number, height = lockupLayout(width).height): FrogStartupHitbox {
const canvasWidth = Math.max(1, width)
if (canvasWidth < 74 || height < lockupLayout(canvasWidth).height) {
return { left: Math.max(0, Math.floor((canvasWidth - 24) / 2)), top: 0, width: Math.min(8, canvasWidth), height }
if (
canvasWidth >= BRAND_WORD.width &&
height >= wordOnlyHeight(canvasWidth) &&
height < lockupLayout(canvasWidth).height
) {
return { left: 0, top: lockupLayout(canvasWidth).wordY, width: BRAND_WORD.width, height: BRAND_WORD_HEIGHT }
}
if (canvasWidth < FROG_FULL_LOCKUP_MIN_WIDTH || height < lockupLayout(canvasWidth).height) {
return { left: 0, top: 0, width: Math.min(SMALL_BRAND_WORD.width, canvasWidth), height }
}
const { frogX, frogY } = lockupLayout(canvasWidth)
const left = Math.max(0, Math.floor(frogX - 7))
@@ -505,13 +531,13 @@ function drawSpiralAct(canvas: Canvas, scene: Scene, progress: number, elapsedMs
}
}
function drawDanglingLetterAct(canvas: Canvas, scene: Scene, time: number, elapsedMs: number): void {
function drawDanglingLetterAct(canvas: Canvas, scene: Scene, time: number, elapsedMs: number, left: number): void {
const wide = canvas.width >= BRAND_WORD.width + 26
const wordX = wide ? Math.floor((canvas.width - BRAND_WORD.width - 26) / 2) + 26 : scene.sourceX
const layout = lockupLayout(canvas.width - left)
const wordX = left + layout.wordX
const frogX = left + layout.frogX
const restingWordY = scene.wordY - 3
const landing = wide
? { x: wordX - 15.5, y: (restingWordY + 3) * 2, scale: 1 }
: { x: scene.sourceX + 7.5, y: (restingWordY + 8) * 2, scale: 1 }
const landing = { x: frogX, y: (restingWordY + (wide ? 3 : 8)) * 2, scale: 1 }
const letters = [5, 2, 0]
const impacts = letters.map((_, index) => (index + 1) * 0.17 - 0.015)
const presses = impacts.map((impact) => {
@@ -581,20 +607,18 @@ function drawFinalLockup(canvas: Canvas, elapsedMs: number): void {
drawFrog(canvas, { x: layout.frogX, y: layout.frogY, scale: 1 }, FROG_POSES.settled, elapsedMs)
}
function drawCompactLockup(canvas: Canvas, elapsedMs: number): void {
if (canvas.width < 26 || canvas.height < 4) {
const title = 'STRANDS'.slice(0, canvas.width)
const left = Math.floor((canvas.width - title.length) / 2)
for (const [index, character] of [...title].entries()) {
canvas.set(left + index, 0, character, 'green', 68)
function drawCompactLockup(canvas: Canvas): void {
if (canvas.width >= BRAND_WORD.width && canvas.height >= wordOnlyHeight(canvas.width)) {
drawSolidWord(canvas, BRAND_WORD, 0, lockupLayout(canvas.width).wordY, 68)
return
}
if (canvas.width < SMALL_BRAND_WORD.width || canvas.height < 2) {
for (const [index, character] of [...'STRANDS'.slice(0, canvas.width)].entries()) {
canvas.set(index, 0, character, 'green', 68)
}
return
}
const left = Math.floor((canvas.width - 24) / 2)
drawFrog(canvas, { x: left + 4, y: 4, scale: 0.34 }, FROG_POSES.settled, elapsedMs)
for (const [index, character] of [...'STRANDS'].entries()) {
canvas.set(left + 10 + index, 1, character, 'green', 68)
}
drawSolidWord(canvas, SMALL_BRAND_WORD, 0, 0, 68)
}
function drawFrogBrandEasterEgg(canvas: Canvas, progress: number, elapsedMs: number): void {
@@ -737,11 +761,16 @@ function drawDissolveFront(canvas: Canvas, scene: Scene, wipeX: number, seconds:
}
}
/** The full lockup cropped below the wordmark, so dropping the frog keeps the word on the same rows. */
function wordOnlyHeight(width: number): number {
return lockupLayout(width).wordY + BRAND_WORD_HEIGHT
}
function lockupLayout(width: number): { frogX: number; frogY: number; wordX: number; wordY: number; height: number } {
const wide = width >= BRAND_WORD.width + 26
const wordX = Math.floor((width - BRAND_WORD.width - (wide ? 26 : 0)) / 2) + (wide ? 26 : 0)
const wordX = wide ? 26 : 0
return {
frogX: wide ? wordX - 15.5 : wordX + 7.5,
frogX: wide ? wordX - 15.5 : 10.5,
frogY: wide ? 10 : 24,
wordX,
wordY: wide ? 2 : 0,
@@ -843,11 +872,11 @@ function spiralPresence(x: number, wipeX: number, seed: number): number {
return clamp(distance / 6 + (hash01(seed + 97) - 0.5) * 0.28)
}
function wordShape(word: string): WordShape {
function wordShape(word: string, glyphs: Record<string, readonly string[]> = GLYPHS): WordShape {
const points: WordPoint[] = []
let offset = 0
for (const [letterIndex, rawLetter] of [...word].entries()) {
const glyph = GLYPHS[rawLetter as keyof typeof GLYPHS]
const glyph = glyphs[rawLetter]!
for (let localY = 0; localY < glyph.length; localY++) {
const row = glyph[localY]!
for (let localX = 0; localX < row.length; localX++) {
+8 -1
View File
@@ -3,6 +3,7 @@ import { Box, useWindowSize } from 'ink'
import type { FrogTheme } from '../chat/types.js'
import { FROG_INTRO_DURATION_MS, renderFrogSpiralFrame } from './frog-intro-renderer.js'
import { setupBrandFrame } from './setup-wizard/brand.js'
import { Text, useTheme } from './theme.js'
const HOLD_MS = 250
@@ -12,9 +13,12 @@ export function DnaVortexIntro({
ready = true,
theme = 'green',
customBase = 'green',
setup = false,
}: {
onComplete(exitCode: 0 | 130): void
ready?: boolean
/** Ends on the setup wizard's brand frame so the handoff does not move or resize the lockup. */
setup?: boolean
theme?: FrogTheme
customBase?: Exclude<FrogTheme, 'custom'>
}): ReactElement {
@@ -41,6 +45,7 @@ export function DnaVortexIntro({
return (): void => clearInterval(timer)
}, [onComplete, fps, ready])
const brandFrame = setup ? setupBrandFrame(terminalWidth - 2, terminalHeight) : undefined
const artwork = renderFrogSpiralFrame(
terminalWidth - 2,
terminalHeight,
@@ -48,7 +53,9 @@ export function DnaVortexIntro({
elapsedMs,
true,
theme,
{ colorMode: colors.mode, customBase, ...(theme === 'custom' ? { frogColor: colors.frog } : {}) }
{ colorMode: colors.mode, customBase, ...(theme === 'custom' ? { frogColor: colors.frog } : {}) },
brandFrame?.height,
brandFrame?.left
)
return (
+1 -1
View File
@@ -190,7 +190,7 @@ function normalizeDetailText(value: string): string {
export function formatContext(context: ChatSnapshot['context'], width = 10): string {
const used = context.projectedTokens ?? context.currentTokens
if (used === undefined || !context.contextWindow) {
return `${'░'.repeat(width)} --`
return `${'░'.repeat(width)} 0%`
}
const percentage = (used / context.contextWindow) * 100
const filled = used > 0 ? Math.max(1, Math.round(Math.min(1, used / context.contextWindow) * width)) : 0
@@ -3,23 +3,39 @@ import { Box, type DOMElement } from 'ink'
import {
FROG_BRAND_EASTER_EGG_DURATION_MS,
frogStartupHeight,
frogStartupHitbox,
renderFrogBrandEasterEggFrame,
renderFrogStartupLockup,
} from '../frog-intro-renderer.js'
import { Text, useTheme } from '../theme.js'
import { openingGridLayout } from './opening-menu.js'
import type { AppearanceSettings } from './types.js'
// Sized for the tallest step so the brand never resizes between steps.
const SETUP_CONTENT_MIN_HEIGHT = 24
/** Starts the lockup on the opening grid's left edge so the brand lines up with the choices. */
export function setupBrandFrame(
width: number,
terminalHeight: number
): { left: number; width: number; height: number } {
const left = openingGridLayout(width).left
const brandWidth = Math.max(1, width - left)
const availableHeight = Math.max(1, terminalHeight - SETUP_CONTENT_MIN_HEIGHT)
const fullHeight = frogStartupHeight(brandWidth, Number.POSITIVE_INFINITY)
const height = availableHeight >= fullHeight + 2 ? fullHeight : frogStartupHeight(brandWidth, availableHeight)
return { left, width: brandWidth, height }
}
export function SetupBrand({
width,
height,
frame,
appearance,
animationId,
elapsedMs,
onFrogElement,
}: {
width: number
height: number
frame: { left: number; width: number; height: number }
appearance: AppearanceSettings
animationId: number | undefined
elapsedMs: number
@@ -32,9 +48,10 @@ export function SetupBrand({
customBase: appearance.customTheme.base,
...(frogTheme === 'custom' ? { frogColor: palette.frog } : {}),
}
const { left, width, height } = frame
const frogHitbox = frogStartupHitbox(width, height)
return (
<Box width={width} height={height + 2} paddingTop={2} flexShrink={0} overflow="hidden" position="relative">
<Box height={height + 2} paddingTop={2} paddingLeft={left} flexShrink={0} overflow="hidden" position="relative">
<Box width={width} height={height} overflow="hidden" position="relative">
<Text>
{animationId === undefined
+120 -85
View File
@@ -32,7 +32,6 @@ import { errorMessage, sanitizeTerminalText } from '../../terminal/sanitize.js'
import { canChooseDirectory, chooseAgentProject, chooseDirectory } from '../../terminal/directory-picker.js'
import { setTerminalMouseMotion } from '../../terminal/terminal.js'
import {
frogStartupHeight,
renderSetupGuideTransitionFrame,
SETUP_GUIDE_TRANSITION_DURATION_MS,
setupGuideLayout,
@@ -72,7 +71,7 @@ import {
wizardSettingsRows,
} from './steps.js'
import { useBrandAnimation } from '../startup-view.js'
import { SetupBrand } from './brand.js'
import { SetupBrand, setupBrandFrame } from './brand.js'
import { OpeningMenu } from './opening-menu.js'
import { SetupSettingsPanel, type SetupSettingsChoiceTarget } from './settings-panel.js'
import { EffortSlider } from '../model-panel.js'
@@ -175,7 +174,6 @@ function SetupWizardContent({
const [setupTarget, setSetupTarget] = useState<SetupDraft>()
const [profileBaseDir] = useState<string | null | undefined>(() => config.snapshot().profileBaseDir)
const [importPath, setImportPath] = useState('')
const [importedEntrypoint, setImportedEntrypoint] = useState<string>()
const [editing, setEditing] = useState<{ field: EditableField; value: string; cursor?: number }>()
const [pathCompletionIndex, setPathCompletionIndex] = useState(-1)
const [error, setError] = useState<string>()
@@ -330,13 +328,8 @@ function SetupWizardContent({
!isSettings && (isProviderSetup || isImport || isCapabilities || isAppearance || flow === 'manual')
const bubbleWidth = Math.max(1, Math.min(isProviderSetup ? 118 : 112, width - 2))
const lockupWidth = Math.max(1, width - 2)
const minimumContentHeight = step === 0 ? 20 : isProviderSetup ? 24 : 22
const brandAvailableHeight = Math.max(1, height - minimumContentHeight)
const fullLockupHeight = frogStartupHeight(lockupWidth, Number.POSITIVE_INFINITY)
const lockupHeight =
lockupWidth >= 74 && brandAvailableHeight >= fullLockupHeight + 2
? fullLockupHeight
: frogStartupHeight(lockupWidth, brandAvailableHeight)
const brandFrame = setupBrandFrame(lockupWidth, height)
const lockupHeight = brandFrame.height
const showBrand = lockupHeight > 1
const brandHeight = showBrand ? lockupHeight + 2 : 0
const openingColumns = lockupWidth >= 64 ? 2 : 1
@@ -381,6 +374,28 @@ function SetupWizardContent({
const updateProfile = useCallback((update: Partial<HarnessAgentConfig>): void => {
setDraft((current) => ({ ...current, profile: { ...current.profile, ...update } }))
}, [])
const credentialRejectedProvider =
providerModels.provider === quickstartProvider &&
!providerModels.loading &&
providerModels.credentialRejected === true
? quickstartProvider
: undefined
const keyCredentialProvider = ['anthropic', 'openai', 'google'].includes(quickstartProvider)
const credentialValidationProvider =
isProviderSetup &&
keyCredentialProvider &&
readyProviders.includes(quickstartProvider) &&
(providerModels.provider !== quickstartProvider || providerModels.loading)
? quickstartProvider
: undefined
const unavailableCredentialProvider = credentialRejectedProvider ?? credentialValidationProvider
const setupReadyProviders = useMemo(
() =>
unavailableCredentialProvider
? readyProviders.filter((provider) => provider !== unavailableCredentialProvider)
: readyProviders,
[readyProviders, unavailableCredentialProvider]
)
const rows = useMemo<WizardRow[]>(() => {
const stepRows = isAppearance
@@ -398,13 +413,16 @@ function SetupWizardContent({
providerEnvironment,
detectedEnvironment,
quickstartProvider,
readyProviders,
setupReadyProviders,
awsDiscovery,
ollamaDiscovery,
setDraft,
updateProfile,
setEditing,
liteLlmDiscovery
liteLlmDiscovery,
credentialRejectedProvider,
credentialValidationProvider,
providerModels.error
)
return isProviderSetup
? [
@@ -443,7 +461,10 @@ function SetupWizardContent({
liteLlmDiscovery,
providerEnvironment,
quickstartProvider,
readyProviders,
setupReadyProviders,
credentialRejectedProvider,
credentialValidationProvider,
providerModels.error,
recheck,
step,
updateProfile,
@@ -454,13 +475,13 @@ function SetupWizardContent({
(flow === 'import'
? importPath.trim().length > 0
: isProviderSetup
? selectedModelProvider === quickstartProvider && readyProviders.includes(quickstartProvider)
? selectedModelProvider === quickstartProvider && setupReadyProviders.includes(quickstartProvider)
: draft.providers.some((provider) => readyProviders.includes(provider)))
const inspectedProvider = isProviderSetup ? quickstartProvider : undefined
const inspectedAssessment = inspectedProvider
? providerAssessment(inspectedProvider, effectiveEnvironment, awsDiscovery, ollamaDiscovery, liteLlmDiscovery)
: undefined
const warning = inspectedAssessment?.warning
const warning = keyCredentialProvider ? undefined : inspectedAssessment?.warning
const assessmentFacts = inspectedAssessment?.facts ?? []
const bannerWarning = warning
? assessmentFacts.length > 0
@@ -475,7 +496,7 @@ function SetupWizardContent({
? EXA_WEB_SEARCH_WARNING
: undefined
const bannerWarningStatus = warning ? inspectedAssessment!.status : 'warning'
const providerReady = isProviderSetup && readyProviders.includes(quickstartProvider)
const providerReady = isProviderSetup && setupReadyProviders.includes(quickstartProvider)
const showProviderConfiguration = isProviderSetup && !providerReady
const modelPanelVisible = splitQuickstart && providerReady
const availableBubbleHeight = Math.max(
@@ -757,7 +778,7 @@ function SetupWizardContent({
backgroundColor={rowBackground(index) ?? COMMAND_DECK_BACKGROUND}
>
<EditableText
value={activeEditing?.value ?? ''}
value={activeEditing?.value ?? (row.selectOptions ? row.description : '')}
cursor={activeEditing?.cursor ?? graphemes(activeEditing?.value ?? '').length}
width={Math.max(1, fieldWidth - 4)}
active={Boolean(activeEditing)}
@@ -830,6 +851,15 @@ function SetupWizardContent({
setError(undefined)
}
function focusNextProviderField(field: ProviderEnvironmentKey): void {
const current = rows.findIndex((row) => row.field === field)
const next = rows.findIndex((row, index) => index > current && !row.disabled)
if (next >= 0) {
setSelection(next)
setFocusedAction(undefined)
}
}
function chooseSelectOption(option: SelectOption): void {
if (!selecting) {
return
@@ -839,6 +869,7 @@ function SetupWizardContent({
setEditing({ field: selecting.field, value: current })
} else {
setProviderValue(selecting.field, option.value ?? '')
focusNextProviderField(selecting.field)
}
setSelecting(undefined)
}
@@ -934,6 +965,55 @@ function SetupWizardContent({
})
}
function completeSetup(completedDraft: SetupDraft = draft, agentProject?: string): void {
let saved: Promise<void>
setSaving(true)
if (appearanceOnly || agentProject) {
if (deferred) {
onComplete({
configuration: {
...configurationFromStore(config),
settings: { ...panelSettings, ...completedDraft.settings, ...appearance },
},
...(agentProject ? { agentProject } : {}),
})
return
}
saved = appearanceOnly
? config.setSettings({ ...panelSettings, ...appearance })
: config.useAgentProject(agentProject!, {
...panelSettings,
...completedDraft.settings,
...appearance,
})
} else {
setError(undefined)
const configuration: SetupConfiguration = {
providers: completedDraft.providers,
profile: compatibleProfile(completedDraft.profile),
...(profileBaseDir !== undefined ? { profileBaseDir } : {}),
permissionMode: completedDraft.permissionMode,
allowedTools:
completedDraft.customPermissions || completedDraft.permissionMode === 'bypassPermissions'
? completedDraft.allowedTools
: [],
providerEnvironment,
settings: { ...panelSettings, ...completedDraft.settings, ...appearance },
}
if (deferred) {
onComplete({ configuration })
return
}
saved = config.saveSetup(configuration)
}
void saved
.then(() => onComplete())
.catch((cause: unknown) => {
setSaving(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
function continueFlow(): void {
if (editing || selecting || saving) {
return
@@ -943,58 +1023,13 @@ function SetupWizardContent({
return
}
if (isAppearance) {
let saved: Promise<void>
setSaving(true)
if (appearanceOnly || (flow === 'import' && importedEntrypoint)) {
if (deferred) {
onComplete({
configuration: {
...configurationFromStore(config),
settings: { ...panelSettings, ...draft.settings, ...appearance },
},
...(!appearanceOnly ? { agentProject: importedEntrypoint! } : {}),
})
return
}
saved = appearanceOnly
? config.setSettings({ ...panelSettings, ...appearance })
: config.useAgentProject(importedEntrypoint!, { ...panelSettings, ...draft.settings, ...appearance })
} else {
setError(undefined)
const configuration: SetupConfiguration = {
providers: draft.providers,
profile: compatibleProfile(draft.profile),
...(profileBaseDir !== undefined ? { profileBaseDir } : {}),
permissionMode: draft.permissionMode,
allowedTools:
draft.customPermissions || draft.permissionMode === 'bypassPermissions' ? draft.allowedTools : [],
providerEnvironment,
settings: { ...panelSettings, ...draft.settings, ...appearance },
}
if (deferred) {
onComplete({ configuration })
return
}
saved = config.saveSetup(configuration)
}
void saved
.then(() => onComplete())
.catch((cause: unknown) => {
setSaving(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
completeSetup()
return
}
if (flow === 'import') {
try {
const imported = importAgentProject(importPath)
panelTransition.transition(() => {
setImportedEntrypoint(imported.entrypoint)
setSettingsCategory(DEFAULT_SETTINGS_CATEGORY)
setStep(APPEARANCE_STEP)
setSelection(0)
setFocusedAction(undefined)
})
completeSetup(draft, imported.entrypoint)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
}
@@ -1036,15 +1071,17 @@ function SetupWizardContent({
panelTransition.transition(() => setStep(1))
return
}
setDraft({
const completedDraft = {
...draft,
providers: [provider, ...draft.providers.filter((candidate) => candidate !== provider)],
})
}
setDraft(completedDraft)
if (step >= 3) {
completeSetup(completedDraft)
return
}
panelTransition.transition(() => {
if (step >= 3) {
setSettingsCategory(DEFAULT_SETTINGS_CATEGORY)
}
setStep(step < 3 ? step + 1 : APPEARANCE_STEP)
setStep(step + 1)
setSelection(0)
setFocusedAction(undefined)
if (step === 1) {
@@ -1072,12 +1109,7 @@ function SetupWizardContent({
})
return
}
panelTransition.transition(() => {
setSettingsCategory(DEFAULT_SETTINGS_CATEGORY)
setStep(APPEARANCE_STEP)
setSelection(0)
setFocusedAction(undefined)
})
completeSetup()
}
function moveBack(): void {
@@ -1099,11 +1131,6 @@ function SetupWizardContent({
}
if (appearanceOnly) {
onCancel?.(0)
} else {
panelTransition.transition(() => {
setStep(flow === 'manual' ? MANUAL_STEPS.length : flow === 'quickstart' ? 3 : 1)
setSelection(0)
})
}
return
}
@@ -1163,6 +1190,7 @@ function SetupWizardContent({
}
} else {
setProviderValue(editing.field, value)
focusNextProviderField(editing.field)
}
setEditing(undefined)
setError(undefined)
@@ -1768,9 +1796,14 @@ function SetupWizardContent({
}
})
const isSetupCompletion =
isAppearance ||
(flow === 'quickstart' && step === 3) ||
(flow === 'manual' && step === MANUAL_STEPS.length) ||
(flow === 'import' && step === 1)
const primaryLabel = saving
? 'Saving...'
: isAppearance
: isSetupCompletion
? deferred || !config.needsSetup()
? 'Save and Launch'
: 'Launch Strands harness'
@@ -1876,8 +1909,7 @@ function SetupWizardContent({
>
{showBrand ? (
<SetupBrand
width={lockupWidth}
height={lockupHeight}
frame={brandFrame}
appearance={appearance}
animationId={frogAnimationId}
elapsedMs={frogElapsedMs}
@@ -2131,7 +2163,10 @@ function SetupWizardContent({
{providerModels.provider !== quickstartProvider || providerModels.loading ? (
<Text dimColor>Loading model catalog...</Text>
) : !providerModels.available ? (
<Text color={palette.warning}>Model catalog unavailable</Text>
<Text color={palette.warning}>
Model catalog unavailable
{providerModels.error ? ` · ${providerModels.error}` : ''}
</Text>
) : visibleProviderModels.length === 0 ? (
<Text dimColor>{modelQuery.trim() ? 'No matching models' : 'No models reported'}</Text>
) : (
@@ -7,6 +7,29 @@ import { OPENING_CHOICES } from './steps.js'
const HOVER_FADE_DURATION_MS = 180
/** Shared with the setup brand so the lockup starts on the grid's left edge. */
export function openingGridLayout(width: number): {
columns: number
columnGap: number
rowGap: number
cardWidth: number
gridWidth: number
left: number
} {
const columns = width >= 64 ? 2 : 1
const columnGap = columns === 2 ? 4 : 0
const cardWidth = columns === 2 ? Math.min(44, Math.floor((width - columnGap) / 2)) : Math.min(48, width)
const gridWidth = cardWidth * columns + (columns - 1) * columnGap
return {
columns,
columnGap,
rowGap: columns === 2 ? 2 : 1,
cardWidth,
gridWidth,
left: Math.floor((width - gridWidth) / 2),
}
}
export function OpeningMenu({
width,
height,
@@ -32,22 +55,18 @@ export function OpeningMenu({
}): ReactElement {
const palette = useTheme()
const hoverProgress = useHoverProgress(hovered, OPENING_CHOICES.length, animate)
const columns = width >= 64 ? 2 : 1
const columnGap = columns === 2 ? 4 : 0
const rowGap = columns === 2 ? 2 : 1
const cardWidth = columns === 2 ? Math.min(44, Math.floor((width - columnGap) / 2)) : Math.min(48, width)
const gridWidth = cardWidth * columns + (columns - 1) * columnGap
const { columnGap, rowGap, cardWidth, gridWidth, left } = openingGridLayout(width)
return (
<FadeIn animate={animate} background={palette.background}>
<Box
{...(height === undefined ? { flexGrow: 1 } : { height, flexGrow: 0, flexShrink: 0 })}
alignItems="center"
alignItems="flex-start"
justifyContent="flex-start"
flexDirection="column"
overflow="hidden"
paddingTop={topGap}
>
<Box width={gridWidth} flexWrap="wrap" columnGap={columnGap} rowGap={rowGap}>
<Box width={gridWidth} marginLeft={left} flexWrap="wrap" columnGap={columnGap} rowGap={rowGap}>
{OPENING_CHOICES.map((choice, index) => {
const active = showSelection && index === selection
const hover = hoverProgress[index] ?? 0
@@ -177,10 +177,7 @@ export function providerAssessment(
: 'Credentials unavailable',
status: 'error',
warning: `AWS credentials ${problem}.
For Midway credentials, run mwinit, then:
ada credentials update --account=<account-id> --provider=isengard --role=<role-name> --once
Otherwise: aws sso login${profileArgument} or aws configure${profileArgument}.
Run aws login${profileArgument} or aws configure${profileArgument}.
For an API token, set AWS_BEARER_TOKEN_BEDROCK and its AWS_REGION.
Choose Refresh after updating credentials or a selected env file.
+26 -17
View File
@@ -53,12 +53,7 @@ export const OPENING_CHOICES = [
{ id: 'import', title: 'Import', description: 'Load in your custom harness from a file or zip.' },
] as const
const SETUP_STEP_INSTRUCTIONS = {
quickstart: [
'Pick a model for your agent',
"Choose your agent's tools",
'Choose plugins and features',
'Choose how Strands looks',
],
quickstart: ['Pick a model for your agent', "Choose your agent's tools", 'Choose plugins and features'],
agent: ['Pick a model for the assistant'],
manual: [
'Pick a model for your agent',
@@ -68,21 +63,19 @@ const SETUP_STEP_INSTRUCTIONS = {
'Configure context and memory',
'Set tool permissions',
'Review your agent',
'Choose how Strands looks',
],
import: ['Choose an agent to import', 'Choose how Strands looks'],
import: ['Choose an agent to import'],
} as const satisfies Record<SetupFlow, readonly string[]>
export function setupStepProgress(
flow: SetupFlow | undefined,
step: number
): { current: number; total: number; instruction: string } | undefined {
if (!flow || step === 0 || (flow === 'import' && step === 1)) {
if (!flow || step === 0 || step === APPEARANCE_STEP || (flow === 'import' && step === 1)) {
return undefined
}
const total = SETUP_STEP_INSTRUCTIONS[flow].length
const current = step === APPEARANCE_STEP ? total : step
return { current, total, instruction: SETUP_STEP_INSTRUCTIONS[flow][current - 1]! }
return { current: step, total, instruction: SETUP_STEP_INSTRUCTIONS[flow][step - 1]! }
}
const BUILTIN_TOOLS = [
@@ -153,7 +146,10 @@ export function rowsForStep(
setDraft: (update: (current: SetupDraft) => SetupDraft) => void,
updateProfile: (update: Partial<HarnessAgentConfig>) => void,
setEditing: (editing: { field: EditableField; value: string }) => void,
liteLlmDiscovery?: LiteLlmDiscovery
liteLlmDiscovery?: LiteLlmDiscovery,
credentialRejectedProvider?: ProviderId,
credentialValidationProvider?: ProviderId,
credentialRejectionMessage?: string
): WizardRow[] {
const environment = effectiveProviderEnvironment(providerEnvironment, detectedEnvironment, awsDiscovery)
if ((flow === 'quickstart' && step === 2) || (flow === 'manual' && step === 3)) {
@@ -200,12 +196,18 @@ export function rowsForStep(
const rows: WizardRow[] = PROVIDER_IDS.map((id) => {
const enabled = readyProviders.includes(id)
const assessment = providerAssessment(id, environment, awsDiscovery, ollamaDiscovery, liteLlmDiscovery)
const credentialRejected = id === credentialRejectedProvider
const credentialValidating = id === credentialValidationProvider
return {
id,
label: PROVIDER_LABELS[id],
description: assessment.description,
description: credentialRejected
? 'Setup required'
: credentialValidating
? 'Validating'
: assessment.description,
active: enabled,
status: assessment.status,
status: credentialRejected ? 'error' : credentialValidating ? 'warning' : assessment.status,
activate: noop,
}
})
@@ -224,13 +226,20 @@ export function rowsForStep(
selectedProvider === 'litellm' &&
field.key === 'LITELLM_API_KEY' &&
liteLlmDiscovery?.authenticationRequired === true
const editable = !credential || !detected || rejectedLiteLlmKey
const rejectedCredential =
rejectedLiteLlmKey || (selectedProvider === credentialRejectedProvider && credential)
const validatingCredential = selectedProvider === credentialValidationProvider && credential
const editable = !credential || !detected || rejectedCredential
return {
id: `${selectedProvider}:${field.key}`,
label: field.label,
description: credential
? detected
? `${rejectedLiteLlmKey ? 'Rejected' : 'Detected'} · ${sourceLabel(detected.source)}`
? rejectedCredential && credentialRejectionMessage
? credentialRejectionMessage
: validatingCredential
? 'Checking API key...'
: `${rejectedCredential ? 'Rejected' : 'Detected'} · ${sourceLabel(detected.source)}`
: credentialSetupDescription()
: configured
? `${configured}${selectOptions ? ' ▾' : ''}`
@@ -241,7 +250,7 @@ export function rowsForStep(
field: field.key,
...(selectOptions ? { selectOptions } : {}),
activate: editable
? edit(field.key, configured ?? (rejectedLiteLlmKey ? '' : (detected?.value ?? '')))
? edit(field.key, configured ?? (rejectedCredential ? '' : (detected?.value ?? '')))
: noop,
}
})
+1 -1
View File
@@ -44,7 +44,7 @@ export function StartupView({
const brandElapsedMs = frogBrandElapsedMs ?? FROG_BRAND_EASTER_EGG_DURATION_MS
const brandProgress = animate ? brandElapsedMs / FROG_BRAND_EASTER_EGG_DURATION_MS : brandElapsedMs < 800 ? 0.54 : 1
return (
<Box flexDirection="column" alignItems="center" paddingTop={lockupHeight >= 12 ? 2 : 1} width="100%" flexShrink={0}>
<Box flexDirection="column" alignItems="center" paddingTop={lockupHeight > 2 ? 2 : 1} width="100%" flexShrink={0}>
<Box width={lockupWidth} height={lockupHeight} overflow="hidden" position="relative">
<Text>
{frogBrandElapsedMs === undefined
+1 -8
View File
@@ -51,10 +51,7 @@ describe('source launcher', () => {
async function temporaryRepository(): Promise<string> {
const repository = await mkdtemp(join(tmpdir(), 'strands-source-launcher-'))
temporaryDirectories.push(repository)
await Promise.all([
mkdir(join(repository, 'strands-cli', 'src'), { recursive: true }),
mkdir(join(repository, 'harness-ts', 'src'), { recursive: true }),
])
await Promise.all([mkdir(join(repository, 'strands-cli', 'src'), { recursive: true })])
await Promise.all([
writeFile(join(repository, 'package.json'), '{}\n'),
writeFile(join(repository, 'package-lock.json'), '{}\n'),
@@ -62,10 +59,6 @@ async function temporaryRepository(): Promise<string> {
writeFile(join(repository, 'strands-cli', 'tsconfig.base.json'), '{}\n'),
writeFile(join(repository, 'strands-cli', 'src', 'tsconfig.json'), '{}\n'),
writeFile(join(repository, 'strands-cli', 'src', 'main.ts'), 'export const version = 1\n'),
writeFile(join(repository, 'harness-ts', 'package.json'), '{}\n'),
writeFile(join(repository, 'harness-ts', 'tsconfig.base.json'), '{}\n'),
writeFile(join(repository, 'harness-ts', 'src', 'tsconfig.json'), '{}\n'),
writeFile(join(repository, 'harness-ts', 'src', 'index.ts'), 'export const marker = true\n'),
])
return repository
}
+40 -5
View File
@@ -10,6 +10,10 @@ import {
renderFrogStartupLockup,
frogStartupHeight,
} from '../src/tui/view/frog-intro-renderer.js'
import { setupBrandFrame } from '../src/tui/view/setup-wizard/brand.js'
// Colored output paints solid cells as backgrounds, leaving only the half-block glyphs as text.
const STRANDS_WORDMARK = /STRANDS|╔════╝|█▀▀ ▀█▀ █▀█ ▄▀█ █▄ █ █▀▄ █▀▀|▀▀ ▀ ▀ {2}▀ {2}▄▀ {3}▄ {4}▀▄ {2}▀▀/
describe('Strands intro', () => {
it('retains Homeland ocean and land colors during the brand animation', () => {
@@ -20,12 +24,12 @@ describe('Strands intro', () => {
expect(frame).not.toContain(';2;129;255;157m')
})
it.each([78, 98])('swirls STRANDS into a vortex and restores the wordmark on a frog click at width %s', (width) => {
it.each([98, 120])('swirls STRANDS into a vortex and restores the wordmark on a frog click at width %s', (width) => {
const startup = renderFrogStartupLockup(width)
const vortex = renderFrogBrandEasterEggFrame(width, 0.54, 1_728)
const rotating = renderFrogBrandEasterEggFrame(width, 0.58, 1_856)
const reformed = renderFrogBrandEasterEggFrame(width, 0.84, 2_688)
const top = width < 91 ? 0 : 2
const top = 2
const wordX = startup.split('\n')[top]!.indexOf('███████╗')
expect(vortex).toMatch(/[●•◆◇━]/u)
@@ -78,7 +82,7 @@ describe('Strands intro', () => {
[40, 24],
])('hands off to the exact startup artwork at %s by %s', (width, height) => {
const artHeight = frogStartupHeight(width, height - 6)
const top = artHeight >= 12 ? 2 : 1
const top = artHeight > 2 ? 2 : 1
const complete = renderFrogSpiralFrame(width, height, 1, 4_200, true)
.split('\n')
.slice(top, top + artHeight)
@@ -92,6 +96,26 @@ describe('Strands intro', () => {
expect(held).toBe(startup)
})
it.each([
[98, 32],
[98, 36],
[98, 40],
[140, 40],
[78, 42],
])('hands off to the setup wizard brand frame at %s by %s', (width, height) => {
const frame = setupBrandFrame(width, height)
const top = frame.height > 2 ? 2 : 1
const complete = renderFrogSpiralFrame(width, height, 1, 4_200, true, 'green', {}, frame.height, frame.left)
.split('\n')
.slice(top, top + frame.height)
.join('\n')
const brand = renderFrogStartupLockup(frame.width, true, 0, 'green', false, {}, frame.height)
.split('\n')
.map((row) => ' '.repeat(frame.left) + row)
.join('\n')
expect(complete).toBe(brand)
})
it('paints full cells as backgrounds and keeps two-color glyphs on the bottom half', () => {
const colored = renderFrogStartupLockup(98, true)
const plain = renderFrogStartupLockup(98)
@@ -118,12 +142,23 @@ describe('Strands intro', () => {
it('renders STRANDS in compact layouts', () => {
for (const width of [22, 40]) {
const styled = renderFrogStartupLockup(width, true, 0, 'green', false, {}, 4)
const styled = renderFrogStartupLockup(width, true, 0, 'green', false, {}, 2)
const plain = sanitizeTerminalText(styled)
expect(plain).toContain('STRANDS')
expect(plain).toMatch(STRANDS_WORDMARK)
}
})
it('shows the frog only beside the wordmark and keeps the wordmark alone when narrower', () => {
const full = renderFrogStartupLockup(91, false, 0, 'green', false, {}, 12)
const wordOnly = renderFrogStartupLockup(90, false, 0, 'green', false, {}, 19)
const small = renderFrogStartupLockup(64, false, 0, 'green', false, {}, 19)
expect(full).toContain('▄▀▀▀▄')
expect(wordOnly).toContain('███████╗')
expect(wordOnly).not.toContain('▄▀▀▀▄')
expect(small).not.toContain('███████╗')
})
it.each([
[48, 20],
[79, 24],
@@ -62,9 +62,14 @@ describe('TUI mouse input', () => {
const target = backend()
target.info = () => ({ model: 'bedrock/test' })
target.listModels = () => [{ id: 'bedrock/test', name: 'bedrock/test', description: '', active: true }]
target.stream = async function* () {
yield { type: 'textDelta', text: '' }
return { stopReason: 'endTurn', context: { projectedTokens: 100, contextWindow: 1_000 } }
}
const controller = new ChatController(target, {
runtime: { version: '1.2.3', model: 'bedrock/test', cwd: '/work' },
})
await controller.submit('measure context')
const instance = render(createElement(ChatApp, { controller }), {
stdin: input,
stdout: output,
@@ -76,7 +81,7 @@ describe('TUI mouse input', () => {
instances.push(instance)
await instance.waitUntilRenderFlush()
const contextTarget = findText(frame(), 'context ░')
const contextTarget = findText(frame(), 'context █')
const modelTarget = findText(frame(), 'bedrock/test')
input.write(mouseInputSequence(0, contextTarget.column, contextTarget.row, 'M'))
+4 -2
View File
@@ -33,7 +33,7 @@ function createController() {
protocol: 'strands',
async *stream() {
yield* []
return { stopReason: 'endTurn' }
return { stopReason: 'endTurn', context: { projectedTokens: 100, contextWindow: 1_000 } }
},
cancel() {},
info: () => ({ model: 'model-00' }),
@@ -221,7 +221,9 @@ describe('mounted panel resizing', () => {
await vi.waitFor(() => expect(view.screen()).toContain('model-00'))
await view.click(view.point('model-00'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('models'))
await view.click(view.point('context ░'))
await controller.submit('measure context')
await vi.waitFor(() => expect(view.screen()).toContain('context █'))
await view.click(view.point('context █'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('context'))
await view.click(view.point('/help'))
expect(controller.getSnapshot().panel).toBeUndefined()
@@ -166,3 +166,20 @@ describe('LiteLLM proxy discovery', () => {
})
})
})
describe('provider model discovery errors', () => {
it('retains the HTTP failure reason without exposing credential values', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new globalThis.Response('', { status: 401 }))
await expect(
discoverProviderModels('openai', {
OPENAI_API_KEY: { value: 'secret-test-key', source: 'session' },
})
).resolves.toEqual({
models: [],
available: false,
error: 'API key was rejected (HTTP 401)',
credentialRejected: true,
})
})
})
+4 -1
View File
@@ -5,6 +5,9 @@ import { describe, expect, it, vi } from 'vitest'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
// Colored output paints solid cells as backgrounds, leaving only the half-block glyphs as text.
const STRANDS_WORDMARK = /STRANDS|╔════╝|█▀▀ ▀█▀ █▀█ ▄▀█ █▄ █ █▀▄ █▀▀|▀▀ ▀ ▀ {2}▀ {2}▄▀ {3}▄ {4}▀▄ {2}▀▀/
vi.setConfig({ testTimeout: 20_000 })
const execFileAsync = promisify(execFile)
@@ -73,7 +76,7 @@ describe.skipIf(process.platform === 'win32')('TUI PTY lifecycle', () => {
const beforePrompt = result.output.slice(0, result.output.indexOf('Message Lifecycle Fixture'))
expect(result.returnCode).toBe(0)
expect(sanitizeTerminalText(beforePrompt)).toContain('STRANDS')
expect(sanitizeTerminalText(beforePrompt)).toMatch(STRANDS_WORDMARK)
expect(result.output).toMatch(/[▗▖▄▝▐▞▟▘▚▌▙▀▜▛]/u)
expect(result.output).toContain('Message Lifecycle Fixture')
expect(beforePrompt.split('\u001b[2J').length - 1).toBe(1)
+14 -8
View File
@@ -12,6 +12,9 @@ import { frogStartupHeight, renderFrogStartupLockup } from '../src/tui/view/frog
import { SetupWizard } from '../src/tui/view/setup-wizard/index.js'
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
// Colored output paints solid cells as backgrounds, leaving only the half-block glyphs as text.
const STRANDS_WORDMARK = /STRANDS|╔════╝|█▀▀ ▀█▀ █▀█ ▄▀█ █▄ █ █▀▄ █▀▀|▀▀ ▀ ▀ {2}▀ {2}▄▀ {3}▄ {4}▀▄ {2}▀▀/
const instances: Instance[] = []
const controllers: ChatController[] = []
@@ -80,9 +83,12 @@ async function mount(element: ReactElement, columns: number, rows: number) {
describe('responsive welcome art', () => {
it.each([
[120, 35, 12],
[80, 34, 19],
[80, 19, 4],
[40, 11, 4],
[80, 34, 6],
[73, 34, 6],
[64, 34, 2],
[120, 20, 8],
[80, 19, 6],
[40, 11, 2],
[22, 8, 1],
[80, 4, 1],
])('fits %s columns and %s available rows into %s art rows', (width, available, height) => {
@@ -92,7 +98,7 @@ describe('responsive welcome art', () => {
expect(lines).toHaveLength(height)
expect(lines.every((line) => stringWidth(line) === width)).toBe(true)
if (height < 12) {
expect(art).toContain('STRANDS')
expect(art).toMatch(STRANDS_WORDMARK)
}
})
@@ -114,7 +120,7 @@ describe('responsive welcome art', () => {
expect(view.screen().replace(/\s/g, '')).toContain('keepthisdraft')
expect(view.screen()).toContain('/help')
if (rows < 30) {
expect(view.screen()).toContain('STRANDS')
expect(view.screen()).toMatch(STRANDS_WORDMARK)
} else {
expect(view.screen()).toContain('╔')
}
@@ -125,7 +131,7 @@ describe('responsive welcome art', () => {
it('uses compact art from the first intro frame and handles resize while initialization is pending', async () => {
const complete = vi.fn()
const view = await mount(createElement(DnaVortexIntro, { ready: false, onComplete: complete }), 40, 16)
expect(view.screen()).toContain('STRANDS')
expect(view.screen()).toMatch(STRANDS_WORDMARK)
for (const [columns, rows] of [
[120, 40],
[40, 16],
@@ -135,7 +141,7 @@ describe('responsive welcome art', () => {
await vi.waitFor(() => {
view.fits()
expect(view.screen()).toContain('space to skip')
if (columns < 74) expect(view.screen()).toContain('STRANDS')
if (columns < 74) expect(view.screen()).toMatch(STRANDS_WORDMARK)
})
}
expect(complete).not.toHaveBeenCalled()
@@ -147,7 +153,7 @@ describe('responsive welcome art', () => {
40,
40
)
expect(view.screen()).toContain('STRANDS')
expect(view.screen()).toMatch(STRANDS_WORDMARK)
for (const [columns, rows] of [
[40, 16],
[120, 40],
+8 -1
View File
@@ -313,18 +313,25 @@ describe('runInkChat', () => {
const controller = new ChatController(backend(), { settings: { animations: false } })
await controller.submit('hello!')
let instance: Instance | undefined
let incrementalRendering: boolean | undefined
const running = runInkChat(controller, {
intro: false,
input,
output,
errorOutput: output,
renderApp: (element, options) => {
instance = render(element, { ...options, interactive: true, patchConsole: false })
if (!options || !('incrementalRendering' in options)) {
throw new Error('Expected Ink render options.')
}
incrementalRendering = options.incrementalRendering
// Full frames let each write be compared against a fresh render.
instance = render(element, { ...options, interactive: true, patchConsole: false, incrementalRendering: false })
return instance
},
})
try {
expect(incrementalRendering).toBe(true)
await vi.waitFor(() => expect(writes.join('')).toContain('Message Test'))
input.push('draft')
await vi.waitFor(() => expect(writes.join('')).toContain('draft'))
@@ -172,6 +172,68 @@ describe('setup provider credentials', () => {
expect(rows.find((row) => row.id === 'openai:OPENAI_API_KEY')).toBeUndefined()
})
it('offers a replacement field after an API key is rejected', () => {
const setEditing = vi.fn()
const environment = { OPENAI_API_KEY: { value: 'rejected', source: 'session' as const } }
const rows = rowsForStep(
1,
'quickstart',
quickstartDraft('openai', environment),
'',
{},
environment,
'openai',
[],
{ profiles: [], regions: [] },
undefined,
() => {},
() => {},
setEditing,
undefined,
'openai'
)
expect(rows.find((row) => row.id === 'openai')).toMatchObject({
description: 'Setup required',
status: 'error',
})
const apiKey = rows.find((row) => row.id === 'openai:OPENAI_API_KEY')
expect(apiKey).toMatchObject({ input: true, description: 'Rejected · this session' })
apiKey?.activate()
expect(setEditing).toHaveBeenCalledWith({ field: 'OPENAI_API_KEY', value: '' })
})
it('keeps the credential panel stable while validating an API key', () => {
const environment = { GEMINI_API_KEY: { value: 'checking', source: 'session' as const } }
const rows = rowsForStep(
1,
'quickstart',
quickstartDraft('google', environment),
'',
{},
environment,
'google',
[],
{ profiles: [], regions: [] },
undefined,
() => {},
() => {},
() => {},
undefined,
undefined,
'google'
)
expect(rows.find((row) => row.id === 'google')).toMatchObject({
description: 'Validating',
status: 'warning',
})
expect(rows.find((row) => row.id === 'google:GEMINI_API_KEY')).toMatchObject({
input: false,
description: 'Checking API key...',
})
})
it('keeps missing-credential guidance concise and points to the shell profile', () => {
vi.stubEnv('SHELL', '/bin/zsh')
const warning = providerAssessment('openai', {}, { profiles: [], regions: [] }, undefined).warning
+91 -11
View File
@@ -13,8 +13,13 @@ import { DEFAULT_CHAT_SETTINGS } from '../src/tui/chat/types.js'
import { settingsRows } from '../src/tui/chat/panels.js'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
import { SetupWizard } from '../src/tui/view/setup-wizard/index.js'
import { APPEARANCE_STEP, setupStepProgress, wizardSettingsRows } from '../src/tui/view/setup-wizard/steps.js'
import { discoverAwsConfiguration, discoverAwsCredentials, discoverLiteLlm } from '../src/tui/provider/discovery.js'
import { setupStepProgress, wizardSettingsRows } from '../src/tui/view/setup-wizard/steps.js'
import {
discoverAwsConfiguration,
discoverAwsCredentials,
discoverLiteLlm,
discoverProviderModels,
} from '../src/tui/provider/discovery.js'
import { discoverAwsConfiguration as readAwsConfiguration } from '../src/tui/provider/aws-config.js'
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
@@ -33,13 +38,16 @@ describe('setup presentation', () => {
setupStepProgress('quickstart', 1),
setupStepProgress('quickstart', 2),
setupStepProgress('quickstart', 3),
setupStepProgress('quickstart', APPEARANCE_STEP),
]).toEqual([
{ current: 1, total: 4, instruction: 'Pick a model for your agent' },
{ current: 2, total: 4, instruction: "Choose your agent's tools" },
{ current: 3, total: 4, instruction: 'Choose plugins and features' },
{ current: 4, total: 4, instruction: 'Choose how Strands looks' },
{ current: 1, total: 3, instruction: 'Pick a model for your agent' },
{ current: 2, total: 3, instruction: "Choose your agent's tools" },
{ current: 3, total: 3, instruction: 'Choose plugins and features' },
])
expect(setupStepProgress('manual', 7)).toEqual({
current: 7,
total: 7,
instruction: 'Review your agent',
})
})
it.each([
@@ -75,7 +83,7 @@ describe('setup presentation', () => {
await instance.waitUntilRenderFlush()
input.push('\r')
await instance.waitUntilRenderFlush()
await vi.waitFor(() => expect(frame).toContain('1 of 4'))
await vi.waitFor(() => expect(frame).toContain('1 of 3'))
expect(frame).not.toContain('Step 1:')
expect(frame).toContain('Pick a model for your agent')
expect(frame).toContain('Back')
@@ -147,6 +155,69 @@ describe('setup presentation', () => {
}
})
it.each([
['quickstart', 0],
['manual', 1],
])('keeps a clicked AWS region visible in %s setup', async (_flow, openingMoves) => {
vi.mocked(discoverAwsCredentials).mockResolvedValue('missing')
const input = ttyInput()
const output = ttyOutput(100, 50)
let frame = ''
output.on('data', (chunk: Buffer) => {
if (chunk.toString().includes('\n')) {
frame = sanitizeTerminalText(chunk.toString())
}
})
const instance = render(
createElement(SetupWizard, {
config: CliConfigStore.memory({}, {}, { animations: false }),
onComplete: () => {},
}),
{
stdin: input,
stdout: output,
stderr: output,
interactive: true,
debug: true,
incrementalRendering: false,
patchConsole: false,
exitOnCtrlC: false,
}
)
const click = async (text: string): Promise<void> => {
const lines = frame.split('\n')
const row = lines.findIndex((line) => line.includes(text))
const column = lines[row]!.indexOf(text)
input.push(mouseInputSequence(0, column, row, 'M'))
input.push(mouseInputSequence(3, column, row, 'm'))
await instance.waitUntilRenderFlush()
}
try {
await instance.waitUntilRenderFlush()
for (let index = 0; index < openingMoves; index++) {
input.push('\u001b[B')
await instance.waitUntilRenderFlush()
}
input.push('\r')
await instance.waitUntilRenderFlush()
await vi.waitFor(() => expect(frame).toContain('AWS region'))
await click('AWS region')
await vi.waitFor(() => expect(frame).toContain('us-east-1'))
await click('us-east-1')
expect(frame).toContain('us-east-1 ▾')
input.push('\r')
await instance.waitUntilRenderFlush()
expect(frame).toContain('AWS profile')
expect(frame).toContain('Default credential chain')
} finally {
instance.unmount()
await instance.waitUntilExit()
vi.mocked(discoverAwsCredentials).mockResolvedValue('valid')
}
})
it('cancels an empty import path on click-away and validates it only on Enter', async () => {
const input = ttyInput()
const output = ttyOutput(80, 24)
@@ -340,7 +411,7 @@ describe('setup presentation', () => {
input.push('\r')
await instance.waitUntilRenderFlush()
await delay(180)
await vi.waitFor(() => expect(frame).toContain('1 of 4'))
await vi.waitFor(() => expect(frame).toContain('1 of 3'))
expect(frame).not.toContain('Name and instruct your agent')
} finally {
instance.unmount()
@@ -467,7 +538,7 @@ describe('setup refresh', () => {
await instance.waitUntilRenderFlush()
await press('\r')
for (let index = 0; index < 3; index++) await press('\u001b[B')
await vi.waitFor(() => expect(frame).toContain('OPENAI_API_KEY was not detected.'))
await vi.waitFor(() => expect(frame).toContain('Session only · Add to'))
expect(frame).not.toContain('Set up OpenAI')
expect(frame).toContain('Click to enter text')
expect(frame).toContain('Session only · Add to')
@@ -490,6 +561,13 @@ describe('setup refresh', () => {
await press('\r')
await vi.waitFor(() => expect(frame).toContain('Models'))
expect(vi.mocked(discoverProviderModels).mock.calls).toContainEqual([
'openai',
expect.objectContaining({
OPENAI_API_KEY: { value: 'sk-session-secret', source: 'session' },
}),
expect.anything(),
])
expect(config.providerEnvironment().OPENAI_API_KEY).toEqual({
value: 'sk-session-secret',
source: 'session',
@@ -648,14 +726,16 @@ describe('setup theme', () => {
try {
await instance.waitUntilRenderFlush()
await press('\r')
for (const destination of ['Tools', 'Plugins & features', 'Appearance']) {
for (const destination of ['Tools', 'Plugins & features']) {
await press('\u001b[Z')
await press('\r')
await vi.waitFor(() => expect(sanitizeTerminalText(writes.join(''))).toContain(destination))
}
expect(sanitizeTerminalText(writes.join(''))).toMatch(/Save and Launch|Launch Strands harness/)
await press('\u001b[Z')
await press('\r')
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledOnce())
expect(sanitizeTerminalText(writes.join(''))).not.toContain('Appearance')
const change = onComplete.mock.calls[0]?.[0]
expect(change?.newConversation).not.toBe(true)
expect(config.snapshot().settings.frogTheme).toBe(expected)
+4 -4
View File
@@ -257,7 +257,7 @@ describe('ChatView', () => {
expect(output.indexOf('answer')).toBeLessThan(output.indexOf('Message Strands harness'))
expect(output.indexOf('Message Strands harness')).toBeLessThan(output.lastIndexOf('bedrock/test'))
expect(output.split('\n').some((line) => line.trim() === 'hello')).toBe(true)
expect(output).toContain('context ░░░░░░░░░░ --')
expect(output).toContain('context ░░░░░░░░░░ 0%')
expect(output).toContain('Shift+Enter')
const rows = output.trimEnd().split('\n')
expect(rows.at(-3)).toContain('context')
@@ -1253,14 +1253,14 @@ describe('voice input', () => {
describe('panel helpers', () => {
it('overlays a selection without changing the rendered text', () => {
const props = {
snapshot: snapshot(),
snapshot: snapshot({ context: { projectedTokens: 100, contextWindow: 1_000 } }),
terminalWidth: 80,
terminalHeight: 16,
}
const output = sanitizeTerminalText(renderView(props, { columns: 80 }))
const lines = output.split('\n')
const target = lines.findIndex((line) => line.includes('context ░'))
const column = lines[target]!.indexOf('context ░')
const target = lines.findIndex((line) => line.includes('context █'))
const column = lines[target]!.indexOf('context █')
const selection = selectScreenText(
lines,
{ column: column + 1, row: target + 1 },