mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
fix(cli): streamline terminal layout and startup experience (#4729)
This commit is contained in:
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('CliConfigStore', () => {
|
||||
mcpDiscovery: false,
|
||||
skillDiscovery: false,
|
||||
agentMessaging: true,
|
||||
setupOnLaunch: true,
|
||||
telemetry: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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('↑↓')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' } },
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user