fix(cli): streamline terminal layout and startup experience (#4729)

This commit is contained in:
Gautam Sirdeshmukh
2026-10-01 11:45:51 -04:00
committed by GitHub
parent 93e51a0ca7
commit e0c95eeead
52 changed files with 1681 additions and 1168 deletions
+11
View File
@@ -45,6 +45,7 @@
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^4.1.10",
"@xterm/headless": "6.0.0",
"eslint": "^10.6.0",
"prettier": "^3.9.4",
"vitest": "^4.1.10"
@@ -3043,6 +3044,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
"dev": true,
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+1 -3
View File
@@ -7,9 +7,6 @@
"strands": "bin/strands.js"
},
"exports": {},
"imports": {
"#ink-text-cache": "./dist/src/tui/terminal/ink.js"
},
"files": [
"bin",
"dist",
@@ -92,6 +89,7 @@
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^4.1.10",
"@xterm/headless": "6.0.0",
"eslint": "^10.6.0",
"prettier": "^3.9.4",
"vitest": "^4.1.10"
+2 -2
View File
@@ -179,7 +179,7 @@ async function runConsole(args: ParsedArgs, request: string | undefined, mode: '
async function runInteractive(args: ParsedArgs, request: string | undefined): Promise<void> {
try {
await import('#ink-text-cache')
await import('../tui/terminal/ink.js')
// React's development timing buffer retains rendered props in Node. Load the production UI
// without changing the environment inherited by tools and user commands.
const nodeEnvironment = process.env.NODE_ENV
@@ -264,7 +264,7 @@ async function runInteractive(args: ParsedArgs, request: string | undefined): Pr
{
...(request ? { firstRequest: request } : {}),
intro: config.snapshot().settings.animations,
setup: args.setup || (!args.agent && (config.needsSetup() || config.snapshot().settings.setupOnLaunch)),
setup: args.setup || (!args.agent && !config.snapshot().agentProject && config.needsSetup()),
config,
}
)
+8 -3
View File
@@ -40,24 +40,29 @@ export const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map((command) => comma
export interface CommandAssistance {
readonly signature?: string
readonly message?: string
readonly completions: readonly LocalCommandSpec[]
}
export function commandAssistance(input: string): CommandAssistance | undefined {
export function commandAssistance(input: string, actionableCommandToken?: string): CommandAssistance | undefined {
const match = input.match(/^\/([^\s]*)(?:\s([\s\S]*))?$/)
if (!match) {
return undefined
}
const name = (match[1] ?? '').toLowerCase()
const argument = match[2]
if (name === 'frog' || name === 'strands' || name === 'party') {
return { message: "hmm... you've discovered an ancient secret...", completions: [] }
}
const unknownCommand = actionableCommandToken ? undefined : { message: 'not found', completions: [] }
if (argument === undefined) {
const completions = LOCAL_COMMANDS.filter((command) => command.name.startsWith(name))
return completions.length > 0 ? { completions } : undefined
return completions.length > 0 ? { completions } : unknownCommand
}
const command = LOCAL_COMMANDS.find((candidate) => candidate.name === name)
if (!command) {
return undefined
return unknownCommand
}
const completions = argumentCompletions(name, argument)
if (argument.length > 0 && completions.length === 0) {
+2 -13
View File
@@ -1737,11 +1737,6 @@ export class ChatController implements ChatControllerApi {
if (this._panel?.kind !== 'settings') {
return false
}
if (setting === 'setup') {
if (!this._requestSetup) return false
this._requestSetup()
return true
}
const update = parseSettingUpdate(setting, this._settings)
if (!update) {
return false
@@ -1758,13 +1753,7 @@ export class ChatController implements ChatControllerApi {
this._settings = globalThis.structuredClone({ ...this._settings, ...update })
this._panel = {
...this._panel,
rows: sanitizeRows(
settingsRows(
this._settings,
this._requestSetup !== undefined,
this._panel.settingsCategory ?? DEFAULT_SETTINGS_CATEGORY
)
),
rows: sanitizeRows(settingsRows(this._settings, this._panel.settingsCategory ?? DEFAULT_SETTINGS_CATEGORY)),
}
this._emit()
return true
@@ -1775,7 +1764,7 @@ export class ChatController implements ChatControllerApi {
if (!category) {
return false
}
const rows = settingsRows(this._settings, this._requestSetup !== undefined, category.id)
const rows = settingsRows(this._settings, category.id)
const options: ChatPanelOptions = {
filters: settingsCategoryFilters(),
settingsCategory: category.id,
+19 -36
View File
@@ -146,43 +146,26 @@ export function settingsCategoryFilters(): ChatPanelFilter[] {
return SETTINGS_CATEGORIES.map(({ id, label }) => ({ id: `settings:${id}`, label }))
}
export function settingsRows(
settings: ChatSettings,
setupAvailable = false,
category?: SettingsCategory
): ChatPanelRow[] {
return SETTING_DEFINITIONS.filter(({ section }) => category === undefined || section === category).flatMap(
({ key, label, section, control, options }): ChatPanelRow[] => [
{
label,
description: settingDescription(settings, key),
value: key,
...(category ? {} : { section }),
...(category ? { filter: `settings:${category}` } : {}),
control:
control === 'toggle'
? { kind: 'toggle', checked: settings[key] as boolean }
: {
kind: 'segmented',
options: options.map((option) => ({
label: option.label,
value: String(option.value),
...(settings[key] === option.value ? { active: true } : {}),
})),
},
},
...(setupAvailable && key === 'setupOnLaunch'
? [
{
label: 'Setup',
description: 'Providers and agent ›',
value: 'setup',
...(category ? {} : { section: 'General' }),
...(category ? { filter: `settings:${category}` } : {}),
export function settingsRows(settings: ChatSettings, category?: SettingsCategory): ChatPanelRow[] {
return SETTING_DEFINITIONS.filter(({ section }) => category === undefined || section === category).map(
({ key, label, section, control, options }): ChatPanelRow => ({
label,
description: settingDescription(settings, key),
value: key,
...(category ? {} : { section }),
...(category ? { filter: `settings:${category}` } : {}),
control:
control === 'toggle'
? { kind: 'toggle', checked: settings[key] as boolean }
: {
kind: 'segmented',
options: options.map((option) => ({
label: option.label,
value: String(option.value),
...(settings[key] === option.value ? { active: true } : {}),
})),
},
]
: []),
]
})
)
}
+56 -70
View File
@@ -1,12 +1,13 @@
import { createElement, type ReactElement } from 'react'
import { render, type Instance } from 'ink'
import { createElement, type ComponentProps, type ReactElement } from 'react'
import { render, useInput, type Instance } from 'ink'
import { ChatRoot, type ChatLaunch, type SetupBridge } from './view/chat-root.js'
import type { McpTrustRequest } from './view/mcp-trust-prompt.js'
import type { ChatControllerApi, ChatConversation } from './chat/controller.js'
import type { CliConfigStore } from './config.js'
import { configurationFromStore, type RequestSetup } from './agent-configuration.js'
import { hardExitProcessTree } from './terminal/process-tree.js'
import { enterAlternateScreen } from './terminal/terminal.js'
import { createInkOutputs, enterAlternateScreen } from './terminal/terminal.js'
interface RunInkChatOptions {
firstRequest?: string
@@ -30,36 +31,26 @@ type ChatControllerFactory = (
type ChatControllerSource = ChatControllerApi | ChatControllerFactory
const SYNCHRONIZED_OUTPUT_START = '\u001b[?2026h'
const SYNCHRONIZED_OUTPUT_END = '\u001b[?2026l'
function createInkOutput(output: NodeJS.WriteStream): NodeJS.WriteStream {
// Apple Terminal leaves stale full-screen frames when Ink emits DEC synchronized-output markers.
return new Proxy(output, {
get(target, property): unknown {
if (property === 'write') {
const write = target.write
return (chunk: unknown, ...args: unknown[]): unknown =>
Reflect.apply(write, target, [
typeof chunk === 'string'
? chunk.replaceAll(SYNCHRONIZED_OUTPUT_START, '').replaceAll(SYNCHRONIZED_OUTPUT_END, '')
: chunk,
...args,
])
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
function ChatSession({
onCancelStartup,
...props
}: ComponentProps<typeof ChatRoot> & { onCancelStartup(): void }): ReactElement {
useInput(
(input, key) => {
if (key.ctrl && input === 'c') onCancelStartup()
},
})
{ isActive: props.trustRequest !== undefined }
)
return createElement(ChatRoot, props)
}
export async function runInkChat(source: ChatControllerSource, options: RunInkChatOptions = {}): Promise<number> {
const input = options.input ?? process.stdin
const output = options.output ?? process.stdout
const errorOutput = options.errorOutput ?? process.stderr
const inkOutput = process.env.TERM_PROGRAM === 'Apple_Terminal' ? createInkOutput(output) : output
const alternateScreen = options.alternateScreen !== false
const leaveTerminalMode = alternateScreen ? enterAlternateScreen(output) : (): void => {}
const inkOutputs = createInkOutputs(output, errorOutput, alternateScreen)
let leaveTerminalMode = (): void => {}
const renderApp = options.renderApp ?? render
const hardExit = (exitCode: number): never => {
leaveTerminalMode()
@@ -72,7 +63,22 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
let pendingExitCode: number | undefined
let replacementTask: Promise<void> | undefined
let launchOptions: ChatLaunch | undefined
const setupBridge: SetupBridge = { request: (): void => {}, confirmMcp: async (): Promise<boolean> => false }
let trustRequest: McpTrustRequest | undefined
const setupBridge: SetupBridge = {
request: (): void => {},
confirmMcp: (workspace, paths): Promise<boolean> =>
new Promise((resolve) => {
trustRequest = {
workspace,
paths,
resolve: (trusted): void => {
trustRequest = undefined
resolve(trusted)
},
}
renderChat()
}),
}
let resolveSetup: (exitCode: 0 | 130) => void = () => {}
const setupTask = options.setup
? new Promise<0 | 130>((resolve) => {
@@ -105,7 +111,7 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
} catch (recordingError) {
message += `\nCould not save the setup failure: ${recordingError instanceof Error ? recordingError.message : String(recordingError)}`
}
errorOutput.write(`error: Failed to apply setup: ${message}\n`)
inkOutputs.stderr.write(`error: Failed to apply setup: ${message}\n`)
controller = previousController
previousController.showError?.('Setup failed', message)
if (pendingExitCode !== undefined) {
@@ -177,31 +183,33 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
controller?.close(pendingExitCode)
instance?.unmount()
}
const introState: { exitCode?: 0 | 130 } = options.intro === false ? { exitCode: 0 } : {}
let resolveIntro: (exitCode: 0 | 130) => void = () => {}
const introTask =
options.intro === false
? Promise.resolve<0 | 130>(0)
: new Promise<0 | 130>((resolve) => {
resolveIntro = resolve
})
const completeIntro = (exitCode: 0 | 130): void => {
introState.exitCode = exitCode
resolveIntro(exitCode)
if (exitCode !== 0) {
closeForExit(exitCode)
}
}
const chatRoot = (): ReactElement =>
createElement(ChatRoot, {
createElement(ChatSession, {
...(controller ? { controller } : {}),
intro: options.intro !== false,
setup: options.setup === true,
...(options.config ? { config: options.config } : {}),
...(trustRequest ? { trustRequest } : {}),
setupBridge,
onSetupComplete: completeSetup,
onIntroComplete: completeIntro,
onCancelStartup: () => closeForExit(130),
})
const renderChat = (): void => {
if (instance) {
instance.rerender(chatRoot())
return
}
if (alternateScreen) leaveTerminalMode = enterAlternateScreen(output)
instance = renderApp(chatRoot(), {
stdin: input,
...inkOutputs,
exitOnCtrlC: false,
patchConsole: true,
// Full-frame repaints flash on terminals that ignore or lack synchronized output.
incrementalRendering: true,
maxFps: 30,
})
}
const onSigint = (): void => closeForExit(130)
const onSigterm = (): void => closeForExit(143)
@@ -216,19 +224,10 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
} else {
controller = source
}
instance = renderApp(chatRoot(), {
stdin: input,
stdout: inkOutput,
stderr: errorOutput,
exitOnCtrlC: false,
patchConsole: true,
// Full-frame repaints flash on terminals that ignore or lack synchronized output.
incrementalRendering: true,
maxFps: 30,
})
if (controller || options.setup) renderChat()
const setupExit = await setupTask
if (setupExit !== 0) {
instance.unmount()
instance?.unmount()
return setupExit
}
if (controllerFactory) {
@@ -252,26 +251,13 @@ export async function runInkChat(source: ChatControllerSource, options: RunInkCh
controller.close(pendingExitCode)
return pendingExitCode
}
instance.rerender(chatRoot())
renderChat()
}
if (!controller) {
throw new Error('Controller initialization did not return a controller.')
}
if (introState.exitCode === 130) {
controller.close(introState.exitCode)
instance.unmount()
await instance.waitUntilExit()
return introState.exitCode
}
startTasks.push(controller.start(options.firstRequest))
const introExit = await introTask
if (introExit !== 0) {
controller.close(introExit)
instance.unmount()
await instance.waitUntilExit()
return introExit
}
await instance.waitUntilExit()
await instance!.waitUntilExit()
return controller.getSnapshot().exitCode ?? 0
} finally {
process.off('SIGINT', onSigint)
-18
View File
@@ -87,8 +87,6 @@ export interface ChatSettings {
skillDiscovery: boolean
/** Allow agents to discover and message other live conversations. */
agentMessaging: boolean
/** Open the setup screen when starting an interactive session. */
setupOnLaunch: boolean
/** Send one anonymous usage ping per interactive start (see README → Telemetry). */
telemetry: boolean
}
@@ -104,7 +102,6 @@ export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
mcpDiscovery: false,
skillDiscovery: false,
agentMessaging: true,
setupOnLaunch: true,
telemetry: true,
}
@@ -118,7 +115,6 @@ export type SettingKey =
| 'mcpDiscovery'
| 'skillDiscovery'
| 'agentMessaging'
| 'setupOnLaunch'
| 'telemetry'
export interface SettingDefinition {
@@ -219,16 +215,6 @@ export const SETTING_DEFINITIONS: readonly SettingDefinition[] = [
{ label: 'Off', value: false },
],
},
{
key: 'setupOnLaunch',
label: 'Launch into Setup by default',
section: 'General',
control: 'toggle',
options: [
{ label: 'On', value: true },
{ label: 'Off', value: false },
],
},
{
key: 'telemetry',
label: 'Usage ping (telemetry)',
@@ -263,8 +249,6 @@ export function settingDescription(settings: ChatSettings, key: SettingKey): str
return settings.agentMessaging
? 'on · lets live conversations discover and message each other · applies at next launch'
: 'off · conversations stay isolated · applies at next launch'
case 'setupOnLaunch':
return settings.setupOnLaunch ? 'on · applies at next launch' : 'off · applies at next launch'
case 'telemetry':
return settings.telemetry
? 'on · one anonymous ping per launch: CLI version, provider, built-in tools and plugins · applies at next launch'
@@ -324,7 +308,6 @@ export function parseSettings(value: unknown, path: string): ChatSettings {
mcpDiscovery: booleanSetting('mcpDiscovery'),
skillDiscovery: booleanSetting('skillDiscovery'),
agentMessaging: booleanSetting('agentMessaging'),
setupOnLaunch: booleanSetting('setupOnLaunch'),
telemetry: booleanSetting('telemetry'),
}
}
@@ -414,7 +397,6 @@ export function parseSettingUpdate(setting: string, current: ChatSettings): Part
case 'mcpDiscovery':
case 'skillDiscovery':
case 'agentMessaging':
case 'setupOnLaunch':
case 'telemetry':
return { [name]: !current[name] }
case 'toolOutput':
+7 -5
View File
@@ -5,7 +5,7 @@ import { commandAssistance, type LocalCommandSpec } from '../chat/commands.js'
import { sanitizeTerminalText } from './sanitize.js'
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
export const MIN_PROMPT_HEIGHT = 3
export const MIN_PROMPT_HEIGHT = 7
export const MIN_PROMPT_ROWS = 2
export const DEFAULT_MAX_PROMPT_ROWS = 8
export const PROMPT_PADDING_WIDTH = 2
@@ -20,19 +20,21 @@ export function promptEditorHeight(
width = 80,
maxRows = DEFAULT_MAX_PROMPT_ROWS,
hasStatus = false,
party = false
party = false,
maxHeight = Infinity
): number {
const minimumHeight = MIN_PROMPT_HEIGHT + (party ? 2 : 0)
if (hasStatus) {
return MIN_PROMPT_HEIGHT
return Math.min(minimumHeight, maxHeight)
}
const promptPrefix = input.startsWith('!') ? '◆ shell ' : '◆ '
const promptPrefix = input.startsWith('!') ? '◆ shell ' : ''
const rows = promptViewport(
input,
cursor,
width - stringWidth(promptPrefix) - PROMPT_PADDING_WIDTH,
Math.max(MIN_PROMPT_ROWS, maxRows)
)
return Math.max(MIN_PROMPT_HEIGHT, rows.length + (party && input ? 2 : 1))
return Math.min(maxHeight, Math.max(minimumHeight, rows.length + (party && input ? 2 : 1)))
}
export interface EditorState {
+87 -3
View File
@@ -34,7 +34,6 @@ export default class TextCache {
}
}
// Ink 7.1.1 retains every measured/wrapped string for the life of the process.
const legacyCacheImport = "import TextCache from './strands-text-cache.js';"
const cacheImport = `import TextCache from ${JSON.stringify(new URL('./ink.js', import.meta.url).href)};`
const patches = new Map<string, [string, string][]>([
@@ -47,6 +46,91 @@ const patches = new Map<string, [string, string][]>([
['cache[cacheKey] = wrappedText;', 'cache.set(cacheKey, wrappedText);'],
],
],
[
'ink.js',
[
[
'this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;',
`this.lastTerminalWidth = getWindowSize(this.options.stdout).columns;
this.lastTerminalHeight = getWindowSize(this.options.stdout).rows;`,
],
[
` resized = () => {
const currentWidth = getWindowSize(this.options.stdout).columns;
if (currentWidth < this.lastTerminalWidth) {
// We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
this.log.clear();
this.lastOutput = '';
this.lastOutputToRender = '';
}
this.calculateLayout();
dom.emitLayoutListeners(this.rootNode);
this.onRender();
this.lastTerminalWidth = currentWidth;
};`,
// Ink's listener runs before application size subscribers. Wait for their dimensions to commit.
` resized = () => {
if (this.resizePending || this.isUnmounted) return;
const { columns, rows } = getWindowSize(this.options.stdout);
if (columns === this.lastTerminalWidth && rows === this.lastTerminalHeight) return;
this.resizePending = true;
this.throttledOnRender?.cancel();
this.throttledLog.cancel?.();
queueMicrotask(() => {
reconciler.flushSyncWork();
this.resizePending = false;
if (this.isUnmounted || this.isUnmounting) return;
const { columns, rows } = getWindowSize(this.options.stdout);
this.resizedFromFullscreen = this.lastOutputHeight >= this.lastTerminalHeight;
const sync = this.shouldSync();
if (sync) this.options.stdout.write(bsu);
try {
if (!this.resizedFromFullscreen && columns < this.lastTerminalWidth) {
this.log.clear();
this.lastOutput = '';
this.lastOutputToRender = '';
}
this.calculateLayout();
dom.emitLayoutListeners(this.rootNode);
this.onRender();
this.lastTerminalWidth = columns;
this.lastTerminalHeight = rows;
} finally {
if (sync) this.options.stdout.write(esu);
}
});
};`,
],
[
` onRender = () => {
this.hasPendingThrottledRender = false;`,
` onRender = () => {
if (this.resizePending) return;
this.hasPendingThrottledRender = false;`,
],
[
' if (shouldClearTerminal) {',
// Absolute row positions survive terminal clipping/reflow; erase only after writing each replacement row.
` const resizedFromFullscreen = this.resizedFromFullscreen;
this.resizedFromFullscreen = false;
if (resizedFromFullscreen && outputHeight <= viewportRows) {
const lines = output.split('\\n');
let frame = lines.map((line, row) => ansiEscapes.cursorTo(0, row) + line + ansiEscapes.eraseEndLine).join('');
if (outputHeight < viewportRows) {
frame += ansiEscapes.cursorTo(0, outputHeight) + ansiEscapes.eraseDown;
}
frame += ansiEscapes.cursorTo(0, Math.min(outputHeight, viewportRows - 1));
this.options.stdout.write(frame);
this.lastOutput = output;
this.lastOutputToRender = outputToRender;
this.lastOutputHeight = outputHeight;
this.log.sync(outputToRender);
return;
}
if (shouldClearTerminal) {`,
],
],
],
])
let targets: Map<string, [string, string][]>
@@ -79,7 +163,7 @@ function patch(url: string, loaded: module.LoadFnOutput): module.LoadFnOutput {
} else {
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) {
throw new Error(`Cannot apply the Ink text-cache patch to ${url}.`)
throw new Error(`Cannot apply the Ink runtime patch to ${url}.`)
}
source = source.replace(before, after)
}
@@ -91,7 +175,7 @@ if (!new URL(import.meta.url).search) {
const entrypoint = import.meta.resolve('ink')
const version = JSON.parse(readFileSync(new URL('../package.json', entrypoint), 'utf8')).version
if (version !== '7.1.1') {
throw new Error(`Review the Ink text-cache patch before upgrading Ink to ${version}.`)
throw new Error(`Review the Ink runtime patches before upgrading Ink to ${version}.`)
}
if (module.registerHooks) {
initialize({ entrypoint })
+25
View File
@@ -0,0 +1,25 @@
import { useMemo, useSyncExternalStore } from 'react'
import { useStdout } from 'ink'
export function useTerminalSize(): { columns: number; rows: number } {
const { stdout } = useStdout()
const store = useMemo(() => {
let size = { columns: stdout.columns || 80, rows: stdout.rows || 24 }
return {
getSnapshot: (): typeof size => {
const columns = stdout.columns || 80
const rows = stdout.rows || 24
if (size.columns !== columns || size.rows !== rows) size = { columns, rows }
return size
},
subscribe: (listener: () => void): (() => void) => {
stdout.on('resize', listener)
return (): void => {
stdout.off('resize', listener)
}
},
}
}, [stdout])
// External-store updates commit synchronously before Ink paints the resized viewport.
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
}
+71
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process'
import { fstatSync } from 'node:fs'
const ENTER_ALTERNATE_SCREEN = '\u001b[?1049h\u001b[2J\u001b[H'
const RESET_KITTY_KEYBOARD = '\u001b[<u'.repeat(16)
@@ -7,6 +8,11 @@ const ENABLE_MOUSE_MOTION = '\u001b[?1002l\u001b[?1003h'
const ENABLE_MOUSE_CLICKS = '\u001b[?1003l\u001b[?1002h'
const DISABLE_MOUSE = '\u001b[?1006l\u001b[?1003l\u001b[?1002l'
const LEAVE_ALTERNATE_SCREEN = `${DISABLE_MOUSE}\u001b[?25h\u001b[?1049l\u001b[?25h`
const SYNCHRONIZED_OUTPUT_START = '\u001b[?2026h'
const SYNCHRONIZED_OUTPUT_END = '\u001b[?2026l'
const SAVE_CURSOR = '\u001b7'
const RESTORE_CURSOR = '\u001b8'
const CURSOR_HOME = '\u001b[H'
export interface TerminalOutput {
isTTY?: boolean
@@ -46,3 +52,68 @@ export function copyTerminalText(text: string, output: TerminalOutput = process.
clipboard.stdin.end(text)
})
}
export function createInkOutputs(
output: NodeJS.WriteStream,
errorOutput: NodeJS.WriteStream,
alternateScreen: boolean
): { stdout: NodeJS.WriteStream; stderr: NodeJS.WriteStream } {
const parkCursor = alternateScreen && output.isTTY
// Apple Terminal leaves stale frames when it receives DEC synchronized-output markers.
const stripSynchronization = process.env.TERM_PROGRAM === 'Apple_Terminal'
if (!parkCursor && !stripSynchronization) return { stdout: output, stderr: errorOutput }
let cursorSaved = false
const wrap = (stream: NodeJS.WriteStream): NodeJS.WriteStream =>
new Proxy(stream, {
get(target, property): unknown {
if (property === 'write') {
return (chunk: unknown, ...args: unknown[]): unknown => {
if (typeof chunk === 'string') {
if (stripSynchronization) {
chunk = chunk.replaceAll(SYNCHRONIZED_OUTPUT_START, '').replaceAll(SYNCHRONIZED_OUTPUT_END, '')
}
if (parkCursor && chunk && chunk !== SYNCHRONIZED_OUTPUT_START && chunk !== SYNCHRONIZED_OUTPUT_END) {
// A bottom-row cursor makes the terminal scroll before delivering a height resize.
// Both streams must resume from the last write to their shared terminal.
chunk = `${cursorSaved ? RESTORE_CURSOR : ''}${chunk}${SAVE_CURSOR}${CURSOR_HOME}`
cursorSaved = true
}
}
return Reflect.apply(target.write, target, [chunk, ...args])
}
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
const stdout = wrap(output)
const stderr =
parkCursor && shareTerminal(output, errorOutput)
? errorOutput === output
? stdout
: wrap(errorOutput)
: errorOutput
return { stdout, stderr }
}
function shareTerminal(output: NodeJS.WriteStream, errorOutput: NodeJS.WriteStream): boolean {
if (output === errorOutput) return true
if (!output.isTTY || !errorOutput.isTTY) return false
const stdoutFd = 'fd' in output ? output.fd : undefined
const stderrFd = 'fd' in errorOutput ? errorOutput.fd : undefined
if (typeof stdoutFd !== 'number' || typeof stderrFd !== 'number') return false
try {
const stdout = fstatSync(stdoutFd)
const stderr = fstatSync(stderrFd)
return (
stdout.isCharacterDevice() &&
stderr.isCharacterDevice() &&
stdout.dev === stderr.dev &&
stdout.ino === stderr.ino &&
stdout.rdev === stderr.rdev
)
} catch {
return false
}
}
+60 -55
View File
@@ -1,9 +1,19 @@
import { createElement, useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
import {
createElement,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type ReactElement,
} from 'react'
import { renderToString, useApp, useInput, useStdout, type DOMElement, type Key } from 'ink'
import type { ChatControllerApi } from '../chat/controller.js'
import { commandAssistance } from '../chat/commands.js'
import { sanitizeTerminalText } from '../terminal/sanitize.js'
import { useTerminalSize } from '../terminal/size.js'
import {
completeSuggestion,
emptyEditor,
@@ -62,9 +72,11 @@ import type { ChatPanelRow } from '../chat/types.js'
export function ChatApp({
controller,
introStartedAt,
openUrl = openExternalUrl,
}: {
controller: ChatControllerApi
introStartedAt?: number
openUrl?: (url: string) => void
}): ReactElement {
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot)
@@ -94,10 +106,7 @@ export function ChatApp({
}, [snapshot.panel?.kind, closeAppearance])
const { exit } = useApp()
const { stdout } = useStdout()
const [terminal, setTerminal] = useState({
width: stdout.columns || 80,
height: stdout.rows || 24,
})
const { columns: width, rows: height } = useTerminalSize()
const [editor, setEditorState] = useState<EditorState>(emptyEditor)
const [suggestionSelection, setSuggestionSelection] = useState(0)
const [dismissedSuggestions, setDismissedSuggestions] = useState<string>()
@@ -111,6 +120,7 @@ export function ChatApp({
// A ref, not state: the maximum changes on every streamed line and only clamps scroll input.
const maxTranscriptScrollRef = useRef(0)
const [pressedMetadata, setPressedMetadata] = useState<MetadataTarget>()
const [hoveredMetadata, setHoveredMetadata] = useState<MetadataTarget>()
const [pressedPanelFilter, setPressedPanelFilter] = useState<string>()
const [pressedPanelRow, setPressedPanelRow] = useState<number>()
const [pressedPanelControl, setPressedPanelControl] = useState<string>()
@@ -121,6 +131,7 @@ export function ChatApp({
const [hoveredPanelFilter, setHoveredPanelFilter] = useState<string>()
const [hoveredPanelSlider, setHoveredPanelSlider] = useState(false)
const [hoveredSuggestion, setHoveredSuggestion] = useState<number>()
const [settingsHovered, setSettingsHovered] = useState(false)
const [pressedQueuedPrompt, setPressedQueuedPrompt] = useState<QueuedPromptTarget>()
const [editingQueuedPromptId, setEditingQueuedPromptId] = useState<string>()
const [screenSelection, setScreenSelection] = useState<readonly ScreenSelectionSegment[]>([])
@@ -186,6 +197,8 @@ export function ChatApp({
setHoveredPanelFilter(undefined)
setHoveredPanelSlider(false)
setHoveredSuggestion(undefined)
setSettingsHovered(false)
setHoveredMetadata(undefined)
}, [])
const resetPressed = useCallback((): void => {
setPressedMetadata(undefined)
@@ -196,17 +209,17 @@ export function ChatApp({
setPressedSuggestion(undefined)
setPressedQueuedPrompt(undefined)
}, [])
const actionableCommandToken = controller.actionableCommandToken(editor.input)
const rawAssistance =
snapshot.panel || dismissedSuggestions === editor.input || controller.busy
? undefined
: commandAssistance(editor.input)
: commandAssistance(editor.input, actionableCommandToken)
const assistance =
rawAssistance && (rawAssistance.completions.length > 0 || /^\/[^\s]+\s$/.test(editor.input))
rawAssistance &&
(rawAssistance.message !== undefined || rawAssistance.completions.length > 0 || /^\/[^\s]+\s$/.test(editor.input))
? rawAssistance
: undefined
const suggestions = assistance?.completions ?? []
const startupCovered = snapshot.panel !== undefined || assistance !== undefined
const actionableCommandToken = controller.actionableCommandToken(editor.input)
const panelRows = snapshot.panel
? filterPanelRows(
snapshot.panel.rows,
@@ -219,8 +232,8 @@ export function ChatApp({
snapshot,
input: editor.input,
cursor: editor.cursor,
terminalWidth: terminal.width,
terminalHeight: terminal.height,
terminalWidth: width,
terminalHeight: height,
suggestions,
...(panelRows ? { panelRows } : {}),
panelQuery,
@@ -280,34 +293,26 @@ export function ChatApp({
setFrog((current) => (current?.id === id ? undefined : current))
}, [])
useEffect(() => {
const resize = (): void => {
const size = { width: stdout.columns || 80, height: stdout.rows || 24 }
const { snapshot, panelRows } = viewPropsRef.current
if (snapshot.panel && panelRows) {
const agents = snapshot.panel.kind === 'agents'
const capacity = agents
? agentGridCapacity(size.width, size.height)
: panelRowCapacity(snapshot.panel.kind, size.height, size.width, panelRows)
const start = agents
? revealAgentGridSelection(
panelSelectionRef.current,
panelViewportStartRef.current,
capacity,
panelRows.length,
agentGridColumns(size.width)
)
: revealPanelSelection(panelSelectionRef.current, panelViewportStartRef.current, capacity, panelRows.length)
panelViewportStartRef.current = start
setPanelViewportStart(start)
}
setTerminal(size)
useLayoutEffect(() => {
const { snapshot, panelRows } = viewPropsRef.current
if (snapshot.panel && panelRows) {
const agents = snapshot.panel.kind === 'agents'
const capacity = agents
? agentGridCapacity(width, height)
: panelRowCapacity(snapshot.panel.kind, height, width, panelRows)
const start = agents
? revealAgentGridSelection(
panelSelectionRef.current,
panelViewportStartRef.current,
capacity,
panelRows.length,
agentGridColumns(width)
)
: revealPanelSelection(panelSelectionRef.current, panelViewportStartRef.current, capacity, panelRows.length)
panelViewportStartRef.current = start
setPanelViewportStart(start)
}
stdout.on('resize', resize)
return (): void => {
stdout.off('resize', resize)
}
}, [stdout])
}, [width, height])
useEffect(() => {
if (snapshot.status === 'closed') {
@@ -343,12 +348,6 @@ export function ChatApp({
panelSliderValueRef.current = snapshot.panel?.slider?.options.find((option) => option.active)?.id
}, [snapshot.panel?.id, resetPanelPosition, resetHover])
useEffect(() => {
if (startupCovered) {
setFrogBrandAnimationId(undefined)
}
}, [startupCovered])
useEffect(() => {
if (!snapshot.activeTurn && snapshot.completedTurns.length === 0) {
setExpandedToolGroups((current) => (current.size > 0 ? new Set() : current))
@@ -372,13 +371,12 @@ export function ChatApp({
setTranscriptScroll(0)
}, [snapshot.activeTurn?.id])
const menuOpen = snapshot.panel !== undefined || suggestions.length > 0
useEffect(() => {
setTerminalMouseMotion(menuOpen, stdout)
setTerminalMouseMotion(true, stdout)
return (): void => {
setTerminalMouseMotion(false, stdout)
}
}, [menuOpen, stdout])
}, [stdout])
useEffect(
(): (() => void) => () => {
@@ -479,13 +477,18 @@ export function ChatApp({
const sliderHit = sliderElement ? elementContainsMouse(sliderElement, mouse) : false
const searchElement = snapshot.panel?.kind === 'models' ? panelSearchElement.current : undefined
const searchHit = searchElement ? elementContainsMouse(searchElement, mouse) : false
const frogHit = frogElement.current ? elementContainsMouse(frogElement.current, mouse) : false
const frogHit =
!snapshot.panel && suggestions.length === 0 && frogElement.current
? elementContainsMouse(frogElement.current, mouse)
: false
if (mouse.action === 'move' && !leftMouseDownRef.current) {
setHoveredPanelRow(panelRowIndex)
setHoveredPanelControl(panelControlTarget)
setHoveredPanelFilter(panelFilterId)
setHoveredPanelSlider(sliderHit)
setHoveredSuggestion(suggestionIndex)
setSettingsHovered(footerAction === 'settings')
setHoveredMetadata(metadataTarget)
}
if (mouse.action === 'press' && button === 0) {
resetHover()
@@ -505,7 +508,6 @@ export function ChatApp({
...viewPropsRef.current,
input: currentEditor.input,
cursor: currentEditor.cursor,
synchronousTranscriptLayout: true,
}),
{ columns: terminalWidth }
)
@@ -1098,11 +1100,11 @@ export function ChatApp({
}
const phase = editingQueuedPromptId ? 'idle' : controller.busy ? 'running' : 'idle'
if (phase === 'idle' && viewPropsRef.current.commandAssistance && key.escape) {
setDismissedSuggestions(currentEditor.input)
return
}
if (phase === 'idle' && suggestions.length > 0) {
if (key.escape) {
setDismissedSuggestions(currentEditor.input)
return
}
const current = Math.min(suggestionSelectionRef.current, suggestions.length - 1)
const next = moveSelection(current, key, suggestions.length, suggestions.length)
if (next !== undefined) {
@@ -1193,18 +1195,21 @@ export function ChatApp({
return (
<ChatView
{...viewProps}
{...(introStartedAt !== undefined ? { introStartedAt } : {})}
snapshot={
previewAppearance ? { ...snapshot, settings: { ...snapshot.settings, ...previewAppearance } } : snapshot
}
onActionElement={registerFooterElement}
settingsHovered={settingsHovered}
{...(hoveredMetadata ? { hoveredMetadata } : {})}
{...(appearanceOpen
? {
overlay: (
<CustomThemeEditor
settings={snapshot.settings}
animate={snapshot.settings.animations}
width={terminal.width}
height={terminal.height}
width={width}
height={height}
onPreview={setPreviewAppearance}
onClose={closeAppearance}
onApply={async (appearance) => {
+14 -84
View File
@@ -1,18 +1,14 @@
import { useCallback, useEffect, useState, type ReactElement } from 'react'
import { useInput } from 'ink'
import { Text, ThemeProvider } from './theme.js'
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { ThemeProvider } from './theme.js'
import { ChatApp } from './app.js'
import type { ChatControllerApi } from '../chat/controller.js'
import { DEFAULT_CHAT_SETTINGS } from '../chat/types.js'
import type { CliConfigStore } from '../config.js'
import type { RequestSetup, SetupChange } from '../agent-configuration.js'
import { DnaVortexIntro } from './intro.js'
import { McpTrustPrompt, type McpTrustRequest } from './mcp-trust-prompt.js'
import { SetupWizard } from './setup-wizard/index.js'
const LOADING_DOT_FRAMES = ['. ', '.. ', '...'] as const
export type ChatLaunch = SetupChange
export interface SetupBridge {
@@ -25,25 +21,9 @@ interface ChatRootProps {
intro: boolean
setup: boolean
config?: CliConfigStore
trustRequest?: McpTrustRequest
setupBridge: SetupBridge
onSetupComplete(exitCode: 0 | 130, changed: boolean, launch?: ChatLaunch): void
onIntroComplete(exitCode: 0 | 130): void
}
function StartingHarness({ animate }: { animate: boolean }): ReactElement {
const [frame, setFrame] = useState(0)
useEffect(() => {
if (!animate) {
return
}
const timer = setInterval(() => setFrame((current) => current + 1), 350)
return (): void => clearInterval(timer)
}, [animate])
return (
<Text dimColor>
Starting Strands harness{animate ? LOADING_DOT_FRAMES[frame % LOADING_DOT_FRAMES.length] : '...'}
</Text>
)
}
export function ChatRoot({
@@ -51,30 +31,15 @@ export function ChatRoot({
intro,
setup,
config,
trustRequest,
setupBridge,
onSetupComplete,
onIntroComplete,
}: ChatRootProps): ReactElement | null {
const [phase, setPhase] = useState<'setup' | 'intro' | 'chat' | 'cancelled'>(
intro ? 'intro' : setup ? 'setup' : 'chat'
)
const [trustRequest, setTrustRequest] = useState<McpTrustRequest>()
useEffect(() => {
setupBridge.confirmMcp = (workspace, paths): Promise<boolean> =>
new Promise((resolve) => {
setTrustRequest({
workspace,
paths,
resolve: (trusted): void => {
setTrustRequest(undefined)
resolve(trusted)
},
})
})
return (): void => {
setupBridge.confirmMcp = async (): Promise<boolean> => false
}
}, [setupBridge])
const [phase, setPhase] = useState<'setup' | 'chat' | 'cancelled'>(setup ? 'setup' : 'chat')
const introStartedAt = useRef<number | undefined>(undefined)
if (controller && intro && !setup && introStartedAt.current === undefined) {
introStartedAt.current = Date.now()
}
useEffect(() => {
setupBridge.request = (change): void => {
if (config) {
@@ -94,26 +59,6 @@ export function ChatRoot({
setPhase(exitCode === 130 && !controller ? 'cancelled' : 'chat')
onSetupComplete(exitCode, changed, launch)
}
const finishIntro = useCallback(
(exitCode: 0 | 130): void => {
setPhase(exitCode === 0 ? (setup ? 'setup' : 'chat') : 'cancelled')
onIntroComplete(exitCode)
if (exitCode === 130 && setup) {
onSetupComplete(exitCode, false)
}
},
[onIntroComplete, onSetupComplete, setup]
)
useInput((character, key) => {
if (phase !== 'intro') {
return
}
if (character === ' ' && !key.ctrl && !key.meta && !key.super) {
finishIntro(0)
} else if (key.ctrl && (character === 'c' || character === 'd')) {
finishIntro(130)
}
})
if (phase === 'setup') {
if (!config) {
@@ -128,20 +73,6 @@ export function ChatRoot({
/>
)
}
if (phase === 'intro') {
const settings = config?.snapshot().settings ?? DEFAULT_CHAT_SETTINGS
return (
<ThemeProvider settings={settings}>
<DnaVortexIntro
ready={setup || controller !== undefined}
setup={setup}
theme={settings.frogTheme}
customBase={settings.customTheme.base}
onComplete={finishIntro}
/>
</ThemeProvider>
)
}
if (phase === 'cancelled') {
return null
}
@@ -154,10 +85,9 @@ export function ChatRoot({
)
}
return controller ? (
<ChatApp controller={controller} />
) : (
<ThemeProvider settings={settings}>
<StartingHarness animate={settings.animations} />
</ThemeProvider>
)
<ChatApp
controller={controller}
{...(introStartedAt.current !== undefined ? { introStartedAt: introStartedAt.current } : {})}
/>
) : null
}
+65 -85
View File
@@ -12,8 +12,8 @@ import { composerMaxRows, promptEditorHeight } from '../terminal/composer.js'
import type { ScreenSelectionSegment } from '../terminal/mouse-input.js'
import { VOICE_METER_WIDTH, voiceMeterFill } from '../voice/session.js'
import type { MetadataTarget, ModelPanelFocus } from './interaction.js'
import { contextColor, metadataPlacements } from './presentation.js'
import { PromptEditor } from './prompt-editor.js'
import { EffortPanel } from './effort-panel.js'
import { ResourcePanel } from './panels.js'
import { CommandPalette } from './command-palette.js'
import { FROG_ANIMATION_HEIGHT, FrogEasterEgg, type FrogVariant } from './frog-easter-egg.js'
@@ -22,7 +22,7 @@ import { TranscriptViewport } from './transcript.js'
import { PARTY_FRAME_INTERVAL_MS, usePartyFrame } from './use-party-frame.js'
import { useSpinner } from './use-spinner.js'
import { Box, Text, ThemeProvider, useTheme } from './theme.js'
import { ComposerHelpFooter, PanelHelpContext } from './help-footer.js'
import { ComposerFooter, PanelHelpContext } from './help-footer.js'
import { FadeIn } from './fade-in.js'
const MAX_VISIBLE_BACKGROUND_TASKS = 4
@@ -59,6 +59,8 @@ function ChatViewContent({
transcriptScroll = 0,
onMaxTranscriptScroll,
pressedMetadata,
hoveredMetadata,
settingsHovered,
pressedPanelFilter,
hoveredPanelFilter,
pressedPanelRow,
@@ -84,12 +86,12 @@ function ChatViewContent({
expandedToolGroups,
onToolGroupElement,
frogBrandAnimationId,
introStartedAt,
panelRows,
frog,
onFrogComplete,
selection,
voice,
synchronousTranscriptLayout = false,
clipboardNotice,
party = false,
}: {
@@ -113,6 +115,8 @@ function ChatViewContent({
transcriptScroll?: number
onMaxTranscriptScroll?: (maximum: number) => void
pressedMetadata?: MetadataTarget
hoveredMetadata?: MetadataTarget
settingsHovered?: boolean
pressedPanelFilter?: string
hoveredPanelFilter?: string
pressedPanelRow?: number
@@ -138,22 +142,24 @@ function ChatViewContent({
expandedToolGroups?: ReadonlySet<string>
onToolGroupElement?: (id: string, element: DOMElement | null) => void
frogBrandAnimationId?: number
introStartedAt?: number
panelRows?: ChatPanel['rows']
frog?: { id: number; variant: FrogVariant }
onFrogComplete?: (id: number) => void
selection?: readonly ScreenSelectionSegment[]
voice?: ChatVoiceStore
synchronousTranscriptLayout?: boolean
clipboardNotice?: { status: 'success'; characterCount: number } | { status: 'error' }
party?: boolean
}): ReactElement {
const { surface, background } = useTheme()
const { surface, background, canvas } = useTheme()
const resolvedCommandAssistance =
commandAssistance ?? (suggestions === undefined ? commandAssistanceForInput(input) : undefined)
commandAssistance ??
(suggestions === undefined ? commandAssistanceForInput(input, actionableCommandToken) : undefined)
const resolvedSuggestions = suggestions ?? resolvedCommandAssistance?.completions ?? []
const hasActivity =
snapshot.completedTurns.length > 0 || snapshot.activeTurn !== undefined || snapshot.notices.length > 0
const startupCovered = Boolean(snapshot.panel) || resolvedCommandAssistance !== undefined
const effortSlider = snapshot.panel?.kind === 'effort' ? snapshot.panel.slider : undefined
const showCommandPicker = resolvedCommandAssistance !== undefined && !snapshot.panel && !snapshot.activeTurn
const showQueueStatus = snapshot.queuedPrompts.length > 0 || snapshot.status === 'interrupting'
const showVoiceStatus = snapshot.voice !== undefined && snapshot.voice.status !== 'off'
const activeBackgroundTasks = snapshot.tasks.filter(
@@ -168,18 +174,28 @@ function ChatViewContent({
const composerStatusRows =
queueStatusRows + Number(showVoiceStatus) + visibleBackgroundTasks.length + Number(hiddenBackgroundTasks > 0)
const editorWidth = Math.max(1, terminalWidth - 2)
const editorMaxRows = composerMaxRows(terminalHeight, composerStatusRows)
const suggestionCapacity = Math.max(1, Math.min(5, terminalHeight - composerStatusRows - 10))
const suggestionRows = showCommandPicker
? Math.min(suggestionCapacity, resolvedSuggestions.length) +
Number(resolvedCommandAssistance.signature !== undefined) +
Number(resolvedCommandAssistance.message !== undefined) +
1
: 0
const editorMaxRows = composerMaxRows(terminalHeight, composerStatusRows + suggestionRows)
const editorMaxHeight = Math.max(party ? 3 : 1, terminalHeight - composerStatusRows - suggestionRows - 2)
const commandDeckHeight =
composerStatusRows +
suggestionRows +
promptEditorHeight(
input,
cursor,
editorWidth,
editorMaxRows,
Boolean(snapshot.composerStatus || snapshot.panel),
party
Boolean(!effortSlider && (snapshot.composerStatus || snapshot.panel)),
party,
editorMaxHeight
) +
3
2
// A stable header element keeps the memoized transcript from re-rendering on spinner ticks.
const { settings } = snapshot
const frogBrandElapsedMs = useBrandAnimation(frogBrandAnimationId, settings.animations)
@@ -189,6 +205,7 @@ function ChatViewContent({
terminalWidth={terminalWidth}
availableHeight={terminalHeight - commandDeckHeight}
animate={settings.animations}
{...(introStartedAt !== undefined ? { introStartedAt } : {})}
theme={settings.frogTheme}
customBase={settings.customTheme.base}
partyElapsedMs={partyFrame * PARTY_FRAME_INTERVAL_MS}
@@ -202,6 +219,7 @@ function ChatViewContent({
terminalHeight,
commandDeckHeight,
settings.animations,
introStartedAt,
settings.frogTheme,
settings.customTheme.base,
partyFrame,
@@ -219,7 +237,7 @@ function ChatViewContent({
paddingX={1}
overflow="hidden"
position="relative"
backgroundColor={background}
backgroundColor={canvas}
>
<Box flexDirection="column" flexGrow={1} overflowY="hidden">
{hasActivity ? (
@@ -230,31 +248,15 @@ function ChatViewContent({
settings={snapshot.settings}
scrollOffset={transcriptScroll}
layoutKey={`${terminalWidth}:${terminalHeight}:${commandDeckHeight}`}
synchronousLayout={synchronousTranscriptLayout}
{...(expandedToolGroups ? { expandedToolGroups } : {})}
{...(onToolGroupElement ? { onToolGroupElement } : {})}
{...(onMaxTranscriptScroll ? { onMaxScrollChange: onMaxTranscriptScroll } : {})}
{...(snapshot.activeTurn ? { activeTurn: snapshot.activeTurn } : {})}
/>
) : startupCovered ? null : (
) : (
startupView
)}
</Box>
{resolvedCommandAssistance && !snapshot.panel && !snapshot.activeTurn ? (
<FadeIn animate={settings.animations} background={background}>
<CommandPalette
commands={resolvedSuggestions}
assistance={resolvedCommandAssistance}
selected={suggestionSelection}
terminalWidth={terminalWidth}
terminalHeight={terminalHeight}
bottomPadding={commandDeckHeight}
{...(pressedSuggestion !== undefined ? { pressed: pressedSuggestion } : {})}
{...(hoveredSuggestion !== undefined ? { hovered: hoveredSuggestion } : {})}
{...(onSuggestionElement ? { onRowElement: onSuggestionElement } : {})}
/>
</FadeIn>
) : null}
<FadeIn animate={settings.animations} background={background}>
<Box width="100%" flexShrink={0} flexDirection="column" backgroundColor={surface}>
{showQueueStatus ? (
@@ -275,29 +277,53 @@ function ChatViewContent({
<Text dimColor>+ {hiddenBackgroundTasks} more background tasks</Text>
</Box>
) : null}
{showCommandPicker ? (
<CommandPalette
commands={resolvedSuggestions}
assistance={resolvedCommandAssistance}
selected={suggestionSelection}
width={editorWidth}
capacity={suggestionCapacity}
{...(pressedSuggestion !== undefined ? { pressed: pressedSuggestion } : {})}
{...(hoveredSuggestion !== undefined ? { hovered: hoveredSuggestion } : {})}
{...(onSuggestionElement ? { onRowElement: onSuggestionElement } : {})}
/>
) : null}
<PromptEditor
input={input}
cursor={cursor}
agentName={snapshot.runtime.agent}
animateCursor={settings.animations}
width={editorWidth}
maxRows={editorMaxRows}
maxHeight={editorMaxHeight}
{...(actionableCommandToken ? { actionableCommandToken } : {})}
{...(snapshot.composerStatus ? { busyStatus: `${composerSpinner} ${snapshot.composerStatus}` } : {})}
{...(snapshot.panel ? { panelStatus: panelEditorStatus(snapshot.panel) } : {})}
party={party}
partyFrame={partyFrame}
/>
<RuntimeMetadata
snapshot={snapshot}
terminalWidth={terminalWidth - 2}
{...(pressedMetadata ? { pressed: pressedMetadata } : {})}
{...(onMetadataElement ? { onElement: onMetadataElement } : {})}
/>
>
{effortSlider && snapshot.panel ? (
<EffortPanel
panel={snapshot.panel}
slider={effortSlider}
width={Math.max(1, editorWidth - 2 - (party ? 2 : 0))}
{...(onPanelElement ? { onElement: onPanelElement } : {})}
{...(onPanelSliderElement ? { onSliderElement: onPanelSliderElement } : {})}
/>
) : null}
</PromptEditor>
</Box>
<ComposerHelpFooter snapshot={snapshot} width={editorWidth} {...(onActionElement ? { onActionElement } : {})} />
<ComposerFooter
snapshot={snapshot}
width={editorWidth}
{...(settingsHovered !== undefined ? { settingsHovered } : {})}
{...(pressedMetadata ? { pressed: pressedMetadata } : {})}
{...(hoveredMetadata ? { hovered: hoveredMetadata } : {})}
{...(onMetadataElement ? { onMetadataElement } : {})}
{...(onActionElement ? { onActionElement } : {})}
/>
</FadeIn>
{snapshot.panel ? (
{snapshot.panel && !effortSlider ? (
<FadeIn key={snapshot.panel.kind} animate={settings.animations} background={background}>
<PanelHelpContext value={snapshot.panel}>
<ResourcePanel
@@ -327,7 +353,6 @@ function ChatViewContent({
{...(onPanelFilterElement ? { onFilterElement: onPanelFilterElement } : {})}
{...(onPanelSearchElement ? { onSearchElement: onPanelSearchElement } : {})}
{...(onPanelSliderElement ? { onSliderElement: onPanelSliderElement } : {})}
commandDeckHeight={commandDeckHeight}
/>
</PanelHelpContext>
</FadeIn>
@@ -553,48 +578,3 @@ function panelEditorStatus(panel: ChatPanel): string {
}
return panel.kind === 'permission' ? 'Permission required' : `Viewing ${panel.title}`
}
function RuntimeMetadata({
snapshot,
terminalWidth,
pressed,
onElement,
}: {
snapshot: ChatSnapshot
terminalWidth: number
pressed?: MetadataTarget
onElement?: (target: MetadataTarget, element: DOMElement | null) => void
}): ReactElement {
const palette = useTheme()
return (
<Box height={1} width="100%" flexShrink={0} overflow="hidden" paddingX={1}>
{metadataPlacements(snapshot, terminalWidth).map((segment) => {
const color =
pressed === segment.target
? palette.hover
: segment.target === 'context'
? contextColor(snapshot.context, palette)
: segment.target === 'model'
? palette.accent
: undefined
return (
<Box key={segment.target} width={segment.width} justifyContent={segment.alignment} overflow="hidden">
<Box
ref={
segment.target === 'cwd'
? undefined
: (element): void => {
onElement?.(segment.target, element)
}
}
>
<Text {...(color !== undefined ? { color } : {})} dimColor={segment.target === 'cwd'}>
{segment.text}
</Text>
</Box>
</Box>
)
})}
</Box>
)
}
+43 -52
View File
@@ -12,9 +12,8 @@ export function CommandPalette({
selected,
pressed,
hovered,
terminalWidth,
terminalHeight,
bottomPadding,
width,
capacity,
onRowElement,
}: {
commands: readonly LocalCommandSpec[]
@@ -22,65 +21,57 @@ export function CommandPalette({
selected: number
pressed?: number
hovered?: number
terminalWidth: number
terminalHeight: number
bottomPadding: number
width: number
capacity: number
onRowElement?: (index: number, element: DOMElement | null) => void
}): ReactElement {
const theme = useTheme()
const helpRows = Number(assistance.signature !== undefined)
const capacity = Math.max(1, terminalHeight - bottomPadding - 5 - helpRows)
const start = Math.max(0, Math.min(selected - capacity + 1, commands.length - capacity))
const usageWidth = Math.max(0, ...commands.map((command) => stringWidth(command.usage))) + 2
const width = Math.max(
const usageWidth = Math.max(
1,
Math.min(
Math.max(
44,
assistance.signature ? stringWidth(assistance.signature) + 4 : 0,
...commands.map((command) => usageWidth + stringWidth(command.description) + 4)
),
terminalWidth - 4
)
Math.min(Math.max(0, ...commands.map((command) => stringWidth(command.usage))) + 2, Math.floor((width - 3) / 2))
)
return (
<Box
position="absolute"
width="100%"
height="100%"
flexDirection="column"
justifyContent="flex-end"
paddingBottom={bottomPadding}
paddingLeft={1}
>
<Box width={width} backgroundColor={theme.panel} flexDirection="column" overflow="hidden">
{assistance.signature ? (
<Box height={1} paddingX={1}>
<Box width={width} backgroundColor={theme.surface} flexDirection="column" flexShrink={0} overflow="hidden">
{assistance.message ? (
<Box height={1} paddingX={1} flexShrink={0}>
<Text dimColor wrap="truncate-end">
{assistance.message}
</Text>
</Box>
) : null}
{assistance.signature ? (
<Box height={1} paddingX={1} flexShrink={0}>
<Text dimColor wrap="truncate-end">
{assistance.signature}
</Text>
</Box>
) : null}
{commands.slice(start, start + capacity).map((command, visibleIndex) => {
const index = start + visibleIndex
const pressedRow = index === pressed
return (
<Box
key={command.replacement ?? command.name}
ref={(element) => onRowElement?.(index, element)}
height={1}
flexShrink={0}
paddingX={1}
backgroundColor={index === hovered || pressedRow || index === selected ? theme.selection : undefined}
>
<Box width={usageWidth} marginRight={1} flexShrink={0} overflow="hidden">
<PanelItemHeader label={command.usage} active={index === selected} pressed={pressedRow} clickable />
</Box>
<Text dimColor wrap="truncate-end">
{assistance.signature}
{command.description}
</Text>
</Box>
) : null}
{commands.slice(start, start + capacity).map((command, visibleIndex) => {
const index = start + visibleIndex
const pressedRow = index === pressed
return (
<Box
key={command.replacement ?? command.name}
ref={(element) => onRowElement?.(index, element)}
height={1}
paddingX={1}
backgroundColor={index === hovered || pressedRow || index === selected ? theme.selection : undefined}
>
<Box width={usageWidth} marginRight={1} flexShrink={0}>
<PanelItemHeader label={command.usage} active={index === selected} pressed={pressedRow} clickable />
</Box>
<Text dimColor wrap="truncate-end">
{command.description}
</Text>
</Box>
)
})}
)
})}
<Box height={1} paddingX={1} flexShrink={0}>
<Text dimColor wrap="truncate-end">
{'─'.repeat(Math.max(0, width - 2))}
</Text>
</Box>
</Box>
)
+62
View File
@@ -0,0 +1,62 @@
import type { ReactElement } from 'react'
import type { DOMElement } from 'ink'
import stringWidth from 'string-width'
import type { ChatPanel, ChatPanelSlider } from '../chat/types.js'
import { PanelHelpContext, PanelHelpFooter } from './help-footer.js'
import { EffortSlider } from './model-panel.js'
import { Box, Text, useTheme } from './theme.js'
export function EffortPanel({
panel,
slider,
width,
onElement,
onSliderElement,
}: {
panel: ChatPanel
slider: ChatPanelSlider
width: number
onElement?: (element: DOMElement | null) => void
onSliderElement?: (element: DOMElement | null) => void
}): ReactElement {
const theme = useTheme()
const [modelName = '', modelId = ''] = panel.body?.split('\n') ?? []
const contentWidth = Math.max(1, width - 2)
const model = modelName || modelId
const showModel = model && stringWidth(`Reasoning effort · ${model}`) <= contentWidth
const sliderWidth = Math.min(contentWidth, 60)
return (
<Box
ref={onElement}
width={width}
paddingX={1}
flexGrow={1}
flexDirection="column"
alignItems="center"
justifyContent="center"
overflow="hidden"
>
<Box width={contentWidth} justifyContent="center">
<Text wrap="truncate-end">
<Text bold color={theme.accent}>
Reasoning effort
</Text>
{showModel ? <Text dimColor> · {model}</Text> : null}
</Text>
</Box>
<EffortSlider
slider={slider}
width={sliderWidth}
compact={false}
showStops
{...(onSliderElement ? { onElement: onSliderElement } : {})}
/>
<Box marginTop={1} width={contentWidth}>
<PanelHelpContext value={panel}>
<PanelHelpFooter width={contentWidth} centered />
</PanelHelpContext>
</Box>
</Box>
)
}
+27 -4
View File
@@ -46,16 +46,24 @@ export function FadeIn({
const { stdout } = useStdout()
const enabled = animate && stdout.isTTY
const [progress, setProgress] = useState(0)
const progressRef = useRef(0)
useEffect(() => {
if (!enabled) {
progressRef.current = 1
setProgress(1)
return
}
const from = progressRef.current
if (from === 1) {
return
}
const startedAt = Date.now()
setProgress(0)
const timer = setInterval(() => {
const next = Math.min(1, (Date.now() - startedAt) / FADE_DURATION_MS)
const elapsed = Math.min(1, (Date.now() - startedAt) / FADE_DURATION_MS)
const next = from + (1 - from) * (1 - (1 - elapsed) ** 3)
progressRef.current = next
setProgress(next)
if (next === 1) {
if (elapsed === 1) {
clearInterval(timer)
}
}, 32)
@@ -63,7 +71,7 @@ export function FadeIn({
}, [enabled])
return (
<Fade background={background} progress={enabled ? 1 - (1 - progress) ** 3 : 1}>
<Fade background={background} progress={enabled ? progress : 1}>
{children}
</Fade>
)
@@ -140,6 +148,21 @@ export function useFadeColor(color: string | undefined): string | undefined {
return mixHexColors(fade.background, color, fade.progress)
}
export function useFadeAnsi(text: string): string {
const fade = useContext(FadeContext)
if (!fade) return text
return text.replace(
// eslint-disable-next-line no-control-regex -- ANSI color sequences begin with ESC.
/\u001b\[(38|48);2;(\d+);(\d+);(\d+)m/g,
(_match: string, layer: string, red: string, green: string, blue: string): string => {
const color = `#${[red, green, blue].map((channel) => Number(channel).toString(16).padStart(2, '0')).join('')}`
const blended = mixHexColors(fade.background, color, fade.progress)
const channels = [1, 3, 5].map((offset) => Number.parseInt(blended.slice(offset, offset + 2), 16))
return `\u001b[${layer};2;${channels.join(';')}m`
}
)
}
export function mixHexColors(from: string, to: string, progress: number): string {
if (!/^#[0-9a-f]{6}$/iu.test(from) || !/^#[0-9a-f]{6}$/iu.test(to)) {
return to
@@ -91,10 +91,10 @@ export function renderFrogSpiralFrame(
theme: FrogTheme = 'green',
options: FrogRenderOptions = {},
artHeight = frogStartupHeight(Math.max(1, width), Math.max(1, height) - 6),
lockupLeft = 0
lockupLeft = 0,
lockupTop = artHeight > 2 ? 2 : 1
): string {
const canvas = new Canvas(Math.max(1, width), Math.max(1, height), theme, false, 0, options)
const top = artHeight > 2 ? 2 : 1
const value = clamp(progress)
if (artHeight < 12 || value >= 1) {
const indent = ' '.repeat(lockupLeft)
@@ -103,10 +103,14 @@ export function renderFrogSpiralFrame(
.map((row) => indent + row)
return canvas
.rows(color)
.map((row, index) => artwork[index - top] ?? row)
.map((row, index) => artwork[index - lockupTop] ?? row)
.join('\n')
}
const scene = createScene(canvas.width, canvas.height, Math.max(12, Math.floor((canvas.height - 15) / 2)) + 3)
const scene = createScene(
canvas.width,
canvas.height,
Math.min(Math.max(12, Math.floor((canvas.height - 15) / 2)) + 3, canvas.height - BRAND_WORD_HEIGHT - 3)
)
if (value < 0.23) {
drawSpiralAct(canvas, scene, value / 0.23, elapsedMs)
return canvas.rows(color).join('\n')
@@ -114,7 +118,7 @@ export function renderFrogSpiralFrame(
const local = (value - 0.23) / 0.77
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 shift = Math.round((scene.wordY - (lockupTop + (wide ? 5 : 7))) * smoothStep((local - 0.51) / 0.17))
const rows = canvas.rows(color)
return rows.map((_, index) => rows[index + shift] ?? ' '.repeat(canvas.width)).join('\n')
}
@@ -374,10 +378,10 @@ function drawFirefly(canvas: Canvas, point: Point, elapsedMs: number, glow = 1):
}
function createScene(width: number, height: number, wordY: number): Scene {
const count = Math.max(86, Math.min(210, Math.round(width * 1.6)))
const random = seededRandom(width * 7_919 + height * 104_729)
// Particle identities stay fixed while the canvas changes around them.
const random = seededRandom(7_919)
const particles: Particle[] = []
for (let index = 0; index < count; index++) {
for (let index = 0; index < 210; index++) {
particles.push({
angle: random() * Math.PI * 2,
radius: 0.25 + random() * 0.75,
+57 -50
View File
@@ -1,11 +1,19 @@
import { createContext, useContext, type ReactElement } from 'react'
import { createContext, Fragment, useContext, type ReactElement } from 'react'
import { Box, type DOMElement } from 'ink'
import type { ChatPanel, ChatSnapshot } from '../chat/types.js'
import type { MetadataTarget } from './interaction.js'
import { contextColor, metadataPlacements } from './presentation.js'
import { Text, useTheme } from './theme.js'
export const PanelHelpContext = createContext<ChatPanel | undefined>(undefined)
export function PanelHelpFooter({ width }: { width: number }): ReactElement | null {
export function PanelHelpFooter({
width,
centered = false,
}: {
width: number
centered?: boolean
}): ReactElement | null {
const panel = useContext(PanelHelpContext)
if (!panel) return null
const permission = panel.kind === 'permission'
@@ -35,7 +43,7 @@ export function PanelHelpFooter({ width }: { width: number }): ReactElement | nu
? 'Tab category · Enter · Esc back'
: '↑↓ · Enter open · Esc back'
return (
<Box width={Math.max(1, width)} height={1} flexShrink={0}>
<Box width={Math.max(1, width)} height={1} flexShrink={0} justifyContent={centered ? 'center' : 'flex-start'}>
<Text dimColor wrap="truncate-end">
{keys}
</Text>
@@ -43,64 +51,63 @@ export function PanelHelpFooter({ width }: { width: number }): ReactElement | nu
)
}
export function ComposerHelpFooter({
export function ComposerFooter({
snapshot,
width,
pressed,
hovered,
settingsHovered = false,
onMetadataElement,
onActionElement,
}: {
snapshot: ChatSnapshot
width: number
pressed?: MetadataTarget
hovered?: MetadataTarget
settingsHovered?: boolean
onMetadataElement?: (target: MetadataTarget, element: DOMElement | null) => void
onActionElement?: (action: 'settings' | 'setup' | 'help', element: DOMElement | null) => void
}): ReactElement {
const { foreground, selection } = useTheme()
const running = snapshot.status === 'running'
const compact = width < 25
const palette = useTheme()
const { accent, foreground } = palette
return (
<Box
height={1}
marginTop={1}
marginX={compact ? -1 : 0}
flexShrink={0}
paddingX={compact ? 0 : 1}
justifyContent="space-between"
>
<Box flexShrink={1} overflow="hidden">
<Text dimColor wrap="truncate-end">
<Text color={foreground}>Enter</Text> {running ? 'queue' : 'send'}
{running ? (
<>
{' · '}
<Text color={foreground}>Esc</Text> stop
</>
<Box height={1} marginTop={1} width={width} flexShrink={0} paddingX={1} overflow="hidden">
{metadataPlacements(snapshot, width).map((segment) => (
<Box
key={segment.target}
width={segment.width}
flexShrink={0}
justifyContent={segment.alignment}
overflow="hidden"
>
{segment.target === 'settings' ? (
<Box ref={(element) => onActionElement?.('settings', element)}>
<Text color={settingsHovered ? accent : foreground}>{segment.text}</Text>
</Box>
) : segment.controls ? (
segment.controls.map((control, index) => (
<Fragment key={control.target}>
{index > 0 ? <Text color={foreground}> • </Text> : null}
<Box ref={(element) => onMetadataElement?.(control.target, element)}>
<Text
color={
hovered === control.target || pressed === control.target
? accent
: control.target === 'context'
? contextColor(snapshot.context, palette)
: foreground
}
>
{control.text}
</Text>
</Box>
</Fragment>
))
) : (
<>
{width >= 86 ? (
<>
{' · '}
<Text color={foreground}>Ctrl+J</Text> newline
</>
) : null}
{width >= 62 ? (
<>
{' '}
· <Text color={foreground}>/</Text> commands
</>
) : null}
</>
<Text dimColor>{segment.text}</Text>
)}
</Text>
</Box>
<Box flexShrink={0}>
{(['settings', 'setup', 'help'] as const).map((action) => (
<Box
key={action}
ref={(element) => onActionElement?.(action, element)}
marginLeft={compact && action === 'settings' ? 0 : 1}
>
<Text backgroundColor={selection}>/{action}</Text>
</Box>
))}
</Box>
</Box>
))}
</Box>
)
}
+5 -3
View File
@@ -4,7 +4,7 @@ import type { ChatControllerApi, ChatPanel, ChatPanelRow, ChatPanelSlider, ChatS
import { resolveModelTarget } from '../model/selection.js'
import type { MouseInput } from '../terminal/mouse-input.js'
export type MetadataTarget = 'model' | 'context' | 'cwd'
export type MetadataTarget = 'model' | 'effort' | 'context' | 'cwd'
export const MODEL_COPY_TARGET = 'model:copy-id'
export type ModelPanelFocus = 'effort' | 'search' | 'providers' | 'models' | 'copy'
@@ -19,7 +19,7 @@ export function settingsLayout(
optionWidth: number
} {
const contentWidth = Math.max(1, Math.min(68, panelWidth - 4))
const longLabel = setting === 'agentMessaging' || setting === 'setupOnLaunch' || setting === 'telemetry'
const longLabel = setting === 'agentMessaging' || setting === 'telemetry'
const labelWidth =
setting === 'frogTheme'
? Math.floor(contentWidth / 3)
@@ -472,7 +472,7 @@ export function shouldToggleVoiceMute(
}
export async function activateMetadataTarget(
controller: Pick<ChatControllerApi, 'openContextPanel' | 'openModelPanel'>,
controller: Pick<ChatControllerApi, 'openContextPanel' | 'openModelPanel' | 'submit'>,
target: MetadataTarget
): Promise<void> {
if (target === 'cwd') {
@@ -480,6 +480,8 @@ export async function activateMetadataTarget(
}
if (target === 'model') {
await controller.openModelPanel()
} else if (target === 'effort') {
await controller.submit('/effort')
} else {
controller.openContextPanel()
}
-118
View File
@@ -1,118 +0,0 @@
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { Box, useWindowSize } from 'ink'
import type { FrogTheme } from '../chat/types.js'
import {
FROG_FULL_LOCKUP_MIN_WIDTH,
FROG_INTRO_DURATION_MS,
frogStartupHeight,
renderFrogSpiralFrame,
} from './frog-intro-renderer.js'
import { setupBrandFrame } from './setup-wizard/brand.js'
import { Text, useTheme } from './theme.js'
const HOLD_MS = 250
export function hasRoomForFrogIntro(columns: number, rows: number, setup = false): boolean {
const terminalWidth = Math.max(1, columns)
const terminalHeight = Math.max(1, rows)
const artworkWidth = Math.max(1, terminalWidth - 2)
if (setup) {
const frame = setupBrandFrame(artworkWidth, terminalHeight)
return frame.width >= FROG_FULL_LOCKUP_MIN_WIDTH && frame.height === frogStartupHeight(frame.width, Infinity)
}
return (
artworkWidth >= FROG_FULL_LOCKUP_MIN_WIDTH &&
frogStartupHeight(artworkWidth, Math.max(1, terminalHeight - 6)) === frogStartupHeight(artworkWidth, Infinity)
)
}
export function DnaVortexIntro({
onComplete,
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 {
const colors = useTheme()
const { columns, rows } = useWindowSize()
const [elapsedMs, setElapsedMs] = useState(0)
const completed = useRef(false)
const startedAt = useRef(Date.now())
const terminalWidth = Math.max(1, columns)
const terminalHeight = Math.max(1, rows)
const hasRoom = hasRoomForFrogIntro(terminalWidth, terminalHeight, setup)
const fps = terminalWidth * terminalHeight > 8_000 ? 12 : terminalWidth * terminalHeight > 4_500 ? 16 : 20
useEffect(() => {
if (!hasRoom) {
if (!completed.current) {
completed.current = true
onComplete(0)
}
return
}
const tick = (): void => {
const elapsed = Date.now() - startedAt.current
setElapsedMs(elapsed)
if (elapsed >= FROG_INTRO_DURATION_MS + HOLD_MS && ready && !completed.current) {
completed.current = true
onComplete(0)
}
}
tick()
const timer = setInterval(tick, 1_000 / fps)
return (): void => clearInterval(timer)
}, [onComplete, fps, hasRoom, ready])
if (!hasRoom) {
return <></>
}
const brandFrame = setup ? setupBrandFrame(terminalWidth - 2, terminalHeight) : undefined
const artwork = renderFrogSpiralFrame(
terminalWidth - 2,
terminalHeight,
elapsedMs / FROG_INTRO_DURATION_MS,
elapsedMs,
true,
theme,
{ colorMode: colors.mode, customBase, ...(theme === 'custom' ? { frogColor: colors.frog } : {}) },
brandFrame?.height,
brandFrame?.left
)
return (
<Box
width={terminalWidth}
height={terminalHeight}
overflow="hidden"
position="relative"
backgroundColor={colors.background}
>
<Text>
{artwork
.split('\n')
.map((row) => (terminalWidth > 1 ? ` ${row}${terminalWidth > 2 ? ' ' : ''}` : row))
.join('\n')}
</Text>
<Box
position="absolute"
width="100%"
height="100%"
flexDirection="column"
alignItems="center"
justifyContent="flex-end"
>
<Text dimColor>[ space to skip ]</Text>
</Box>
</Box>
)
}
+54 -15
View File
@@ -276,18 +276,20 @@ export function EffortSlider({
slider,
width,
compact,
pressed,
pressed = false,
hovered,
focused,
focused = false,
onElement,
showStops = false,
}: {
slider: ChatPanelSlider
width: number
compact: boolean
pressed: boolean
pressed?: boolean
hovered?: boolean
focused: boolean
focused?: boolean
onElement?: (element: DOMElement | null) => void
showStops?: boolean
}): ReactElement {
const { accent, hover, selection } = useTheme()
const activeIndex = Math.max(
@@ -301,6 +303,9 @@ export function EffortSlider({
const thumbCenter = positions[activeIndex] ?? 0
const thumbStart = Math.max(0, Math.min(width - 3, thumbCenter - 1))
const track = Array.from({ length: width }, () => '─')
if (showStops) {
for (const position of positions) track[position] = '┬'
}
track.fill('█', thumbStart, thumbStart + 3)
const ticks = Array.from({ length: width }, () => ' ')
for (const position of positions) {
@@ -308,23 +313,57 @@ export function EffortSlider({
}
return (
<Box marginTop={compact ? 0 : 1} flexDirection="column" alignItems="center">
<Box>
<Text dimColor>{slider.label} </Text>
<Text
{...(slider.disabled ? { dimColor: true } : { color: pressed ? hover : accent })}
bold={slider.disabled !== true}
>
{activeOption?.label ?? 'Unavailable'}
</Text>
</Box>
{!showStops ? (
<Box>
<Text dimColor>{slider.label} </Text>
<Text
{...(slider.disabled ? { dimColor: true } : { color: pressed ? hover : accent })}
bold={slider.disabled !== true}
>
{activeOption?.label ?? 'Unavailable'}
</Text>
</Box>
) : null}
<Box
ref={onElement}
width={width}
backgroundColor={hovered || pressed || focused ? selection : undefined}
backgroundColor={!showStops && (hovered || pressed || focused) ? selection : undefined}
flexDirection="column"
>
<Text {...(slider.disabled ? { dimColor: true } : { color: accent })}>{track.join('')}</Text>
{!compact ? <Text dimColor>{ticks.join('')}</Text> : null}
{showStops ? (
<Box width={width} height={1}>
{slider.options.map((option, index) => {
// Label cells follow sliderOptionAtMouse's rounding, including ties.
const intervals = Math.max(1, slider.options.length - 1)
const span = Math.max(1, width - 1)
const start = index === 0 ? 0 : Math.min(width, Math.ceil(((index - 0.5) * span) / intervals))
const end =
index === slider.options.length - 1
? width
: Math.min(width, Math.ceil(((index + 0.5) * span) / intervals))
return (
<Box
key={option.id}
width={Math.max(0, end - start)}
flexShrink={0}
justifyContent={index === 0 ? 'flex-start' : index === positions.length - 1 ? 'flex-end' : 'center'}
overflow="hidden"
>
<Text
{...(index === activeIndex ? { color: accent } : {})}
dimColor={index !== activeIndex}
wrap="truncate-end"
>
{option.label}
</Text>
</Box>
)
})}
</Box>
) : !compact ? (
<Text dimColor>{ticks.join('')}</Text>
) : null}
</Box>
</Box>
)
+1 -13
View File
@@ -62,27 +62,15 @@ export function PanelOverlay({
height,
children,
onElement,
bottomOffset,
}: {
width: number
height?: number
children: ReactNode
onElement?: (element: DOMElement | null) => void
/** Docks the panel at the bottom left, this many rows above the screen's bottom edge. */
bottomOffset?: number
}): ReactElement {
const theme = useTheme()
const docked = bottomOffset !== undefined
return (
<Box
position="absolute"
width="100%"
height="100%"
alignItems={docked ? 'flex-end' : 'center'}
justifyContent={docked ? 'flex-start' : 'center'}
padding={1}
{...(docked ? { paddingBottom: bottomOffset } : {})}
>
<Box position="absolute" width="100%" height="100%" alignItems="center" justifyContent="center" padding={1}>
<Box
ref={onElement}
width={width}
+2 -32
View File
@@ -20,7 +20,7 @@ import {
} from './presentation.js'
import { AgentsPanel } from './agents-panel.js'
import { ExportPanel } from './export-panel.js'
import { EffortSlider, ModelPicker } from './model-panel.js'
import { ModelPicker } from './model-panel.js'
import { RenamePanel } from './rename-panel.js'
import { SessionsPanel } from './sessions-panel.js'
import { PanelItemHeader, PanelOverlay, PanelTitle } from './panel-components.js'
@@ -75,7 +75,6 @@ export function ResourcePanel({
onFilterElement,
onSearchElement,
onSliderElement,
commandDeckHeight,
}: {
panel: ChatPanel
context: ChatContextUsage
@@ -103,7 +102,6 @@ export function ResourcePanel({
onFilterElement?: (id: string, element: DOMElement | null) => void
onSearchElement?: (element: DOMElement | null) => void
onSliderElement?: (element: DOMElement | null) => void
commandDeckHeight?: number
}): ReactElement {
const palette = useTheme()
const { surface, warning, selection, accent } = palette
@@ -122,7 +120,7 @@ export function ResourcePanel({
? 60
: checklist
? 96
: panel.kind === 'context' || panel.kind === 'effort'
: panel.kind === 'context'
? 52
: panel.kind === 'permission' && panel.diff
? 100
@@ -231,34 +229,6 @@ export function ResourcePanel({
/>
)
}
if (panel.kind === 'effort' && panel.slider) {
const [modelName = '', modelId = ''] = panel.body?.split('\n') ?? []
return (
<PanelOverlay
width={width}
{...(commandDeckHeight !== undefined ? { bottomOffset: commandDeckHeight + 1 } : {})}
{...(onPanelElement ? { onElement: onPanelElement } : {})}
>
<Box flexDirection="column" alignItems="center" overflow="hidden">
<Text bold color={accent} wrap="truncate-end">
Reasoning effort
</Text>
<Text dimColor wrap="truncate-end">
{modelName || modelId}
</Text>
<EffortSlider
slider={panel.slider}
width={Math.max(18, Math.min(36, width - 8))}
compact={false}
pressed={pressedSlider}
{...(hoveredSlider !== undefined ? { hovered: hoveredSlider } : {})}
focused
{...(onSliderElement ? { onElement: onSliderElement } : {})}
/>
</Box>
</PanelOverlay>
)
}
if (panel.kind === 'context') {
const used = context.projectedTokens ?? context.currentTokens
return (
+90 -42
View File
@@ -4,16 +4,18 @@ import stringWidth from 'string-width'
import type { ChatEntry, ChatPanel, ChatSnapshot } from '../chat/controller.js'
import { DEFAULT_CHAT_SETTINGS, type ThemeColors } from '../chat/types.js'
import { modelDisplayName } from '../model/display.js'
import { detailPageSize, type MetadataTarget } from './interaction.js'
import { graphemes } from '../terminal/composer.js'
import { detailPageSize } from './interaction.js'
import { getTheme } from './theme.js'
const METADATA_START_COLUMN = 2
interface MetadataPlacement {
target: MetadataTarget
target: 'model' | 'cwd' | 'settings'
text: string
width: number
alignment: 'flex-start' | 'center' | 'flex-end'
controls?: { target: 'model' | 'effort' | 'context'; text: string }[]
}
export function permissionPageSize(terminalHeight: number, optionRows: number): number {
@@ -189,7 +191,10 @@ 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) {
if (!context.contextWindow) {
return `${'░'.repeat(width)} —`
}
if (used === undefined) {
return `${'░'.repeat(width)} 0%`
}
const percentage = (used / context.contextWindow) * 100
@@ -220,43 +225,98 @@ export function contextColor(context: ChatSnapshot['context'], palette: ThemeCol
}
export function metadataPlacements(snapshot: ChatSnapshot, terminalWidth: number): MetadataPlacement[] {
const available = Math.max(1, terminalWidth - METADATA_START_COLUMN)
const baseWidth = Math.floor(available / 3)
const remainder = available % 3
const widths = [baseWidth + (remainder > 0 ? 1 : 0), baseWidth + (remainder > 1 ? 1 : 0), baseWidth] as const
const contextLabel = widths[2] >= 20 ? 'context ' : ''
const contextBarWidth = Math.max(1, Math.min(10, widths[2] - contextLabel.length - 7))
const separatorWidth = 2
const available = Math.max(0, Math.floor(terminalWidth) - METADATA_START_COLUMN)
const model = modelDisplayName(snapshot.runtime.model)
const effort = model && snapshot.runtime.effort ? ` • ${snapshot.runtime.effort}` : ''
const modelLabel = model ? `${model}${effort}` : (snapshot.runtime.effort ?? '')
const settingsText = truncateMetadata('/settings', available)
const settingsWidth = stringWidth(settingsText)
const leftWidth = Math.max(0, available - settingsWidth - separatorWidth)
const context = formatContext(snapshot.context)
const minimumContextWidth = stringWidth(formatContext(snapshot.context, 1))
const controlSeparatorWidth = modelLabel ? stringWidth(' • ') : 0
const minimumModelWidth = Math.min(stringWidth(modelLabel), Math.max(1, stringWidth(effort) + 1))
const contextBudget = Math.max(0, leftWidth - minimumModelWidth - controlSeparatorWidth)
const contextWidth =
contextBudget >= minimumContextWidth
? Math.min(
stringWidth(context),
Math.max(minimumContextWidth, leftWidth - stringWidth(modelLabel) - controlSeparatorWidth)
)
: 0
const modelWidth = Math.min(
stringWidth(modelLabel),
Math.max(0, leftWidth - contextWidth - (contextWidth ? controlSeparatorWidth : 0))
)
const showEffort = !model || (effort && modelWidth > stringWidth(effort))
const modelName = truncateMetadata(model, modelWidth - (showEffort ? stringWidth(effort) : 0))
const effortLabel = showEffort ? truncateMetadata(snapshot.runtime.effort ?? '', modelWidth) : ''
const controls = [
{ target: 'model' as const, text: modelName },
{ target: 'effort' as const, text: effortLabel },
{
target: 'context' as const,
text: contextWidth ? formatContext(snapshot.context, contextWidth - minimumContextWidth + 1) : '',
},
].filter((control) => control.text)
const modelText = controls.map((control) => control.text).join(' • ')
const pathMinimumStart = modelText ? stringWidth(modelText) + separatorWidth : 0
const pathMaximumEnd = Math.max(0, available - settingsWidth - separatorWidth)
const pathText = truncateMetadata(snapshot.runtime.cwd, Math.max(0, pathMaximumEnd - pathMinimumStart), true)
const pathWidth = stringWidth(pathText)
const pathStart = Math.max(
pathMinimumStart,
Math.min(Math.floor((available - pathWidth) / 2), pathMaximumEnd - pathWidth)
)
const modelSlotWidth = pathText ? pathStart : modelText ? available - settingsWidth : 0
return [
{
target: 'model' as const,
value: [modelDisplayName(snapshot.runtime.model), snapshot.runtime.effort].filter(Boolean).join(' · '),
truncate: truncateEnd,
text: modelText,
width: modelSlotWidth,
alignment: 'flex-start' as const,
controls,
},
{
target: 'cwd' as const,
value: snapshot.runtime.cwd,
truncate: truncateMiddle,
alignment: 'center' as const,
},
{
target: 'context' as const,
// Without a known context window there's nothing to measure against, so the meter is hidden.
value: snapshot.context.contextWindow ? `${contextLabel}${formatContext(snapshot.context, contextBarWidth)}` : '',
truncate: truncateEnd,
text: pathText,
width: pathWidth + (modelText ? 0 : pathStart),
alignment: 'flex-end' as const,
},
].flatMap((value, index): MetadataPlacement[] => {
const width = widths[index]!
const text = value.truncate(value.value, width)
const placement = {
target: value.target,
text,
width,
alignment: value.alignment,
{
target: 'settings' as const,
text: settingsText,
width: available - modelSlotWidth - pathWidth,
alignment: 'flex-end' as const,
},
].filter((placement) => placement.text)
}
function truncateMetadata(value: string, width: number, middle = false): string {
if (width <= 0) {
return ''
}
if (stringWidth(value) <= width) {
return value
}
if (width === 1 && /^[\x20-\x7e]*$/u.test(value)) {
return value[0]!
}
const segments = graphemes(value)
let suffix = ''
const suffixWidth = middle ? Math.ceil((width - 1) * 0.7) : 0
while (segments.length && stringWidth(segments.at(-1)! + suffix) <= suffixWidth) {
suffix = segments.pop()! + suffix
}
let prefix = ''
for (const segment of segments) {
if (stringWidth(prefix + segment + suffix) > width - 1) {
break
}
return text ? [placement] : []
})
prefix += segment
}
return `${prefix}…${suffix}`
}
export function formatTurnMetrics(durationMs: number | undefined, totalTokens: number | undefined): string {
@@ -299,18 +359,6 @@ function truncateEnd(value: string, maxLength: number): string {
return `${value.slice(0, maxLength - 1)}…`
}
function truncateMiddle(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value
}
if (maxLength <= 1) {
return value.slice(0, maxLength)
}
const suffixLength = Math.ceil((maxLength - 1) * 0.7)
const prefixLength = maxLength - 1 - suffixLength
return `${value.slice(0, prefixLength)}…${value.slice(-suffixLength)}`
}
export function formatValue(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
+29 -18
View File
@@ -3,9 +3,9 @@ import stringWidth from 'string-width'
import {
DEFAULT_MAX_PROMPT_ROWS,
MIN_PROMPT_HEIGHT,
MIN_PROMPT_ROWS,
PROMPT_PADDING_WIDTH,
promptEditorHeight,
promptViewport,
type PromptViewportRow,
} from '../terminal/composer.js'
@@ -38,46 +38,48 @@ export function PromptEditor({
cursor,
panelStatus,
busyStatus,
agentName,
actionableCommandToken,
width = 80,
maxRows = DEFAULT_MAX_PROMPT_ROWS,
maxHeight = Infinity,
party = false,
partyFrame = 0,
animateCursor = true,
children,
}: {
input: string
cursor: number
panelStatus?: string
busyStatus?: string
agentName: string
actionableCommandToken?: string
width?: number
maxRows?: number
maxHeight?: number
party?: boolean
partyFrame?: number
animateCursor?: boolean
children?: ReactNode
}): ReactElement {
const theme = useTheme()
const status = busyStatus ?? panelStatus
if (status) {
const height = promptEditorHeight(input, cursor, width, maxRows, Boolean(status && !children), party, maxHeight)
if (children || status) {
return (
<PromptSurface width={width} height={MIN_PROMPT_HEIGHT} party={party} partyFrame={partyFrame}>
<Text {...(busyStatus ? { color: theme.accent } : { dimColor: true })}>{status}</Text>
<PromptSurface width={width} height={height} party={party} partyFrame={partyFrame}>
{children ?? <Text {...(busyStatus ? { color: theme.accent } : { dimColor: true })}>{status}</Text>}
</PromptSurface>
)
}
const shellMode = input.startsWith('!')
const promptPrefix = shellMode ? '◆ shell ' : '◆ '
const promptPrefix = shellMode ? '◆ shell ' : ''
const prefixWidth = stringWidth(promptPrefix)
const continuationPrefix = ' '.repeat(prefixWidth)
const rows = promptViewport(
input,
cursor,
width - prefixWidth - PROMPT_PADDING_WIDTH,
Math.max(MIN_PROMPT_ROWS, maxRows)
Math.max(1, Math.min(Math.max(MIN_PROMPT_ROWS, maxRows), height - (party ? 2 : 0)))
)
const height = Math.max(MIN_PROMPT_HEIGHT, rows.length + (party && input ? 2 : 1))
return (
<PromptSurface width={width} height={height} party={party} partyFrame={partyFrame}>
{input ? (
@@ -95,12 +97,9 @@ export function PromptEditor({
))
) : (
<Box height={1} flexShrink={0}>
<Text color={theme.accent} bold>
{'◆ '}
</Text>
<Text>
<Text wrap="truncate-end">
<BlinkingCursor animate={animateCursor} />
<Text dimColor>Message {agentName}</Text>
<Text dimColor>{promptPlaceholder(width - prefixWidth - PROMPT_PADDING_WIDTH - 1 - (party ? 2 : 0))}</Text>
</Text>
</Box>
)}
@@ -108,6 +107,17 @@ export function PromptEditor({
)
}
function promptPlaceholder(width: number): string {
return (
[
'Enter to send • Ctrl+J for newline • / for commands',
'Enter send • Ctrl+J newline • / commands',
'Enter send • / commands',
'Enter send',
].find((hint) => stringWidth(hint) <= width) ?? 'Enter'
)
}
function PromptSurface({
width,
height,
@@ -131,6 +141,7 @@ function PromptSurface({
flexShrink={0}
paddingX={1}
flexDirection="column"
overflow="hidden"
>
{children}
</Box>
@@ -139,7 +150,7 @@ function PromptSurface({
const innerWidth = Math.max(1, width - 2)
const innerHeight = height - 2
return (
<Box height={height} width={width} flexShrink={0} flexDirection="column">
<Box height={height} width={width} flexShrink={0} flexDirection="column" overflow="hidden">
<PartyBorder length={width} frame={partyFrame} top />
<Box height={innerHeight} width={width} flexShrink={0}>
<PartyBorder length={innerHeight} offset={2 * width + innerHeight} frame={partyFrame} reverse vertical />
@@ -221,16 +232,16 @@ function EditorRow({
const afterHighlight = row.after.slice(0, afterHighlightLength)
return (
<Text wrap="truncate-end">
{beforeHighlight ? <Text color={theme.hover}>{beforeHighlight}</Text> : null}
{beforeHighlight ? <Text color={theme.accent}>{beforeHighlight}</Text> : null}
{row.before.slice(token.length)}
{row.current === undefined ? null : (
<BlinkingCursor
character={row.current}
animate={animateCursor}
{...(currentOffset < token.length ? { color: theme.hover } : {})}
{...(currentOffset < token.length ? { color: theme.accent } : {})}
/>
)}
{afterHighlight ? <Text color={theme.hover}>{afterHighlight}</Text> : null}
{afterHighlight ? <Text color={theme.accent}>{afterHighlight}</Text> : null}
{row.after.slice(afterHighlightLength)}
</Text>
)
@@ -22,9 +22,13 @@ export function setupBrandFrame(
): { 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 availableHeight = Math.max(1, terminalHeight - SETUP_CONTENT_MIN_HEIGHT - 2)
const fullHeight = frogStartupHeight(brandWidth, Number.POSITIVE_INFINITY)
const height = availableHeight >= fullHeight + 2 ? fullHeight : frogStartupHeight(brandWidth, availableHeight)
// Compact startup sizing reserves eight surrounding rows; setup already reserves its content and padding.
const height =
availableHeight >= fullHeight
? fullHeight
: Math.min(availableHeight, frogStartupHeight(brandWidth, availableHeight + 8))
return { left, width: brandWidth, height }
}
+35 -26
View File
@@ -8,7 +8,8 @@ import {
type Dispatch,
type SetStateAction,
} from 'react'
import { Box, useInput, useStdout, useWindowSize, type DOMElement } from 'ink'
import { Box, useInput, useStdout, type DOMElement } from 'ink'
import { useTerminalSize } from '../../terminal/size.js'
import type { HarnessAgentConfig } from '@strands-agents/harness'
import stringWidth from 'string-width'
@@ -27,7 +28,7 @@ import { configurationFromStore, type SetupChange } from '../../agent-configurat
import type { ChatSettings } from '../../chat/types.js'
import type { SettingsCategory } from '../../settings.js'
import { DEFAULT_SETTINGS_CATEGORY, SETTINGS_CATEGORIES } from '../../settings.js'
import { parseMouseInput } from '../../terminal/mouse-input.js'
import { parseMouseInput, type MouseInput } from '../../terminal/mouse-input.js'
import { emptyEditor, graphemes, reduceInputSequence } from '../../terminal/composer.js'
import { errorMessage, sanitizeTerminalText } from '../../terminal/sanitize.js'
import { canChooseDirectory, chooseAgentProject, chooseDirectory } from '../../terminal/directory-picker.js'
@@ -67,9 +68,9 @@ import {
setupStepProgress,
wizardSettingsRows,
} from './steps.js'
import { useBrandAnimation } from '../startup-view.js'
import { useBrandAnimation, useBrandAnimationFrame } from '../startup-view.js'
import { SetupBrand, setupBrandFrame } from './brand.js'
import { OpeningMenu } from './opening-menu.js'
import { OpeningMenu, openingGridLayout } from './opening-menu.js'
import { SetupSettingsPanel, type SetupSettingsChoiceTarget } from './settings-panel.js'
import { EffortSlider } from '../model-panel.js'
import { ProviderList } from '../provider-list.js'
@@ -126,7 +127,7 @@ function SetupWizardContent({
checkForUpdate?(): Promise<string | undefined>
}): ReactElement {
const { stdout } = useStdout()
const { columns, rows: terminalRows } = useWindowSize()
const { columns, rows: terminalRows } = useTerminalSize()
const [flow, setFlow] = useState<SetupFlow>()
const [step, setStep] = useState(appearanceOnly ? APPEARANCE_STEP : 0)
const [settingsReturn, setSettingsReturn] = useState<{
@@ -225,6 +226,7 @@ function SetupWizardContent({
const settingsElement = useRef<DOMElement | undefined>(undefined)
const frogElement = useRef<DOMElement | undefined>(undefined)
const frogPress = useRef<{ column: number; row: number } | undefined>(undefined)
const hoverPointer = useRef<MouseInput | undefined>(undefined)
const pressedElement = useRef<SetupControl | undefined>(undefined)
const width = Math.max(1, columns)
const height = Math.max(1, terminalRows)
@@ -325,9 +327,8 @@ function SetupWizardContent({
const lockupHeight = brandFrame.height
const showBrand = lockupHeight > 1
const brandHeight = showBrand ? lockupHeight + 2 : 0
const openingColumns = lockupWidth >= 64 ? 2 : 1
const { columns: openingColumns, rowGap: openingRowGap } = openingGridLayout(lockupWidth)
const openingRows = Math.ceil(OPENING_CHOICES.length / openingColumns)
const openingRowGap = openingColumns === 2 ? 2 : 1
const openingContentHeight = height - brandHeight - navigationHeight - (error ? 2 : 0)
const openingTopGap =
openingContentHeight >= openingRows * (openingColumns === 2 ? 9 : 5) + (openingRows - 1) * openingRowGap + 3
@@ -1423,9 +1424,23 @@ function SetupWizardContent({
}, [stdout])
useEffect(() => {
hoverPointer.current = undefined
setHoveredControl(undefined)
pressedElement.current = undefined
}, [step, flow, quickstartProvider, selecting?.field, modelViewportStart, width, height])
}, [step, flow, quickstartProvider, selecting?.field, modelViewportStart])
useEffect(() => {
pressedElement.current = undefined
frogPress.current = undefined
const mouse = hoverPointer.current
if (step !== 0 || !mouse) {
setHoveredControl(undefined)
return
}
const row = elementAtMouse(rowElements.current, mouse)
const settings = settingsElement.current && elementContainsMouse(settingsElement.current, mouse)
setHoveredControl(row ?? (settings ? 'settings' : undefined))
}, [width, height])
useEffect(() => {
if (isProviderSetup && !modelPanelVisible && (modelSearchFocused || selection >= quickstartModelOffset)) {
@@ -1474,14 +1489,17 @@ function SetupWizardContent({
})
}, [awsDiscovery.credentialStatus, config, effectiveEnvironment, isProviderSetup, ollamaDiscovery, readyProviders])
const frogElapsedMs = useBrandAnimation(frogAnimationId)
const frogClockMs = useBrandAnimation(frogAnimationId)
const frogElapsedMs = useBrandAnimationFrame(frogClockMs, brandFrame.width, brandFrame.height)
useInput((input, key) => {
if (appearanceOpen || panelTransition.transitioning) return
const mouse = parseMouseInput(input)
if (mouse) {
hoverPointer.current = mouse
const scroll = mouseScrollDirection(mouse)
if (scroll !== undefined) {
hoverPointer.current = undefined
setHoveredControl(undefined)
if (isProviderSetup && modelListElement.current && elementContainsMouse(modelListElement.current, mouse)) {
setModelViewportStart((start) =>
@@ -1634,6 +1652,7 @@ function SetupWizardContent({
return
}
hoverPointer.current = undefined
if (key.ctrl && input === 'c') {
onCancel?.(130)
return
@@ -1962,26 +1981,16 @@ function SetupWizardContent({
]
const settingsHovered = hoveredControl === 'settings'
const settingsBackHovered = isSettings && settingsHovered
const settingsButton = !appearanceOnly ? (
<Box
ref={(element) => {
settingsElement.current = element ?? undefined
}}
width={11}
height={1}
paddingX={1}
flexShrink={0}
alignItems="center"
justifyContent="center"
backgroundColor={
settingsBackHovered
? backHoverBackground
: (controlBackground('settings', focusedAction === 'settings') ?? COMMAND_DECK_BACKGROUND)
}
>
<Text color={settingsBackHovered ? palette.muted : focusedAction === 'settings' ? accent : palette.muted}>
{isSettings ? 'Back' : 'Settings'}
<Text color={settingsHovered || focusedAction === 'settings' ? accent : palette.foreground}>
{isSettings ? 'Back' : '/settings'}
</Text>
</Box>
) : null
@@ -1993,7 +2002,7 @@ function SetupWizardContent({
paddingX={1}
flexDirection="column"
overflow="hidden"
backgroundColor={palette.background}
backgroundColor={palette.canvas}
>
{showBrand ? (
<SetupBrand
@@ -2687,24 +2696,23 @@ function SetupWizardContent({
</Fade>
)}
{width < 32 ? (
<Box height={navigationHeight} flexShrink={0} flexDirection="column">
<Box height={navigationHeight} flexShrink={0} flexDirection="column" paddingX={1}>
<Box justifyContent="center">
<Text dimColor wrap="truncate-end">
{navigationHints[0]}
</Text>
</Box>
<Box height={1} alignItems="center">
{settingsButton}
<Box flexGrow={1} justifyContent="center" overflow="hidden">
<Text dimColor wrap="truncate-end">
{navigationHints[1]}
</Text>
</Box>
{settingsButton}
</Box>
</Box>
) : (
<Box height={navigationHeight} flexShrink={0} alignItems="center">
{settingsButton}
<Box height={navigationHeight} flexShrink={0} alignItems="flex-end" paddingX={1}>
<Box flexGrow={1} flexDirection="column" alignItems="center" overflow="hidden">
{navigationHints.map((hint) => (
<Text key={hint} dimColor wrap="truncate-end">
@@ -2712,6 +2720,7 @@ function SetupWizardContent({
</Text>
))}
</Box>
{settingsButton}
</Box>
)}
{appearanceOpen ? (
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { useStdout } from 'ink'
import stringWidth from 'string-width'
import { Box, Text, useTheme } from '../theme.js'
@@ -47,7 +48,8 @@ export function SetupProgress({
return (): void => clearInterval(timer)
}, [enabled, target])
const barWidth = Math.max(3, width - label.length - 2)
const labelWidth = stringWidth(label)
const barWidth = width > labelWidth + 1 ? Math.max(1, width - labelWidth - 2) : 0
const eighths = Math.round(progress * barWidth * 8)
const fullBlocks = Math.floor(eighths / 8)
const partialBlock = PARTIAL_BLOCKS[eighths % 8]
@@ -55,13 +57,17 @@ export function SetupProgress({
return (
<Box width={width} height={1} flexShrink={0}>
<Text color={accent}>
{'█'.repeat(fullBlocks)}
{partialBlock}
</Text>
<Text dimColor>{'░'.repeat(remainingBlocks)}</Text>
<Box width={barWidth} flexShrink={0}>
<Text color={accent}>
{'█'.repeat(fullBlocks)}
{partialBlock}
</Text>
<Text dimColor>{'░'.repeat(remainingBlocks)}</Text>
</Box>
<Box flexGrow={1} />
<Text dimColor>{label}</Text>
<Text dimColor wrap="truncate-end">
{label}
</Text>
</Box>
)
}
+76 -4
View File
@@ -1,13 +1,16 @@
import { useEffect, useState, type ReactElement } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { Box, type DOMElement } from 'ink'
import type { FrogTheme } from '../chat/controller.js'
import {
FROG_BRAND_EASTER_EGG_DURATION_MS,
FROG_FULL_LOCKUP_MIN_WIDTH,
FROG_INTRO_DURATION_MS,
frogStartupHeight,
frogStartupHitbox,
frogStartupWidth,
renderFrogBrandEasterEggFrame,
renderFrogSpiralFrame,
renderFrogStartupLockup,
} from './frog-intro-renderer.js'
import { Text, useTheme } from './theme.js'
@@ -16,6 +19,7 @@ export function StartupView({
terminalWidth,
availableHeight,
animate,
introStartedAt,
frogBrandElapsedMs,
onFrogElement,
theme,
@@ -26,6 +30,7 @@ export function StartupView({
terminalWidth: number
availableHeight: number
animate: boolean
introStartedAt?: number
frogBrandElapsedMs?: number
onFrogElement?: (element: DOMElement | null) => void
theme: FrogTheme
@@ -43,12 +48,49 @@ export function StartupView({
// Sized to the artwork so the column centers it.
const lockupWidth = frogStartupWidth(maxWidth, frogStartupHeight(maxWidth, availableHeight))
const lockupHeight = frogStartupHeight(lockupWidth, availableHeight)
const fullLockup =
lockupWidth >= FROG_FULL_LOCKUP_MIN_WIDTH && lockupHeight >= frogStartupHeight(lockupWidth, Infinity)
const paddingTop = fullLockup ? 6 : lockupHeight > 2 ? 2 : 1
// The hopping frog needs more headroom than the resting artwork.
const headerHeight = fullLockup ? 24 : paddingTop + lockupHeight
const introElapsedMs = useStartupIntro(introStartedAt, animate && fullLockup)
const frogHitbox = frogStartupHitbox(lockupWidth, lockupHeight)
const brandElapsedMs = frogBrandElapsedMs ?? FROG_BRAND_EASTER_EGG_DURATION_MS
const brandElapsedMs = useBrandAnimationFrame(
frogBrandElapsedMs ?? FROG_BRAND_EASTER_EGG_DURATION_MS,
lockupWidth,
lockupHeight
)
const brandProgress = animate ? brandElapsedMs / FROG_BRAND_EASTER_EGG_DURATION_MS : brandElapsedMs < 800 ? 0.54 : 1
if (introElapsedMs < FROG_INTRO_DURATION_MS && frogBrandElapsedMs === undefined) {
return (
<Box width="100%" height={headerHeight} flexShrink={0} overflow="hidden">
<Text>
{renderFrogSpiralFrame(
maxWidth,
headerHeight,
introElapsedMs / FROG_INTRO_DURATION_MS,
introElapsedMs,
true,
theme,
frogOptions,
lockupHeight,
Math.floor((maxWidth - lockupWidth) / 2),
paddingTop
)}
</Text>
</Box>
)
}
return (
<Box flexDirection="column" alignItems="center" paddingTop={lockupHeight > 2 ? 2 : 1} width="100%" flexShrink={0}>
<Box width={lockupWidth} height={lockupHeight} overflow="hidden" position="relative">
<Box
flexDirection="column"
alignItems="center"
paddingTop={paddingTop}
height={headerHeight}
width="100%"
flexShrink={0}
>
<Box width={lockupWidth} height={lockupHeight} flexShrink={0} overflow="hidden" position="relative">
<Text>
{frogBrandElapsedMs === undefined
? renderFrogStartupLockup(lockupWidth, true, partyElapsedMs, theme, party, frogOptions, lockupHeight)
@@ -79,6 +121,36 @@ export function StartupView({
)
}
function useStartupIntro(startedAt: number | undefined, animate: boolean): number {
const [elapsedMs, setElapsedMs] = useState(() =>
startedAt === undefined ? FROG_INTRO_DURATION_MS : Math.min(FROG_INTRO_DURATION_MS, Date.now() - startedAt)
)
useEffect(() => {
if (startedAt === undefined || !animate || Date.now() - startedAt >= FROG_INTRO_DURATION_MS) {
setElapsedMs(FROG_INTRO_DURATION_MS)
return
}
const timer = setInterval(() => {
const elapsed = Math.min(FROG_INTRO_DURATION_MS, Date.now() - startedAt)
setElapsedMs(elapsed)
if (elapsed >= FROG_INTRO_DURATION_MS) clearInterval(timer)
}, 50)
return (): void => clearInterval(timer)
}, [startedAt, animate])
return animate ? elapsedMs : FROG_INTRO_DURATION_MS
}
export function useBrandAnimationFrame(elapsedMs: number, width: number, height: number): number {
const previous = useRef({ elapsedMs, complete: false })
const fits = width >= FROG_FULL_LOCKUP_MIN_WIDTH && height >= frogStartupHeight(width, Infinity)
// A decreasing clock starts a new click animation; expanding the canvas does not.
const complete = !fits || (elapsedMs >= previous.current.elapsedMs && previous.current.complete)
useLayoutEffect(() => {
previous.current = { elapsedMs, complete }
}, [elapsedMs, complete])
return complete ? FROG_BRAND_EASTER_EGG_DURATION_MS : elapsedMs
}
export function useBrandAnimation(animationId: number | undefined, animate = true): number {
const [elapsedMs, setElapsedMs] = useState(FROG_BRAND_EASTER_EGG_DURATION_MS)
+8 -3
View File
@@ -9,11 +9,11 @@ import {
type ThemeColors,
} from '../chat/types.js'
import { detectColorMode } from './theme-detection.js'
import { useFadeColor } from './fade-in.js'
import { useFadeAnsi, useFadeColor } from './fade-in.js'
export { detectColorMode } from './theme-detection.js'
export type Theme = ThemeColors & { mode: ResolvedColorMode }
export type Theme = ThemeColors & { mode: ResolvedColorMode; canvas: string | undefined }
type ThemeSettings = Pick<ChatSettings, 'frogTheme' | 'colorMode' | 'customTheme'>
const BASE_COLORS = {
@@ -75,6 +75,8 @@ export function getTheme(settings: ThemeSettings, detectedMode?: ResolvedColorMo
accent: ACCENTS[base][mode],
frog: FROG_COLORS[base][mode],
...(settings.frogTheme === 'custom' ? settings.customTheme[mode] : {}),
// Preset palette backgrounds support controls and previews; the terminal owns the canvas.
canvas: settings.frogTheme === 'custom' ? settings.customTheme[mode].background : undefined,
mode,
}
}
@@ -153,13 +155,16 @@ export function Text({
: (backgroundColor ?? parent?.backgroundColor)
const fadedForeground = useFadeColor(foreground)
const fadedBackground = useFadeColor(background)
const fadedText = useFadeAnsi(typeof children === 'string' ? children : '')
return (
<InkText
{...props}
{...(fadedForeground ? { color: fadedForeground } : {})}
{...(fadedBackground ? { backgroundColor: fadedBackground } : {})}
>
<TextStyleContext value={{ backgroundColor: background }}>{children}</TextStyleContext>
<TextStyleContext value={{ backgroundColor: background }}>
{typeof children === 'string' ? fadedText : children}
</TextStyleContext>
</InkText>
)
}
+2 -13
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { memo, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { Box, measureElement, type DOMElement } from 'ink'
import type { ChatNotice, ChatSnapshot, ChatTurn } from '../chat/controller.js'
@@ -43,7 +43,6 @@ export const TranscriptViewport = memo(function TranscriptViewport({
settings,
scrollOffset,
layoutKey,
synchronousLayout = false,
onMaxScrollChange,
expandedToolGroups = EMPTY_TOOL_GROUPS,
onToolGroupElement,
@@ -55,7 +54,6 @@ export const TranscriptViewport = memo(function TranscriptViewport({
settings: ChatSnapshot['settings']
scrollOffset: number
layoutKey: string
synchronousLayout?: boolean
onMaxScrollChange?: (maximum: number) => void
expandedToolGroups?: ReadonlySet<string>
onToolGroupElement?: (id: string, element: DOMElement | null) => void
@@ -172,16 +170,7 @@ export const TranscriptViewport = memo(function TranscriptViewport({
measurementRenders.current.count++
setMeasurementVersion((version) => version + 1)
}
useLayoutEffect(() => {
if (synchronousLayout) {
measureLayout()
}
})
useEffect(() => {
if (!synchronousLayout) {
measureLayout()
}
})
useLayoutEffect(measureLayout)
return (
<Box
+2 -2
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'
import { URL } from 'node:url'
await import('#ink-text-cache')
await import('#ink-text-cache')
await import('../../dist/src/tui/terminal/ink.js')
await import('../../dist/src/tui/terminal/ink.js')
const entrypoint = import.meta.resolve('ink')
const { default: measure } = await import(new URL('./measure-text.js', entrypoint))
+4 -2
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env node
import { ChatController } from '../../src/tui/chat/controller.js'
import { runInkChat } from '../../src/tui/run.js'
import { WorkspaceSandbox } from '../../src/tui/workspace/sandbox.js'
await import('../../dist/src/tui/terminal/ink.js')
const { ChatController } = await import('../../src/tui/chat/controller.js')
const { runInkChat } = await import('../../src/tui/run.js')
const sandbox = new WorkspaceSandbox(process.cwd())
let shellAbort
const backend = {
+53 -12
View File
@@ -15,18 +15,19 @@ import termios
import time
ANSI_ESCAPE = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
CHAT_READY = b"\x1b[?1003l\x1b[?1002h"
CHAT_READY = b"\x1b[?1002l\x1b[?1003h"
def main() -> int:
command = sys.argv[1:]
ready_marker = b"Message Lifecycle Fixture"
ready_marker = b"Enter to send"
shell_mode = os.environ.get("STRANDS_CLI_TEST_SHELL_MODE")
frog_mode = os.environ.get("STRANDS_CLI_TEST_FROG_MODE") == "true"
skip_intro = os.environ.get("STRANDS_CLI_TEST_SKIP_INTRO") == "true"
startup_typing = os.environ.get("STRANDS_CLI_TEST_STARTUP_TYPING") == "true"
intro = os.environ.get("STRANDS_CLI_TEST_INTRO") == "true"
resize = os.environ.get("STRANDS_CLI_TEST_RESIZE") == "true"
master, slave = pty.openpty()
rows = 40 if skip_intro else 20 if intro else 30
rows = 40 if startup_typing else 20 if intro else 30
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, 100, 0, 0))
initial_terminal = termios.tcgetattr(slave)
process = subprocess.Popen(
@@ -52,18 +53,18 @@ def main() -> int:
return
transcript.extend(chunk)
def wait_for(markers: list[bytes], timeout: float = 8.0, *, styled: bool = True) -> None:
def wait_for(markers: list[bytes], timeout: float = 8.0, *, styled: bool = True, start: int = 0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
pump()
output = bytes(transcript) if styled else ANSI_ESCAPE.sub(b"", bytes(transcript))
output = bytes(transcript[start:]) if styled else ANSI_ESCAPE.sub(b"", bytes(transcript[start:]))
if all(marker in output for marker in markers):
return
if process.poll() is not None:
break
select.select([master], [], [], 0.05)
pump()
output = bytes(transcript) if styled else ANSI_ESCAPE.sub(b"", bytes(transcript))
output = bytes(transcript[start:]) if styled else ANSI_ESCAPE.sub(b"", bytes(transcript[start:]))
missing = [marker.decode() for marker in markers if marker not in output]
raise RuntimeError(f"missing markers {missing}; returncode={process.poll()}; output={transcript!r}")
@@ -88,11 +89,45 @@ def main() -> int:
pump()
try:
if skip_intro:
wait_for_raw_mode()
os.write(master, b" ")
wait_for([ready_marker, CHAT_READY])
wait_for_raw_mode()
def set_size(columns: int, height: int) -> None:
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", height, columns, 0, 0))
os.kill(process.pid, signal.SIGWINCH)
def settle(timeout: float = 0.15) -> None:
deadline = time.monotonic() + timeout
last = len(transcript)
while time.monotonic() < deadline:
pump()
if len(transcript) != last:
last = len(transcript)
deadline = time.monotonic() + timeout
else:
select.select([master], [], [], 0.05)
resize_output = b""
burst_output = b""
noop_output = b""
if resize:
start = len(transcript)
for columns, height in [(80, 24), (40, 16), (22, 10), (160, 50), (160, 25), (100, 30)]:
offset = len(transcript)
set_size(columns, height)
wait_for([b"\x1b[1;1H"], timeout=2.0, start=offset)
resize_output = bytes(transcript[start:])
burst_start = len(transcript)
for columns, height in [(70, 25), (50, 18), (120, 40)]:
set_size(columns, height)
wait_for([b"\x1b[1;1H"], timeout=2.0, start=burst_start)
settle()
burst_output = bytes(transcript[burst_start:])
noop_start = len(transcript)
set_size(120, 40)
settle(0.2)
noop_output = bytes(transcript[noop_start:])
if frog_mode:
os.write(master, b"/frog peek")
wait_for([b"/frog peek"], timeout=2.0, styled=False)
@@ -114,10 +149,13 @@ def main() -> int:
time.sleep(0.2)
os.write(master, b"\x03")
wait_for([b"Cancelled", b"__SHELL_IDLE__"], timeout=4.0, styled=False)
for keypress in (b"junk", b"\x7f", b"\x7f", b"\x7f", b"\x7f", b"/exit"):
draft = b"startup draft" if startup_typing else b"junk"
os.write(master, draft)
wait_for([draft], timeout=2.0, styled=False)
for keypress in [b"\x7f"] * len(draft) + [b"/exit"]:
os.write(master, keypress)
time.sleep(0.05)
wait_for(["◆ /exit".encode()], timeout=2.0, styled=False)
wait_for([b"/exit"], timeout=2.0, styled=False)
time.sleep(0.2)
os.write(master, b"\r")
@@ -131,6 +169,9 @@ def main() -> int:
"returnCode": process.returncode,
"termiosRestored": initial_terminal == final_terminal,
"transcript": base64.b64encode(transcript).decode(),
"resizeTranscript": base64.b64encode(resize_output).decode(),
"resizeBurstTranscript": base64.b64encode(burst_output).decode(),
"resizeNoopTranscript": base64.b64encode(noop_output).decode(),
}
)
)
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath, URL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import TextCache from '#ink-text-cache'
import TextCache from '../dist/src/tui/terminal/ink.js'
const execute = promisify(execFile)
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
+3 -6
View File
@@ -1877,7 +1877,7 @@ describe('ChatController', () => {
})
})
it('opens setup through /setup and settings after appearance updates', async () => {
it('keeps /setup available after appearance updates', async () => {
const requestSetup = vi.fn()
const controller = new ChatController(backend(), { requestSetup })
@@ -1890,11 +1890,8 @@ describe('ChatController', () => {
await controller.activatePanelRow({ label: '', description: '', value: 'colorMode=light' })
await controller.activatePanelRow({ label: '', description: '', value: 'settings:General' })
const rows = controller.getSnapshot().panel?.rows ?? []
const setupIndex = rows.findIndex((row) => row.value === 'setup')
const setup = rows[setupIndex]
expect(setup).toMatchObject({ label: 'Setup', description: 'Providers and agent ›' })
expect(rows.map(({ value }) => value)).toEqual(['setupOnLaunch', 'setup', 'telemetry'])
expect(await controller.activatePanelRow(setup!)).toBe(true)
expect(rows.map(({ value }) => value)).toEqual(['telemetry'])
await controller.submit('/setup')
expect(requestSetup).toHaveBeenCalledOnce()
})
+25 -26
View File
@@ -24,10 +24,12 @@ describe('prompt editor input', () => {
})
it('reports the rendered editor height for command-deck layout', () => {
expect(promptEditorHeight('', 0, 80, 8)).toBe(3)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 8)).toBe(5)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 3)).toBe(4)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 8, true)).toBe(3)
expect(promptEditorHeight('', 0, 80, 8)).toBe(7)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 8)).toBe(7)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 3)).toBe(7)
expect(promptEditorHeight('one\ntwo\nthree\nfour', 18, 80, 8, true)).toBe(7)
expect(promptEditorHeight('', 0, 80, 8, false, true)).toBe(9)
expect(promptEditorHeight('', 0, 80, 8, true, true)).toBe(9)
})
it('handles the DEL byte that Ink reports for Backspace', () => {
@@ -56,17 +58,22 @@ describe('prompt editor input', () => {
})
})
it('renders the caret before the empty editor placeholder', () => {
const output = renderToString(
createElement(PromptEditor, {
input: '',
cursor: 0,
agentName: 'Strands harness',
animateCursor: false,
})
it.each([
[80, 'Enter to send • Ctrl+J for newline • / for commands'],
[50, 'Enter send • Ctrl+J newline • / commands'],
[40, 'Enter send • / commands'],
[22, 'Enter send'],
])('fits placeholder guidance on one row at %i columns', (width, hint) => {
const empty = renderToString(createElement(PromptEditor, { input: '', cursor: 0, width, animateCursor: false }), {
columns: width,
})
expect(empty).toContain(`▌${hint}`)
const typing = renderToString(
createElement(PromptEditor, { input: 'draft', cursor: 5, width, animateCursor: false }),
{ columns: width }
)
expect(output).toContain('◆ ▌Message Strands harness')
expect(typing).not.toContain('Enter')
expect(empty.split('\n')).toHaveLength(typing.split('\n').length)
})
it('marks bang commands as shell execution', () => {
@@ -74,7 +81,6 @@ describe('prompt editor input', () => {
createElement(PromptEditor, {
input: '!pwd',
cursor: 4,
agentName: 'Strands harness',
})
)
@@ -104,14 +110,11 @@ describe('prompt editor input', () => {
createElement(PromptEditor, {
input: '123456789',
cursor: 9,
agentName: 'Strands harness',
width: 8,
})
)
expect(output).toContain('◆ 1234')
expect(output).toContain(' 5678')
expect(output).toContain(' 9')
expect(output).toContain(' 123456\n 789')
})
it('follows the cursor after reaching the configured row cap', () => {
@@ -119,15 +122,12 @@ describe('prompt editor input', () => {
createElement(PromptEditor, {
input: 'one\ntwo\nthree\nfour',
cursor: 18,
agentName: 'Strands harness',
maxRows: 3,
})
)
expect(output).not.toContain('one')
expect(output).toContain('◆ two')
expect(output).toContain(' three')
expect(output).toContain(' four')
expect(output).toContain(' two\n three\n four')
})
it('reveals earlier multiline input when the cursor moves back', () => {
@@ -138,7 +138,7 @@ describe('prompt editor input', () => {
])
})
it('colors an actionable command token purple without coloring its argument', () => {
it('colors an actionable command token with the accent without coloring its argument', () => {
const level = chalk.level
chalk.level = 3
try {
@@ -146,12 +146,11 @@ describe('prompt editor input', () => {
createElement(PromptEditor, {
input: '/model improve the response',
cursor: 26,
agentName: 'Strands harness',
actionableCommandToken: '/model',
})
)
expect(output).toContain(
'\u001b[38;2;192;132;252m/model\u001b[38;2;236;239;241m improve the respons\u001b[7me\u001b[27m'
'\u001b[38;2;104;245;138m/model\u001b[38;2;236;239;241m improve the respons\u001b[7me\u001b[27m'
)
} finally {
chalk.level = level
-1
View File
@@ -46,7 +46,6 @@ describe('CliConfigStore', () => {
mcpDiscovery: false,
skillDiscovery: false,
agentMessaging: true,
setupOnLaunch: true,
telemetry: true,
},
})
-29
View File
@@ -1,14 +1,12 @@
import { describe, expect, it } from 'vitest'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
import { hasRoomForFrogIntro } from '../src/tui/view/intro.js'
import {
renderFrogSpiralFrame,
renderFrogBrandEasterEggFrame,
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}▀▀/
@@ -47,13 +45,6 @@ describe('Strands intro', () => {
expect(renderFrogBrandEasterEggFrame(width, 1, 3_200)).toBe(startup)
})
it('only runs when the full frog lockup fits', () => {
expect(hasRoomForFrogIntro(92, 40)).toBe(false)
expect(hasRoomForFrogIntro(93, 31)).toBe(false)
expect(hasRoomForFrogIntro(93, 32)).toBe(true)
expect(hasRoomForFrogIntro(120, 40)).toBe(true)
})
it('writes STRANDS with the spiral before the frog enters', () => {
const writing = renderFrogSpiralFrame(98, 38, 0.14, 588)
const dissolved = renderFrogSpiralFrame(98, 38, 0.23, 966)
@@ -90,26 +81,6 @@ 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)
@@ -0,0 +1,51 @@
import stringWidth from 'string-width'
import { describe, expect, it } from 'vitest'
import { metadataPlacements } from '../src/tui/view/presentation.js'
import { snapshot } from './fixtures/chat-snapshot.js'
function metadataSnapshot(model: string, cwd: string) {
const current = snapshot()
return snapshot({ runtime: { ...current.runtime, model, effort: 'High', cwd } })
}
describe('metadataPlacements', () => {
it('centers the directory when it fits between the controls', () => {
const placements = metadataPlacements(metadataSnapshot('Claude Opus 4.6', '/work'), 100)
const path = placements.find(({ target }) => target === 'cwd')!
expect(Math.abs(placements[0]!.width + stringWidth(path.text) / 2 - 49)).toBeLessThanOrEqual(0.5)
expect(placements.reduce((sum, { width }) => sum + width, 0)).toBe(98)
})
it('shortens the directory before the controls and preserves the final directory name', () => {
const current = metadataSnapshot('Claude Opus 4.6', `/Users/gsird/${'workspace/'.repeat(15)}harness-sdk`)
const placements = metadataPlacements(current, 80)
expect(placements[0]!.text).toContain('Claude Opus 4.6 • High')
expect(placements.find(({ target }) => target === 'cwd')!.text).toMatch(/….*harness-sdk$/u)
expect(placements.at(-1)!.text).toBe('/settings')
})
it('preserves effort and settings when the model must be shortened', () => {
const placements = metadataPlacements(metadataSnapshot('large model '.repeat(20), '/work'), 40)
expect(placements[0]!.text).toContain('… • High')
expect(placements.at(-1)!.text).toBe('/settings')
})
it.each([0, 1, 12, 24, 40, 80, 120])('fits Unicode labels without splitting graphemes at %i columns', (width) => {
const model = '界👩‍💻e\u0301🇺🇸'.repeat(20)
const cwd = `/用户/${'👨‍👩‍👧‍👦e\u0301📁/'.repeat(20)}`
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const allowed = new Set([...segmenter.segment(`${model}${cwd} • High…/settings░—`)].map(({ segment }) => segment))
let offset = 0
let previousEnd: number | undefined
for (const placement of metadataPlacements(metadataSnapshot(model, cwd), width)) {
const textWidth = stringWidth(placement.text)
expect(textWidth).toBeLessThanOrEqual(placement.width)
const start = offset + (placement.alignment === 'flex-end' ? placement.width - textWidth : 0)
if (previousEnd !== undefined) expect(start - previousEnd).toBeGreaterThanOrEqual(2)
for (const { segment } of segmenter.segment(placement.text)) expect(allowed.has(segment)).toBe(true)
previousEnd = start + textWidth
offset += placement.width
}
expect(offset).toBeLessThanOrEqual(Math.max(0, width - 2))
})
})
+51 -10
View File
@@ -55,7 +55,7 @@ describe('TUI mouse input', () => {
expect(frame().join('\n')).not.toContain('Choose a theme')
})
it('activates model and context metadata from direct clicks', async () => {
it('activates model metadata and the Settings button from direct clicks', async () => {
const input = ttyInput()
const output = ttyOutput(80, 16)
const frame = captureFrame(output)
@@ -81,13 +81,13 @@ describe('TUI mouse input', () => {
instances.push(instance)
await instance.waitUntilRenderFlush()
const contextTarget = findText(frame(), 'context █')
const contextTarget = findText(frame(), '/settings')
const modelTarget = findText(frame(), 'bedrock/test')
input.write(mouseInputSequence(0, contextTarget.column, contextTarget.row, 'M'))
input.write(mouseInputSequence(3, contextTarget.column, contextTarget.row, 'm'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('context'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('settings'))
controller.dismissPanel()
await instance.waitUntilRenderFlush()
@@ -192,7 +192,7 @@ describe('TUI mouse input', () => {
await vi.waitFor(() => expect(target.switchModel).toHaveBeenCalledWith(scrolledLastModel.id))
})
it('drags the effort slider without rebuilding the model panel', async () => {
it.each(['/model', '/effort'])('drags the effort slider in %s without rebuilding the panel', async (command) => {
const input = ttyInput()
const output = ttyOutput(80, 20)
const frame = captureFrame(output)
@@ -219,12 +219,12 @@ describe('TUI mouse input', () => {
interactive: true,
})
instances.push(instance)
await controller.submit('/model')
await controller.submit(command)
await instance.waitUntilRenderFlush()
const panelId = controller.getSnapshot().panel?.id
const trackRow = frame().findIndex((line) => /─+███─+/.test(line))
const track = /─+███─+/.exec(frame()[trackRow]!)
const trackRow = frame().findIndex((line) => /[─┬]+███[─┬]+/u.test(line))
const track = /[─┬]+███[─┬]+/u.exec(frame()[trackRow]!)
expect(trackRow).toBeGreaterThanOrEqual(0)
expect(track).toBeDefined()
const start = track!.index
@@ -236,14 +236,55 @@ describe('TUI mouse input', () => {
input.write(mouseInputSequence(3, end, trackRow, 'm'))
await vi.waitFor(() => expect(target.setEffort).toHaveBeenLastCalledWith('max'))
expect(target.listModels).toHaveBeenCalledOnce()
expect(target.listModels).toHaveBeenCalledTimes(command === '/model' ? 1 : 0)
expect(controller.getSnapshot().panel).toMatchObject({
id: panelId,
slider: { options: expect.arrayContaining([expect.objectContaining({ id: 'max', active: true })]) },
})
})
it('closes the /effort panel on Enter without switching models', async () => {
it.each([22, 60, 80])('selects the visible effort label at %i columns', async (width) => {
const input = ttyInput()
const output = ttyOutput(width, 20)
const frame = captureFrame(output)
const target = backend()
target.listEfforts = () => [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High', active: true },
{ id: 'xhigh', label: 'Extra high' },
{ id: 'max', label: 'Max' },
]
target.setEffort = vi.fn(async (effort) => effort)
const controller = new ChatController(target, {
runtime: { version: '1.2.3', model: 'model-00', cwd: '/work' },
settings: { animations: false },
})
const instance = render(createElement(ChatApp, { controller }), {
stdin: input,
stdout: output,
stderr: ttyOutput(width, 20),
exitOnCtrlC: false,
patchConsole: false,
interactive: true,
})
instances.push(instance)
await controller.submit('/effort')
await instance.waitUntilRenderFlush()
const trackRow = frame().findIndex((line) => /[─┬]+███[─┬]+/u.test(line))
expect(trackRow).toBeGreaterThanOrEqual(0)
const labelRow = trackRow + 1
const label = /Low|L…/u.exec(frame()[labelRow]!)
expect(label).not.toBeNull()
const column = label!.index + label![0].length - 1
input.write(mouseInputSequence(0, column, labelRow, 'M'))
input.write(mouseInputSequence(3, column, labelRow, 'm'))
await vi.waitFor(() => expect(target.setEffort).toHaveBeenLastCalledWith('low'))
})
it.each(['\r', '\u001b'])('closes the /effort panel with %j without switching models', async (closeKey) => {
const input = ttyInput()
const output = ttyOutput(80, 20)
const target = backend()
@@ -276,7 +317,7 @@ describe('TUI mouse input', () => {
input.write('\u001b[C')
await vi.waitFor(() => expect(target.setEffort).toHaveBeenLastCalledWith('high'))
input.write('\r')
input.write(closeKey)
await vi.waitFor(() => expect(controller.getSnapshot().panel).toBeUndefined())
expect(target.switchModel).not.toHaveBeenCalled()
+13 -18
View File
@@ -132,7 +132,7 @@ describe('mounted panel resizing', () => {
expect(view.screen()).toContain('Usage ping')
expect(view.screen()).toContain('(telemetry)')
expect(view.screen()).toContain('Esc back')
expect(view.screen()).toContain('/help')
expect(view.screen()).not.toContain('/help')
})
}
await view.press('\r')
@@ -149,7 +149,7 @@ describe('mounted panel resizing', () => {
await vi.waitFor(() => expect(view.screen()).toContain('keep this draft'))
})
it('keeps the selected model, panel footer and composer help visible after shrinking and expanding', async () => {
it('keeps the selected model and panel footer visible after shrinking and expanding', async () => {
const { controller, switchModel, setEffort } = createController()
const view = await mount(controller)
await controller.submit('/model')
@@ -162,10 +162,7 @@ describe('mounted panel resizing', () => {
view.fits()
expect(view.screen()).toContain('Model 08')
expect(view.screen()).toContain('Esc back')
const lines = view.screen().split('\n')
expect(lines.findIndex((line) => line.includes('Esc back'))).toBeLessThan(
lines.findIndex((line) => line.includes('/help'))
)
expect(view.screen()).not.toContain('/help')
})
}
const lines = view.screen().split('\n')
@@ -194,7 +191,6 @@ describe('mounted panel resizing', () => {
view.fits()
expect(view.screen()).toContain('9/9')
expect(view.screen()).toContain(selected.label)
expect(view.screen()).not.toContain('╔')
})
}
await view.press('\r')
@@ -203,7 +199,7 @@ describe('mounted panel resizing', () => {
expect(manager.getSnapshot().panel?.rows.find((row) => row.current)?.value).toBe(selected.value)
})
it('blocks covered metadata while retaining visible metadata and footer actions', async () => {
it('keeps metadata below panels and restores the Settings action when they close', async () => {
const { controller } = createController()
const view = await mount(controller)
await view.resize(40, 14)
@@ -211,23 +207,22 @@ describe('mounted panel resizing', () => {
const modelTarget = view.point('model-00')
controller.openContextPanel()
await vi.waitFor(() => expect(view.screen()).toContain('Context usage'))
const covered = { column: modelTarget.column + 4, row: modelTarget.row }
expect(view.screen().split('\n')[covered.row - 1]).not.toContain('model-00')
const panelId = controller.getSnapshot().panel!.id
await view.click(covered)
expect(controller.getSnapshot().panel?.id).toBe(panelId)
expect(view.screen().split('\n')[modelTarget.row - 1]).toContain('model-00')
await view.click(modelTarget)
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('models'))
await view.resize(160, 40)
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 controller.submit('measure context')
await vi.waitFor(() => expect(view.screen()).toContain('context █'))
await view.click(view.point('context █'))
await vi.waitFor(() => expect(view.screen()).toContain('/settings'))
const settingsTarget = view.point('/settings')
await controller.submit('/context')
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('context'))
await view.click(view.point('/help'))
await view.click(settingsTarget)
expect(controller.getSnapshot().panel).toBeUndefined()
await view.click(view.point('/help'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('help'))
await view.click(view.point('/settings'))
await vi.waitFor(() => expect(controller.getSnapshot().panel?.kind).toBe('settings'))
})
})
+32 -8
View File
@@ -16,13 +16,17 @@ interface PtyResult {
returnCode: number
termiosRestored: boolean
transcript: string
resizeTranscript: string
resizeBurstTranscript: string
resizeNoopTranscript: string
}
async function runPtySmoke(
intro = false,
shellMode?: 'command' | 'interrupt',
skipIntro = false,
frog = false
startupTyping = false,
frog = false,
resize = false
): Promise<PtyResult & { output: string }> {
const driver = fileURLToPath(new URL('./fixtures/tui-pty-driver.py', import.meta.url))
const loader = fileURLToPath(new URL('./fixtures/strands-cli-routing-source-loader.mjs', import.meta.url))
@@ -38,8 +42,9 @@ async function runPtySmoke(
...process.env,
STRANDS_CLI_TEST_INTRO: intro ? 'true' : 'false',
...(shellMode ? { STRANDS_CLI_TEST_SHELL_MODE: shellMode } : {}),
...(skipIntro ? { STRANDS_CLI_TEST_SKIP_INTRO: 'true' } : {}),
...(startupTyping ? { STRANDS_CLI_TEST_STARTUP_TYPING: 'true' } : {}),
...(frog ? { STRANDS_CLI_TEST_FROG_MODE: 'true' } : {}),
...(resize ? { STRANDS_CLI_TEST_RESIZE: 'true' } : {}),
},
}
)
@@ -64,6 +69,22 @@ function expectRestoredTerminal(result: PtyResult & { output: string }): void {
}
describe.skipIf(process.platform === 'win32')('TUI PTY lifecycle', () => {
it('resizes the real PTY without blanking and restores the terminal after /exit', async () => {
const result = await runPtySmoke(false, undefined, false, false, true)
const frames = Buffer.from(result.resizeTranscript, 'base64').toString()
const burst = Buffer.from(result.resizeBurstTranscript, 'base64').toString()
const noop = Buffer.from(result.resizeNoopTranscript, 'base64').toString()
expect(result.returnCode).toBe(0)
expect(frames.split('\u001b[1;1H')).toHaveLength(7)
expect(burst.split('\u001b[1;1H')).toHaveLength(2)
expect(noop).toBe('')
for (const code of ['2J', '3J', '2K']) {
expect(frames + burst).not.toContain(`\u001b[${code}`)
}
expectRestoredTerminal(result)
})
it('accepts typed edits and restores the terminal after /exit', async () => {
const result = await runPtySmoke()
@@ -73,22 +94,25 @@ describe.skipIf(process.platform === 'win32')('TUI PTY lifecycle', () => {
it('bypasses the intro when the full frog does not fit and restores the terminal after /exit', async () => {
const result = await runPtySmoke(true)
const beforePrompt = result.output.slice(0, result.output.indexOf('Message Lifecycle Fixture'))
const beforePrompt = result.output.slice(0, result.output.indexOf('Enter to send'))
expect(result.returnCode).toBe(0)
expect(sanitizeTerminalText(beforePrompt)).toMatch(STRANDS_WORDMARK)
expect(sanitizeTerminalText(beforePrompt)).not.toContain('[ space to skip ]')
expect(result.output).toContain('Message Lifecycle Fixture')
expect(result.output).toContain('Enter to send')
expect(beforePrompt.split('\u001b[2J').length - 1).toBe(1)
expectRestoredTerminal(result)
})
it('skips the frog intro with Space and restores the terminal after /exit', async () => {
it('accepts spaced text at startup and restores the terminal after /exit', async () => {
const result = await runPtySmoke(true, undefined, true)
const clean = sanitizeTerminalText(result.output)
expect(result.returnCode).toBe(0)
expect(sanitizeTerminalText(result.output)).toContain('[ space to skip ]')
expect(result.output).toContain('Message Lifecycle Fixture')
expect(clean).toMatch(STRANDS_WORDMARK)
expect(clean).not.toContain('[ space to skip ]')
expect(clean).toContain('Enter to send')
expect(clean).toContain('startup draft')
expectRestoredTerminal(result)
})
+61
View File
@@ -0,0 +1,61 @@
import { PassThrough } from 'node:stream'
import headless from '@xterm/headless'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createInkOutputs, enterAlternateScreen } from '../src/tui/terminal/terminal.js'
describe('terminal output cursor', () => {
afterEach(() => vi.unstubAllEnvs())
it.each(['ghostty', 'Apple_Terminal'])('preserves relative writes and the primary screen in %s', async (terminal) => {
vi.stubEnv('TERM_PROGRAM', terminal)
const output = Object.assign(new PassThrough(), { isTTY: true, columns: 20, rows: 5 })
const screen = new headless.Terminal({ cols: 20, rows: 5, allowProposedApi: true })
output.on('data', (chunk: Buffer) => screen.write(chunk))
const flush = (): Promise<void> => new Promise((resolve) => screen.write('', resolve))
const line = (row: number): string => screen.buffer.active.getLine(row)!.translateToString(true)
try {
output.write('\x1b[31mshell> ')
const leave = enterAlternateScreen(output)
const { stdout: inkOutput, stderr: inkErrorOutput } = createInkOutputs(
output as unknown as NodeJS.WriteStream,
output as unknown as NodeJS.WriteStream,
true
)
expect(inkErrorOutput).toBe(inkOutput)
const written = vi.fn()
inkOutput.write('\x1b[32m\x1b[5;1Hlast row', written)
await flush()
expect(written).toHaveBeenCalledOnce()
expect(screen.buffer.active.cursorY).toBe(0)
inkErrorOutput.write('\x1b[1A\rprevious row')
await flush()
expect(line(3)).toBe('previous row')
expect(line(4)).toBe('last row')
expect(screen.buffer.active.getLine(3)!.getCell(0)!.getFgColor()).toBe(2)
expect(screen.buffer.active.cursorY).toBe(0)
screen.resize(20, 4)
expect(line(3)).toBe('previous row')
leave()
output.write('ready')
await flush()
expect(line(0)).toBe('shell> ready')
expect(screen.buffer.active.getLine(0)!.getCell(7)!.getFgColor()).toBe(1)
} finally {
screen.dispose()
output.destroy()
}
})
it.each([
[false, true],
[true, false],
])('leaves ordinary output unchanged (tty=%s, alternateScreen=%s)', (isTTY, alternateScreen) => {
vi.stubEnv('TERM_PROGRAM', 'ghostty')
const output = Object.assign(new PassThrough(), { isTTY }) as unknown as NodeJS.WriteStream
const streams = createInkOutputs(output, output, alternateScreen)
expect(streams.stdout).toBe(output)
expect(streams.stderr).toBe(output)
output.destroy()
})
})
+31 -27
View File
@@ -7,7 +7,6 @@ import { ChatController, type ChatBackend } from '../src/tui/chat/controller.js'
import { CliConfigStore } from '../src/tui/config.js'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
import { ChatApp } from '../src/tui/view/app.js'
import { DnaVortexIntro } from '../src/tui/view/intro.js'
import { frogStartupHeight, renderFrogStartupLockup } from '../src/tui/view/frog-intro-renderer.js'
import { SetupWizard } from '../src/tui/view/setup-wizard/index.js'
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
@@ -81,6 +80,16 @@ async function mount(element: ReactElement, columns: number, rows: number) {
}
describe('responsive welcome art', () => {
it.each([8, 10])('keeps slash suggestions and the footer visible at %s rows', async (rows) => {
const view = await mount(createElement(ChatApp, { controller: controller() }), 40, rows)
view.input.write('/')
await vi.waitFor(() => {
view.fits()
expect(view.screen()).toContain('/help')
expect(view.screen()).toContain('/settings')
})
})
it.each([
[120, 35, 12],
[80, 34, 6],
@@ -118,8 +127,10 @@ describe('responsive welcome art', () => {
await vi.waitFor(() => {
view.fits()
expect(view.screen().replace(/\s/g, '')).toContain('keepthisdraft')
expect(view.screen()).toContain('/help')
if (rows < 30) {
expect(view.screen()).toContain('/settings')
if (rows === 10) {
expect(view.screen()).not.toMatch(STRANDS_WORDMARK)
} else if (rows < 30) {
expect(view.screen()).toMatch(STRANDS_WORDMARK)
} else {
expect(view.screen()).toContain('╔')
@@ -128,30 +139,6 @@ describe('responsive welcome art', () => {
}
})
it('skips the intro when the full frog does not fit', async () => {
const complete = vi.fn()
const view = await mount(createElement(DnaVortexIntro, { ready: false, onComplete: complete }), 40, 16)
await vi.waitFor(() => expect(complete).toHaveBeenCalledWith(0))
expect(view.screen()).toBe('')
})
it('renders the intro while the full frog fits and skips it after a compact resize', async () => {
const complete = vi.fn()
const view = await mount(createElement(DnaVortexIntro, { ready: false, onComplete: complete }), 120, 40)
const hint = '[ space to skip ]'
const rows = view.screen().split('\n')
const hintRow = rows.findIndex((row) => row.includes(hint))
const hintColumn = rows[hintRow]!.indexOf(hint)
expect(hintRow).toBe(rows.length - 1)
expect(Math.abs(hintColumn + hint.length / 2 - 60)).toBeLessThanOrEqual(1)
expect(complete).not.toHaveBeenCalled()
await view.resize(40, 16)
await vi.waitFor(() => expect(complete).toHaveBeenCalledWith(0))
})
it('keeps setup choices visible in a narrow window and after resizing', async () => {
const view = await mount(
createElement(SetupWizard, { config: CliConfigStore.memory(), onComplete: () => {}, onCancel: () => {} }),
@@ -161,6 +148,10 @@ describe('responsive welcome art', () => {
expect(view.screen()).toMatch(STRANDS_WORDMARK)
for (const [columns, rows] of [
[40, 16],
[65, 40],
[66, 40],
[92, 37],
[93, 37],
[120, 40],
[40, 24],
] as const) {
@@ -170,6 +161,19 @@ describe('responsive welcome art', () => {
for (const choice of ['Quickstart', 'Customize', 'Import']) {
expect(view.screen()).toContain(choice)
}
if (rows >= 37) {
const lines = view.screen().split('\n')
const titleRows = ['Quickstart', 'Customize', 'Import', 'Resume'].map((title) =>
lines.findIndex((line) =>
line
.trim()
.split(/\s{2,}/)
.includes(title)
)
)
expect(titleRows.every((row) => row >= 0)).toBe(true)
expect(new Set(titleRows).size).toBe(columns >= 66 ? 2 : 4)
}
expect(view.screen()).toContain('Shift+Tab')
expect(view.screen()).toMatch(/[Cc]lick/)
expect(view.screen()).toContain('↑↓')
+82 -125
View File
@@ -2,8 +2,7 @@ import { PassThrough } from 'node:stream'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createElement } from 'react'
import { render, renderToString, type Instance } from 'ink'
import { render, type Instance } from 'ink'
import { Message, TextBlock } from '@strands-agents/sdk'
import { describe, expect, it, vi } from 'vitest'
@@ -18,10 +17,9 @@ import {
import { CliConfigStore } from '../src/tui/config.js'
import { configurationFromStore, type RequestSetup, type SetupChange } from '../src/tui/agent-configuration.js'
import { runInkChat } from '../src/tui/run.js'
import * as processTree from '../src/tui/terminal/process-tree.js'
import { ConversationManager } from '../src/tui/session/conversations.js'
import { PythonVoiceSession } from '../src/tui/voice/session.js'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
import { ChatView } from '../src/tui/view/chat-view.js'
function backend(): ChatBackend {
return {
@@ -283,104 +281,6 @@ describe('runInkChat', () => {
}
)
it('redraws the complete chat after splitting and resizing the terminal without losing the draft', async () => {
const input = Object.assign(new PassThrough(), {
isTTY: true,
setRawMode: vi.fn(),
ref: vi.fn(),
unref: vi.fn(),
}) as unknown as NodeJS.ReadStream
const output = Object.assign(new PassThrough(), {
isTTY: true,
columns: 180,
rows: 50,
}) as unknown as NodeJS.WriteStream
const writes: string[] = []
output.on('data', (chunk: Buffer) => writes.push(chunk.toString()))
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) => {
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'))
writes.length = 0
input.push('\n')
await instance!.waitUntilRenderFlush()
const multilineDraft = sanitizeTerminalText(
renderToString(
createElement(ChatView, {
snapshot: controller.getSnapshot(),
input: 'draft\n',
cursor: 6,
terminalWidth: output.columns,
terminalHeight: output.rows,
synchronousTranscriptLayout: true,
}),
{ columns: output.columns }
)
).trimEnd()
await vi.waitFor(() => {
const frame = writes.filter((write) => write.includes('draft')).at(-1) ?? ''
expect(sanitizeTerminalText(frame).trimEnd()).toBe(multilineDraft)
})
writes.length = 0
input.push('\u007f')
await vi.waitFor(() => {
expect(writes.some((write) => sanitizeTerminalText(write).includes('draft'))).toBe(true)
})
for (const [columns, rows] of [
[90, 50],
[60, 25],
[180, 50],
] as const) {
writes.length = 0
output.columns = columns
output.rows = rows
output.emit('resize')
await instance!.waitUntilRenderFlush()
const expected = sanitizeTerminalText(
renderToString(
createElement(ChatView, {
snapshot: controller.getSnapshot(),
input: 'draft',
cursor: 5,
terminalWidth: columns,
terminalHeight: rows,
synchronousTranscriptLayout: true,
}),
{ columns }
)
).trimEnd()
await vi.waitFor(() => {
const frame = writes.filter((write) => write.includes('draft')).at(-1) ?? ''
expect(sanitizeTerminalText(frame).trimEnd()).toBe(expected)
})
}
} finally {
controller.close(0)
await running
}
})
it('restores the alternate screen and removes signal listeners after a normal exit', async () => {
const target = backend()
const controller = new ChatController(target)
@@ -433,7 +333,56 @@ describe('runInkChat', () => {
expect(writes.at(-1)).toContain('?1049l')
})
it('renders inside the alternate screen before initializing a controller', async () => {
it('cancels startup MCP trust with Ctrl-C and restores the terminal before forced exit', async () => {
const forcedExit = new Error('forced startup exit')
const input = Object.assign(new PassThrough(), {
isTTY: true,
setRawMode: vi.fn(),
ref: vi.fn(),
unref: vi.fn(),
}) as unknown as NodeJS.ReadStream
const output = Object.assign(new PassThrough(), { isTTY: true, columns: 120, rows: 40 })
let written = ''
output.on('data', (chunk: Buffer) => {
written += chunk.toString()
})
const sigintListeners = process.listenerCount('SIGINT')
const hardExit = vi.spyOn(processTree, 'hardExitProcessTree').mockImplementation(() => {
expect(input.setRawMode).toHaveBeenLastCalledWith(false)
expect(written).toContain('?1049l')
throw forcedExit
})
let startupSignal: AbortSignal | undefined
const running = runInkChat(
async (signal, _setup, _conversation, _launch, confirmMcp) => {
startupSignal = signal
await confirmMcp('/workspace', ['mcp.json'])
throw new Error('trust declined')
},
{
input,
output: output as unknown as NodeJS.WriteStream,
errorOutput: output as unknown as NodeJS.WriteStream,
renderApp: (element, options) => render(element, { ...options, interactive: true, patchConsole: false }),
}
).catch((error: unknown) => error)
try {
await vi.waitFor(() => expect(written).toContain("Trust this project's MCP configuration?"))
input.push('\u0003')
await vi.waitFor(() => expect(startupSignal?.aborted).toBe(true))
expect(await running).toBe(forcedExit)
expect(hardExit).toHaveBeenCalledExactlyOnceWith(130)
expect(process.listenerCount('SIGINT')).toBe(sigintListeners)
} finally {
if (!startupSignal?.aborted) input.push('n')
await running
hardExit.mockRestore()
input.destroy()
output.destroy()
}
})
it('waits for the controller before entering the alternate screen and rendering chat', async () => {
const controller = new ChatController(backend())
controller.close(0)
const writes: string[] = []
@@ -445,10 +394,13 @@ describe('runInkChat', () => {
rerender: vi.fn(),
clear: vi.fn(),
} as unknown as Instance
const renderApp = vi.fn((_element: unknown) => instance)
const source = vi.fn(async () => {
const renderApp = vi.fn((_element: unknown) => {
expect(writes[0]).toContain('?1049h')
expect(renderApp).toHaveBeenCalledOnce()
return instance
})
const source = vi.fn(async () => {
expect(writes).toEqual([])
expect(renderApp).not.toHaveBeenCalled()
return controller
})
@@ -458,12 +410,13 @@ describe('runInkChat', () => {
renderApp: renderApp as never,
})
expect(renderApp.mock.invocationCallOrder[0]).toBeLessThan(source.mock.invocationCallOrder[0]!)
expect(instance.rerender).toHaveBeenCalledOnce()
expect(renderApp).toHaveBeenCalledOnce()
expect(source.mock.invocationCallOrder[0]).toBeLessThan(renderApp.mock.invocationCallOrder[0]!)
expect(instance.rerender).not.toHaveBeenCalled()
expect(writes.at(-1)).toContain('?1049l')
})
it('starts the first request as soon as the controller is ready while the intro is still running', async () => {
it('renders and starts the first request when the controller is ready without waiting for the intro', async () => {
const controller = new ChatController(backend())
const start = vi.spyOn(controller, 'start')
let resolveController!: (value: ChatController) => void
@@ -483,24 +436,32 @@ describe('runInkChat', () => {
return instance
})
const runTask = runInkChat(() => controllerTask, {
const source = vi.fn(() => controllerTask)
const runTask = runInkChat(source, {
alternateScreen: false,
firstRequest: 'hello',
renderApp: renderApp as never,
})
await vi.waitFor(() => expect(renderApp).toHaveBeenCalledOnce())
resolveController(controller)
await vi.waitFor(() => expect(start).toHaveBeenCalledWith('hello'))
const root = renderedRoot as {
props: { onIntroComplete(exitCode: 0 | 130): void }
try {
await vi.waitFor(() => expect(source).toHaveBeenCalledOnce())
expect(renderApp).not.toHaveBeenCalled()
expect(start).not.toHaveBeenCalled()
} finally {
resolveController(controller)
await runTask
}
root.props.onIntroComplete(0)
await runTask
expect(renderApp).toHaveBeenCalledOnce()
expect(start).toHaveBeenCalledExactlyOnceWith('hello')
expect(renderApp.mock.invocationCallOrder[0]).toBeLessThan(start.mock.invocationCallOrder[0]!)
const root = renderedRoot as {
props: { controller: ChatController; intro: boolean }
}
expect(root.props.controller).toBe(controller)
expect(root.props.intro).toBe(true)
})
it('plays the intro before first-run setup and waits to construct the controller', async () => {
it('renders explicitly requested setup and waits for its completion before constructing the controller', async () => {
const controller = new ChatController(backend())
controller.close(0)
const source = vi.fn(async () => controller)
@@ -528,16 +489,12 @@ describe('runInkChat', () => {
props: {
intro: boolean
setup: boolean
onIntroComplete(exitCode: 0 | 130): void
onSetupComplete(exitCode: 0 | 130): void
onSetupComplete(exitCode: 0 | 130, changed: boolean): void
}
}
expect(root.props.intro).toBe(true)
expect(root.props.setup).toBe(true)
root.props.onIntroComplete(0)
await Promise.resolve()
expect(source).not.toHaveBeenCalled()
root.props.onSetupComplete(0)
root.props.onSetupComplete(0, false)
await runTask
expect(source).toHaveBeenCalledOnce()
+34 -50
View File
@@ -1,13 +1,11 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { main } from '../src/cli/run.js'
import { ChatController } from '../src/tui/chat/controller.js'
import { SETUP_VERSION, CliConfigStore } from '../src/tui/config.js'
import { parseSettings } from '../src/tui/settings.js'
const runInkChat = vi.hoisted(() => vi.fn(async () => 0))
vi.mock('../src/tui/terminal/ink.js', () => ({}))
vi.mock('../src/tui/run.js', () => ({ runInkChat }))
afterEach(() => {
@@ -17,24 +15,24 @@ afterEach(() => {
})
it.each([
{ name: 'configured default', settings: {}, onboardingVersion: SETUP_VERSION, args: [], expected: true },
{ name: 'configured default', settings: {}, onboardingVersion: SETUP_VERSION, args: [], expected: false },
{
name: 'configured opt-out',
settings: { setupOnLaunch: false },
name: 'saved setup preference',
settings: { setupOnLaunch: true },
onboardingVersion: SETUP_VERSION,
args: [],
expected: false,
},
{
name: 'first launch with opt-out',
settings: { setupOnLaunch: false },
name: 'first launch',
settings: {},
onboardingVersion: 0,
args: [],
expected: true,
},
{
name: 'explicit setup with opt-out',
settings: { setupOnLaunch: false },
name: 'explicit setup',
settings: {},
onboardingVersion: SETUP_VERSION,
args: ['--setup'],
expected: true,
@@ -42,19 +40,40 @@ it.each([
{
name: 'explicit agent',
settings: {},
onboardingVersion: SETUP_VERSION,
onboardingVersion: 0,
args: ['--agent', './agent.ts'],
expected: false,
},
{
name: 'explicit setup and agent',
settings: {},
onboardingVersion: SETUP_VERSION,
onboardingVersion: 0,
args: ['--setup', '--agent', './agent.ts'],
expected: true,
},
])('selects startup setup for $name', async ({ settings, onboardingVersion, args, expected }) => {
vi.spyOn(CliConfigStore, 'load').mockResolvedValue(CliConfigStore.memory({}, settings, { onboardingVersion }))
{
name: 'saved agent',
settings: {},
onboardingVersion: 0,
agentProject: './agent.ts',
args: [],
expected: false,
},
{
name: 'explicit setup and saved agent',
settings: {},
onboardingVersion: 0,
agentProject: './agent.ts',
args: ['--setup'],
expected: true,
},
])('selects startup setup for $name', async (testCase) => {
const { settings, onboardingVersion, args, expected } = testCase
const config = CliConfigStore.memory({}, parseSettings(settings, 'test/config.json'), { onboardingVersion })
if ('agentProject' in testCase) {
vi.spyOn(config, 'snapshot').mockReturnValue({ ...config.snapshot(), agentProject: testCase.agentProject })
}
vi.spyOn(CliConfigStore, 'load').mockResolvedValue(config)
const stdinIsTTY = process.stdin.isTTY
const stdoutIsTTY = process.stdout.isTTY
process.stdin.isTTY = true
@@ -68,38 +87,3 @@ it.each([
process.stdout.isTTY = stdoutIsTTY
}
})
it('defaults existing configurations to on and persists the settings toggle', async () => {
const root = await mkdtemp(join(tmpdir(), 'strands-setup-on-launch-'))
try {
const path = join(root, 'config.json')
await writeFile(path, JSON.stringify({ onboarding: { version: SETUP_VERSION }, settings: { animations: false } }))
let config = await CliConfigStore.load(path)
const controller = new ChatController(
{
id: 'setup-settings-test',
name: 'strands',
protocol: 'strands',
async *stream() {
yield* []
return { stopReason: 'endTurn' as const }
},
cancel() {},
},
{ settings: config.snapshot().settings, setSettings: (settings) => config.setSettings(settings) }
)
try {
await controller.submit('/settings')
await controller.activatePanelRow({ label: 'General', description: '', value: 'settings:General' })
const row = controller.getSnapshot().panel!.rows.find((candidate) => candidate.value === 'setupOnLaunch')!
expect(row).toMatchObject({ label: 'Launch into Setup by default', control: { kind: 'toggle', checked: true } })
expect(await controller.activatePanelRow(row)).toBe(true)
config = await CliConfigStore.load(path)
expect(config.snapshot().settings).toMatchObject({ setupOnLaunch: false, animations: false })
} finally {
await controller.dispose()
}
} finally {
await rm(root, { recursive: true, force: true })
}
})
+91
View File
@@ -0,0 +1,91 @@
import { createElement } from 'react'
import { renderToString } from 'ink'
import stringWidth from 'string-width'
import { describe, expect, it } from 'vitest'
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
import { renderFrogStartupLockup } from '../src/tui/view/frog-intro-renderer.js'
import { setupBrandFrame } from '../src/tui/view/setup-wizard/brand.js'
import { SetupProgress } from '../src/tui/view/setup-wizard/progress.js'
function brandRows(width: number, terminalHeight: number): string[] {
const frame = setupBrandFrame(width, terminalHeight)
return renderFrogStartupLockup(frame.width, false, 0, 'green', false, {}, frame.height)
.split('\n')
.map((row) => row.trimEnd())
}
describe('setup resize layout', () => {
it('retains the exact six-row wordmark when widening from 92 to 93 columns at height 37', () => {
expect(setupBrandFrame(90, 37).height).toBe(6)
expect(setupBrandFrame(91, 37).height).toBe(8)
const before = brandRows(90, 37).filter(Boolean)
const after = brandRows(91, 37).filter(Boolean)
expect(before).toHaveLength(6)
expect(after).toEqual(before)
})
it.each([
[90, 31, 2],
[90, 32, 6],
[91, 27, 1],
[91, 28, 2],
[91, 33, 2],
[91, 34, 8],
[91, 37, 8],
[91, 38, 12],
[120, 37, 8],
[120, 38, 12],
])('uses only the available art rows at %s×%s', (width, terminalHeight, expectedHeight) => {
const frame = setupBrandFrame(width, terminalHeight)
expect(frame.height).toBe(expectedHeight)
if (frame.height > 1) {
expect(frame.height + 2 + 24).toBeLessThanOrEqual(terminalHeight)
}
expect(frame.left + frame.width).toBe(width)
expect(brandRows(width, terminalHeight)).toHaveLength(expectedHeight)
})
it.each([
[48, `${'█'.repeat(20)}${'░'.repeat(20)} 3 of 6`],
[10, '█░ 3 of 6'],
[9, '▌ 3 of 6'],
[8, '▌ 3 of 6'],
[7, ' 3 of 6'],
[6, '3 of 6'],
[5, '3 of…'],
[1, '…'],
])('keeps progress and its label within %s columns', (width, expected) => {
const frame = sanitizeTerminalText(
renderToString(createElement(SetupProgress, { current: 3, total: 6, width, animate: false }), {
columns: width,
})
)
expect(frame).toBe(expected)
expect(stringWidth(frame)).toBeLessThanOrEqual(width)
})
it.each([
[0, '░ 0 of 6'],
[1, '▏ 1 of 6'],
[6, '█ 6 of 6'],
])('retains the progress endpoint for step %s in eight columns', (current, expected) => {
expect(
sanitizeTerminalText(
renderToString(createElement(SetupProgress, { current, total: 6, width: 8, animate: false }), {
columns: 8,
})
)
).toBe(expected)
})
it('measures progress labels in terminal cells', () => {
const frame = sanitizeTerminalText(
renderToString(createElement(SetupProgress, { current: 1, total: 2, label: '確認', width: 6, animate: false }), {
columns: 6,
})
)
expect(frame).toBe('▌ 確認')
expect(stringWidth(frame)).toBe(6)
})
})
+73 -3
View File
@@ -17,6 +17,10 @@ import { contextColor, detailLines, permissionLines } from '../src/tui/view/pres
import { PromptEditor } from '../src/tui/view/prompt-editor.js'
import { SettingsControl } from '../src/tui/view/settings-panel.js'
import { detectColorMode, getTheme, Text, ThemeProvider } from '../src/tui/view/theme.js'
import { ChatView } from '../src/tui/view/chat-view.js'
import { SetupWizard } from '../src/tui/view/setup-wizard/index.js'
import { CliConfigStore } from '../src/tui/config.js'
import { snapshot } from './fixtures/chat-snapshot.js'
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
const colorLevel = chalk.level
@@ -52,6 +56,7 @@ describe('theme resolution', () => {
for (const mode of ['light', 'dark'] as const) {
const theme = getTheme(settings(mode, name))
expect(theme.mode).toBe(mode)
expect(theme.canvas).toBeUndefined()
for (const key of name === 'green' ? THEME_COLOR_KEYS : (['accent', 'frog'] as const)) {
expect(theme[key]).toMatch(/^#[\da-f]{6}$/i)
}
@@ -122,6 +127,72 @@ describe('terminal background detection', () => {
})
describe('themed Ink output', () => {
it.each(['light', 'dark'] as const)('leaves the terminal canvas unpainted in %s mode', async (mode) => {
const appearance = { ...settings(mode), animations: false }
for (const screen of ['chat', 'setup'] as const) {
const input = ttyInput()
const output = ttyOutput(120, 45)
let frame = ''
output.on('data', (chunk: Buffer) => {
if (chunk.toString().includes('\n')) frame = chunk.toString()
})
const children =
screen === 'chat'
? h(ChatView, {
snapshot: snapshot({ settings: appearance }),
input: '',
cursor: 0,
terminalWidth: 120,
terminalHeight: 45,
})
: h(SetupWizard, {
config: CliConfigStore.memory({}, appearance),
checkForUpdate: async () => undefined,
onComplete: () => {},
})
const instance = render(h(ThemeProvider, { settings: appearance, children }), {
stdin: input,
stdout: output,
stderr: output,
debug: true,
interactive: true,
patchConsole: false,
exitOnCtrlC: false,
})
try {
await instance.waitUntilRenderFlush()
expect(frame, screen).toContain('\n')
expect(frame.split('\n')[0], `${screen} canvas`).not.toMatch(/\[(?:48;|4[0-7]m|10[0-7]m)/u)
} finally {
instance.unmount()
}
}
})
it('paints a canvas only when the active custom theme explicitly specifies one', () => {
const appearance: ChatSettings = {
...settings('dark', 'custom'),
animations: false,
customTheme: { base: 'green', light: {}, dark: { background: '#123456' } },
}
expect(getTheme(appearance).canvas).toBe('#123456')
expect(getTheme({ ...appearance, colorMode: 'light' }).canvas).toBeUndefined()
const frame = renderToString(
h(ThemeProvider, {
settings: appearance,
children: h(ChatView, {
snapshot: snapshot({ settings: appearance }),
input: '',
cursor: 0,
terminalWidth: 80,
terminalHeight: 24,
}),
}),
{ columns: 80 }
)
expect(frame.split('\n')[0]).toContain(ansi('#123456', true))
})
it('restores the explicit foreground after composer commands', () => {
const theme = getTheme(settings('light'))
const input = '/model improve the response'
@@ -132,11 +203,10 @@ describe('themed Ink output', () => {
input,
cursor: input.length,
actionableCommandToken: '/model',
agentName: 'Strands harness',
}),
})
)
expect(output).toContain(`${ansi(theme.hover)}/model${ansi(theme.foreground)} improve the respons`)
expect(output).toContain(`${ansi(theme.accent)}/model${ansi(theme.foreground)} improve the respons`)
})
it('preserves an explicit text background through nested styles inside a panel', () => {
@@ -201,7 +271,7 @@ describe('themed Ink output', () => {
h(Markdown, {
children: '# Heading **bold**\n\n`code` and [link](https://example.com)\n\n> Quote\n\n- item',
}),
h(PromptEditor, { input: 'draft', cursor: 2, agentName: 'Strands harness', width: 60 }),
h(PromptEditor, { input: 'draft', cursor: 2, width: 60 }),
h(MediaView, {
content: { type: 'document', name: 'Notes', format: 'txt', source: { type: 'text', text: 'preview' } },
}),
+124 -61
View File
@@ -8,7 +8,7 @@ import { snapshot } from './fixtures/chat-snapshot.js'
import { ChatView } from '../src/tui/view/chat-view.js'
import { Markdown } from '../src/tui/view/markdown.js'
import { panelControlTarget, panelRowCapacity, revealPanelSelection } from '../src/tui/view/interaction.js'
import { maxPermissionScroll, permissionLines } from '../src/tui/view/presentation.js'
import { formatContext, maxPermissionScroll, permissionLines } from '../src/tui/view/presentation.js'
import { DEFAULT_CHAT_SETTINGS, type ChatPanel } from '../src/tui/chat/controller.js'
import { formatPermissionPanelBody, settingsRows } from '../src/tui/chat/panels.js'
import { SETTINGS_CATEGORIES } from '../src/tui/settings.js'
@@ -138,6 +138,72 @@ describe('ChatView', () => {
expect(output).not.toContain('┘')
})
it.each([
[120, 40, false],
[80, 24, false],
[40, 16, false],
[22, 10, false],
[80, 24, true],
])('keeps /effort compact without shifting the conversation at %ix%i, party=%s', (width, height, party) => {
const current = snapshot({
completedTurns: [
{
id: 'turn-1',
prompt: 'hello',
agentName: 'Strands harness',
entries: [{ id: 'turn-1:1', type: 'assistant', text: 'Visible conversation\n'.repeat(30) }],
status: 'complete',
},
],
})
const props = { terminalWidth: width, terminalHeight: height, party }
const before = renderView({ ...props, snapshot: current }, { columns: width }).split('\n')
const after = renderView(
{
...props,
snapshot: {
...current,
panel: {
id: 'effort',
kind: 'effort',
title: 'effort',
body: 'Grok 4.6\nus.xai.grok-4.6',
rows: [],
slider: {
label: 'Effort',
options: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High', active: true },
],
},
},
},
},
{ columns: width }
).split('\n')
const editorRow = before.findIndex((line) => line.includes('Enter'))
expect(editorRow).toBeGreaterThanOrEqual(0)
const panelRow = after.findIndex((line) => line.includes('Reasoning effort'))
expect(panelRow).toBeGreaterThanOrEqual(editorRow)
expect(after.slice(0, editorRow)).toEqual(before.slice(0, editorRow))
const track = after
.slice(panelRow)
.join('\n')
.match(/[─┬█]+/u)?.[0]
expect(track).toBeDefined()
expect(stringWidth(track!)).toBeLessThanOrEqual(60)
if (width >= 40) {
expect(after[panelRow]).toContain('Grok 4.6')
expect(after.join('\n')).toContain('Enter done · Esc close')
expect(after.slice(0, panelRow).join('\n')).toContain('Visible conversation')
}
expect(after.findIndex((line) => line.includes('/work'))).toBe(before.findIndex((line) => line.includes('/work')))
expect(after.join('\n')).not.toContain('Enter send')
expect(after.length).toBe(before.length)
expect(after.length).toBeLessThanOrEqual(height)
expect(after.every((line) => stringWidth(line) <= width)).toBe(true)
})
it.each(['agents', 'mcp', 'settings'] as const)('renders the %s panel from its independent viewport', (kind) => {
const rows = Array.from({ length: 12 }, (_, index) => ({
label: `Tool ${String(index).padStart(2, '0')}`,
@@ -248,7 +314,7 @@ describe('ChatView', () => {
expect(output).toContain('/work')
})
it('renders the composer with an integrated runtime strip and navigation footer', () => {
it('renders guidance inside the composer and a single metadata row below it', () => {
const output = renderView({
snapshot: snapshot({
completedTurns: [
@@ -266,16 +332,20 @@ describe('ChatView', () => {
terminalHeight: 40,
})
expect(output.indexOf('answer')).toBeLessThan(output.indexOf('Message Strands harness'))
expect(output.indexOf('Message Strands harness')).toBeLessThan(output.lastIndexOf('bedrock/test'))
expect(output.indexOf('answer')).toBeLessThan(output.indexOf('Enter to send'))
expect(output.indexOf('Enter to send')).toBeLessThan(output.lastIndexOf('bedrock/test'))
expect(output.split('\n').some((line) => line.trim() === 'hello')).toBe(true)
expect(output).toContain('context ░░░░░░░░░░ 0%')
expect(output).not.toContain('context ░')
expect(output).toContain('Enter to send • Ctrl+J for newline • / for commands')
expect(output).toContain('Ctrl+J')
const rows = output.trimEnd().split('\n')
expect(rows.at(-3)).toContain('context')
expect(rows.at(-1)).toContain('bedrock/test')
expect(rows.at(-1)).toContain('/work')
expect(rows.at(-2)?.trim()).toBe('')
expect(rows.at(-1)).toContain('Enter send')
expect(rows.at(-1)).toContain('/help')
expect(rows.at(-1)).toContain('/settings')
expect(rows.at(-1)).not.toContain('Enter')
expect(output).not.toContain('/setup')
expect(output).not.toContain('/help')
})
it('shows active background tasks immediately above the composer', () => {
@@ -309,7 +379,7 @@ describe('ChatView', () => {
expect(output).toContain('Background task running (subagent) - task-1')
expect(output).toContain('Background task queued (read) - task-2')
expect(output).not.toContain('Background task completed (write) - task-3')
expect(output.indexOf('Background task queued (read)')).toBeLessThan(output.indexOf('Message Strands harness'))
expect(output.indexOf('Background task queued (read)')).toBeLessThan(output.indexOf('Enter to send'))
})
it('scrolls the startup lockup with the conversation', () => {
@@ -353,7 +423,6 @@ describe('ChatView', () => {
}),
terminalWidth: 100,
terminalHeight: 40,
synchronousTranscriptLayout: true,
})
)
expect(continuedOutput).not.toContain('╔')
@@ -363,18 +432,10 @@ describe('ChatView', () => {
[3_497, 1_000_000, 'context █░░░░░░░░░ 0.3%'],
[125_000, 100_000, 'context ██████████ 125%'],
])('shows precise context usage for %i of %i tokens', (projectedTokens, contextWindow, expected) => {
const output = renderView({
snapshot: snapshot({
context: { projectedTokens, contextWindow },
}),
terminalWidth: 100,
terminalHeight: 40,
})
expect(output).toContain(expected)
expect(formatContext({ projectedTokens, contextWindow })).toBe(expected.replace('context ', ''))
})
it('hides the context meter when the context window is unknown', () => {
it('marks the context meter as unknown when the context window is unavailable', () => {
const output = renderView({
snapshot: snapshot({ context: { projectedTokens: 12_000 } }),
terminalWidth: 100,
@@ -383,7 +444,7 @@ describe('ChatView', () => {
expect(output).toContain('bedrock/test')
expect(output).not.toContain('context ')
expect(output).not.toContain('░')
expect(output).toContain('░░░░░░░░░░ —')
})
it('shows only the token count in the context panel when the context window is unknown', () => {
@@ -401,7 +462,7 @@ describe('ChatView', () => {
expect(output).toContain('Context usage')
expect(output).toContain('12,000 tokens')
expect(output).not.toContain('/ —')
expect(output).not.toContain('░')
expect(output.slice(output.indexOf('Context usage'), output.indexOf('Esc close'))).not.toContain('░')
})
it.each([44, 100])('shows the context meter and last-turn usage at width %i', (terminalWidth) => {
@@ -429,8 +490,8 @@ describe('ChatView', () => {
expect(output).toContain('Context usage')
expect(output).toContain('59,689 / 1,050,000 tokens')
expect(output.trimEnd().split('\n').at(-3)).toContain('5.7%')
expect(output.trimEnd().split('\n').at(-1)).toContain('/help')
expect(output).toContain('5.7%')
expect(output).not.toContain('/help')
expect(output).toMatch(/Last turn\s+71,289 tokens/)
expect(output).toMatch(/Input\s+71,077/)
expect(output).toMatch(/Output\s+212/)
@@ -900,7 +961,7 @@ describe('ChatView', () => {
})
expect(output).toContain('| Compacting context...')
expect(output).not.toContain('Message Strands harness')
expect(output).not.toContain('Enter to send')
})
it('renders sent peer messages as transcript message bands', () => {
@@ -943,41 +1004,43 @@ describe('ChatView', () => {
expect(output).not.toContain('message_agent')
})
it.each([40, 80])('keeps settings controls and footer visible at %sx24 for first and last selections', (width) => {
const config = { ...DEFAULT_CHAT_SETTINGS, colorMode: 'light' as const }
const rows = settingsRows(config, false, 'Appearance')
const capacity = panelRowCapacity('settings', 24, width, rows)
for (const selected of [0, rows.length - 1]) {
const output = sanitizeTerminalText(
renderView({
snapshot: snapshot({
settings: config,
panel: {
id: 'settings',
kind: 'settings',
title: 'Appearance',
rows,
settingsCategory: 'Appearance',
settingsCategories: SETTINGS_CATEGORIES,
},
}),
terminalWidth: width,
terminalHeight: 24,
panelSelection: selected,
panelViewportStart: revealPanelSelection(selected, 0, capacity, rows.length),
})
)
const lines = output.split('\n')
expect(lines.length).toBeLessThanOrEqual(24)
expect(output).toContain(rows[selected]!.label)
expect(output).toContain(selected === 0 ? 'Dark' : 'Full')
expect(output).toContain('←→ change')
expect(output).toContain('Esc back')
expect(lines.findIndex((line) => line.includes('Esc back'))).toBeLessThan(
lines.findIndex((line) => line.includes('/help'))
)
it.each([40, 80])(
'keeps settings controls and their hints visible at %sx24 for first and last selections',
(width) => {
const config = { ...DEFAULT_CHAT_SETTINGS, colorMode: 'light' as const }
const rows = settingsRows(config, 'Appearance')
const capacity = panelRowCapacity('settings', 24, width, rows)
for (const selected of [0, rows.length - 1]) {
const output = sanitizeTerminalText(
renderView({
snapshot: snapshot({
settings: config,
panel: {
id: 'settings',
kind: 'settings',
title: 'Appearance',
rows,
settingsCategory: 'Appearance',
settingsCategories: SETTINGS_CATEGORIES,
},
}),
terminalWidth: width,
terminalHeight: 24,
panelSelection: selected,
panelViewportStart: revealPanelSelection(selected, 0, capacity, rows.length),
})
)
const lines = output.split('\n')
expect(lines.length).toBeLessThanOrEqual(24)
expect(output).toContain(rows[selected]!.label)
expect(output).toContain(selected === 0 ? 'Dark' : 'Full')
expect(output).toContain('←→ change')
expect(output).toContain('Esc back')
expect(output).not.toContain('/help')
expect(output).not.toContain('Enter send')
}
}
})
)
it('renders settings with a plain title and labeled controls', () => {
const rendered = renderView({
@@ -1303,8 +1366,8 @@ describe('panel helpers', () => {
}
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('bedrock/test'))
const column = lines[target]!.indexOf('bedrock/test')
const selection = selectScreenText(
lines,
{ column: column + 1, row: target + 1 },