mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
fix(cli): improve onboarding and terminal responsiveness (#4518)
This commit is contained in:
Generated
+3
@@ -10,6 +10,9 @@
|
||||
"workspaces": [
|
||||
"strands-ts"
|
||||
],
|
||||
"bin": {
|
||||
"strands-dev": "strands-cli/bin/strands.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.9.4"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user