From eaf5eb786109b306c6357123b92aa843de94d1e7 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:07:15 +0530 Subject: [PATCH 1/5] Redesign appearance and shader studios with responsive shared panels --- .../native-appearance-welcome/UI_REDESIGN.md | 44 ++++ src/appearance/AppearanceHub.ts | 123 +++++------ src/appearance/ShaderStudioPanel.ts | 192 +++++++++--------- src/ui/StudioLayout.ts | 104 ++++++++++ src/ui/controls.ts | 9 + 5 files changed, 299 insertions(+), 173 deletions(-) create mode 100644 docs/development/native-appearance-welcome/UI_REDESIGN.md create mode 100644 src/ui/StudioLayout.ts diff --git a/docs/development/native-appearance-welcome/UI_REDESIGN.md b/docs/development/native-appearance-welcome/UI_REDESIGN.md new file mode 100644 index 00000000..ee086ac4 --- /dev/null +++ b/docs/development/native-appearance-welcome/UI_REDESIGN.md @@ -0,0 +1,44 @@ +# Appearance and Welcome UI redesign + +The reference surfaces use a strong selected-row band, quiet group labels, a compact +tab strip, generous spacing when available, and a stable key footer. Theme Studio +separates browsing from preview; Settings uses `groupLines`/`groupedWindow` to keep +selection visible. BatchReview and ReportReview separate the proposed result from +the deliberate confirmation. Menus remain terminal text. + +## Design and component map + +- Extend the shared UI with `StudioLayout`: `framePanel` + `renderTabStrip`, + `groupLines`/`groupedWindow`, `selectedRowBand`, and `renderControlRows`. It owns + responsive list/detail geometry and reserves the footer before allocating content. +- Appearance: grouped editor launchers and host controls, selected editor summary + in a detail column; Motion and routing use the same shell. +- Both shader hosts: shared preset browser with category tabs (Tab jumps categories), + explicit exclusive groups, enabled/pass-order/written status, parameter controls, + details and framed approximate preview. Existing host descriptions and gates remain + authoritative. Written is never evidence of loaded GPU output. +- Shared configuration review: exact plan lines wrapped in a framed document area, + with guidance and a fixed Enter-write/Esc-cancel footer. No plan text is discarded. +- Welcome: section tabs jump to existing fields without changing their identities; + grouped settings, live composition alongside when wide, focused text-edit and file + picker views, preset gallery, and contextual help. P retains the startup renderer + and draft; its exit hint is outside the production card. + +## Responsive rules + +120×40: list | detail/preview columns. 80×24: stacked list and optional preview. +60×20 and 40×14: selected row neighbourhood, short status and wrapped key footer. +Drop preview first, then explanatory copy; never sacrifice selection or actions. +Tiny windows show a resize hint and the essential keys. Scroll headings with their +items and retain a visible pointer in NO_COLOR; use the shared palette and glyphs. +Hidden previews never request animation frames. No new timers or dependencies. + +## Execution and checks + +1. Capture baseline ANSI/plain snapshots before edits (done). +2. Add responsive assertions; implement shared layout, hub, shaders and review; + run affected tests and commit those implementation files. +3. Implement Welcome sections, pickers and preview entry/exit; verify and commit. +4. Finish render/routing/NO_COLOR coverage and after snapshots; commit tests. +5. Run only the brief's focused runner, source TypeScript and diff checks; report + logs, local commits and physical-terminal gaps in the evidence folder. No push. diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index e7bee9e6..e6f0d0b7 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -6,7 +6,8 @@ import {chromaPreviewNote} from './chromaNotes.js'; import type {Key} from '../terminal/keys.js'; import type {MotionSettings, PromptConfiguration} from '../prompt/configuration.js'; import {MOTION_ITEMS, MOTION_LABELS, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; -import {renderControls} from '../ui/controls.js'; +import {renderValueControl} from '../ui/controls.js'; +import {renderStudioLayout, renderStudioReview, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; import {GLYPHS} from '../ui/glyphs.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {padCells, truncateAnsi} from '../util/text.js'; @@ -36,7 +37,7 @@ export interface AppearanceHubState { hostGuidance?: string; /** Host values were changed and not saved yet. */ hostDirty: boolean; - confirmation?: {plan: HostAppearancePlan; offset: number}; + confirmation?: {plan: HostAppearancePlan; offset: number; rowCount?: number}; backgroundInput?: string; hostMessage?: string; canRestoreHost?: boolean; @@ -76,7 +77,7 @@ export function appearanceHubKey(state: AppearanceHubState, key: Key, configurat const NMSH_ROWS = hubRows(state); if (state.confirmation) { if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirmation = undefined; return undefined; } - if (key.kind === 'up' || key.kind === 'down') state.confirmation.offset = Math.max(0, Math.min(state.confirmation.plan.preview.length - 1, state.confirmation.offset + (key.kind === 'up' ? -1 : 1))); + if (key.kind === 'up' || key.kind === 'down') state.confirmation.offset = Math.max(0, Math.min((state.confirmation.rowCount ?? state.confirmation.plan.preview.length) - 1, state.confirmation.offset + (key.kind === 'up' ? -1 : 1))); return key.kind === 'enter' ? {kind: 'confirmHost'} : undefined; } if (state.backgroundInput !== undefined) { @@ -155,57 +156,30 @@ export function hubMotionPreview(state: AppearanceHubState, configuration: Promp export function renderAppearanceHub(state: AppearanceHubState, configuration: PromptConfiguration, columns: number, themeLabel: string, cursorBackend: string, preview?: {gate: MotionGate; now: number}, height?: number): string[] { const NMSH_ROWS = hubRows(state); - const primary = foreground(UI_COLORS.primary); - const secondary = foreground(UI_COLORS.secondary); - const subtle = foreground(UI_COLORS.subtle); - const accent = foreground(UI_COLORS.accent); - const reset = '\u001b[0m'; - const mark = (selected: boolean) => selected ? `${accent}${GLYPHS.selection}${reset}` : ' '; - if (state.confirmation) { - const review = state.confirmation; - const available = Math.max(1, (height ?? 24) - 7); - const lines = review.plan.preview.slice(review.offset, review.offset + available); - return [`${primary} Review host configuration${reset}`, ' Enter confirms exactly this plan; Esc keeps every file unchanged.', - ...lines.map(line => ` ${stripTerminalControls(line)}`), '', ` ${stripTerminalControls(review.plan.guidance)}`, - renderControls([['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']])].map(row => truncateAnsi(row, columns)); - } + if (state.confirmation) return renderStudioReview(state.confirmation, 'Review host configuration', columns, height); if (state.view === 'routing') { const routes = state.routes ?? []; - const routeLabel = {native: `${accent}Host GPU${reset}`, portable: `${secondary}Portable${reset}`, off: `${subtle}Off${reset}`} as const; - const head = [`${primary} Appearance › Effects routing${reset}`, - ` ${subtle}Who draws each animated surface now. Host GPU shaders only draw what the host can target (the cursor, whole-window focus and display); NMSh draws its own rows.${reset}`, '']; - const list = routes.flatMap(route => [` ${padCells(route.label, 20)}${routeLabel[route.route]}`, ` ${subtle}${stripTerminalControls(route.reason)}${reset}`]); - const controls = ['', renderControls([['Esc', 'back']])]; - const all = [...head, ...list, ...controls]; - const rows = height === undefined || all.length <= height ? all : [head[0]!, ...list.slice(0, Math.max(0, height - 3)), ...controls]; - return rows.map(row => truncateAnsi(row, columns)); + return renderStudioLayout({title: 'Appearance › Effects routing', subtitle: 'Who draws each effect · configuration snapshot', + entries: routes.length ? routes.map(route => ({group: route.route === 'native' ? 'Host GPU · written, unverified' : route.route === 'portable' ? 'Portable · NMSh rows' : 'Off', label: route.label, render: () => `${route.label} · ${stripTerminalControls(route.reason)}`})) : [{group: 'Routing', label: 'No routes available'}], + selected: -1, controls: [['Enter', 'back'], ['Esc', 'back']], + }, columns, height); } if (state.view === 'motion' || state.view === 'motionAdvanced') { const advanced = state.view === 'motionAdvanced'; const rendering = configuration.motion.rendering === 'rich' ? 'Rich' : 'Clean'; const items: readonly MotionItem[] = advanced ? MOTION_TUNING_ITEMS : MOTION_ITEMS; - const head = [`${primary} Appearance › Motion${advanced ? ` › Advanced (${rendering})` : ''}${reset}`, ` ${subtle}${advanced - ? `Tuning for ${rendering} rendering only; ${rendering === 'Rich' ? 'Clean' : 'Rich'} keeps its own values.` - : 'General NMSh motion. Cursor motion lives in /cursor; Chroma color motion in /chroma. Reduced Motion and Decorative Effects Off stop all of it.'}${reset}`, '']; - const list: string[] = []; - items.forEach((item, index) => { - const selected = index === state.selected; - const value = item.labelOf(item.get(configuration.motion)); - list.push(`${mark(selected)} ${focusForeground(selected)}${padCells(item.label, 24)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); - }); - if (!advanced) { - const selected = state.selected === items.length; - list.push(`${mark(selected)} ${focusForeground(selected)}${padCells('Advanced', 24)}${reset}${selected ? accent : secondary}${rendering} tuning ›${reset}`); - } - const noteText = items[state.selected]?.note ?? `Intensity and speed for ${rendering} rendering; Enter opens them`; - const note = ['', ` ${subtle}${noteText}${reset}`]; - const shown = preview ? hubMotionPreview(state, configuration, columns, preview.gate, preview.now) : undefined; - const controls = ['', renderControls([['↑↓', 'select'], ['←→', 'change'], ...(!advanced && state.selected === items.length ? [['Enter', 'open'] as [string, string]] : []), ...(shown ? [['R', 'replay'] as [string, string]] : []), ['Esc', 'back']])]; - // The preview is always the same size; a short terminal gives up the intro, then the preview, never the controls or the list. - const block = shown ? ['', ` ${subtle}Preview · ${rendering}${reset}`, ...shown.rows] : []; - const layouts = [[...head, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...block, ...controls], [head[0]!, ...list, ...note, ...controls], [head[0]!, ...list, ...controls], [...list, ...controls]]; - const rows = height === undefined ? layouts[0]! : layouts.find(layout => layout.length <= height) ?? layouts[layouts.length - 1]!; - return rows.map(row => truncateAnsi(row, columns)); + const entries: StudioEntry[] = items.map(item => ({group: advanced ? `${rendering} tuning` : 'General motion', label: item.label, value: `‹ ${item.labelOf(item.get(configuration.motion))} ›`})); + if (!advanced) entries.push({group: 'Tuning', label: 'Advanced', value: `${rendering} tuning ›`}); + return renderStudioLayout({title: `Appearance › Motion${advanced ? ` › Advanced (${rendering})` : ''}`, + subtitle: 'NMSh rows · Reduced Motion and Effects Off stop animation', entries, selected: state.selected, + controls: [['↑↓', 'select'], ['←→', 'change'], ['Enter', 'open'], ['R', 'replay'], ['Esc', 'back']], + details: (width, room) => { + const shown = preview ? hubMotionPreview(state, configuration, width, preview.gate, preview.now) : undefined; + const note = studioWrap(items[state.selected]?.note ?? `Intensity and speed for ${rendering} rendering; Enter opens them`, width).map(studioMuted); + const sample = shown ? [studioLabel(`Preview · ${rendering}`), ...shown.rows] : []; + return note.length + sample.length <= room ? [...note, ...sample] : note; + }, + }, columns, height); } const cursor = configuration.cursor; const motion = configuration.motion; @@ -225,36 +199,31 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr return routes.length ? [count('native') ? `${count('native')} on host GPU` : '', `${count('portable')} Portable`, count('off') ? `${count('off')} off` : ''].filter(Boolean).join(' · ') : 'Who draws each effect'; })(), motion: anyMotion ? `${motion.rendering === 'rich' ? 'Rich' : 'Clean'} · Launch ${MOTION_LABELS[motion.commandLaunch]} · Context ${MOTION_LABELS[motion.contextTransitions]} · Events ${MOTION_LABELS[motion.eventFeedback]}` : 'Off', }; - const rows = [`${primary} Appearance${reset}`, ` ${subtle}Everything visual in one place; each row opens its own editor.${reset}`, '', ` ${subtle}NMSh${reset}`]; - NMSH_ROWS.forEach((row, index) => { - const selected = index === state.selected; - rows.push(`${mark(selected)} ${focusForeground(selected)}${padCells(row.label, 20)}${reset}${subtle}${summaries[row.id]}${reset} ${selected ? `${accent}›${reset}` : ''}`); - }); - rows.push(` ${subtle}Chroma ${configuration.presentation.preset === 'off' ? 'OFF' : 'ON'} · ${chromaPreviewNote(configuration.presentation.preset !== 'off')}${reset}`); - rows.push('', ` ${subtle}Host window · configuration snapshot (CLI overrides are not visible)${reset}`, ` ${secondary}${padCells('Host', 20)}${reset}${primary}${stripTerminalControls(state.hostName)}${reset}`); + const entries: StudioEntry[] = NMSH_ROWS.map(row => ({ + group: ['theme', 'prompt'].includes(row.id) ? 'Identity' : ['cursor', 'kittyShaders', 'glslShaders'].includes(row.id) ? 'Cursor & host effects' : row.id === 'welcome' ? 'Startup' : 'Interface & motion', + label: row.label, value: summaries[row.id], + })); if (state.host) { - const bar = (fraction: number) => { const filled = Math.max(0, Math.min(10, Math.round(fraction * 10))); return `${'█'.repeat(filled)}${'░'.repeat(10 - filled)}`; }; - const hostRows = [['Opacity', `${bar(state.host.opacity)} ${Math.round(state.host.opacity * 100)}%`], ['Blur mode', BLUR_MODES[state.host.blurModeIndex]!]]; - if (BLUR_MODES[state.host.blurModeIndex] === 'Numeric') hostRows.push(['Blur', `${bar(state.host.blurStrength / 50)} ${state.host.blurStrength}`]); - hostRows.push(['Background', state.backgroundInput !== undefined ? `RGB: ${state.backgroundInput}_` : stripTerminalControls(state.host.background ?? 'Inherit host color')]); - hostRows.forEach(([label, value], index) => { - const selected = NMSH_ROWS.length + index === state.selected; - rows.push(`${mark(selected)} ${focusForeground(selected)}${padCells(label!, 20)}${reset}${selected ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); - }); - if (state.hostDirty) rows.push(` ${subtle}S reviews pending host changes before writing${reset}`); - rows.push(` ${subtle}R reviews removal of NMSh-owned appearance overrides${reset}`); - } else rows.push(` ${subtle}${stripTerminalControls(state.hostGuidance ?? `Opacity and blur are controlled by ${state.hostName}.`)}${reset}`); - if (!state.host && state.canRestoreHost) rows.push(` ${subtle}R reviews removal of NMSh-owned appearance overrides${reset}`); - if (state.hostMessage) rows.push(` ${subtle}${stripTerminalControls(state.hostMessage)}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', 'open'], ...(state.host ? [['←→', 'adjust host'] as [string, string]] : []), ['Esc', 'close']])); - if (height !== undefined && rows.length > height) { - const limit = Math.max(1, Math.floor(height)); - const controls = columns < 60 ? renderControls([['Esc', 'close'], ['↑↓', 'select'], ['←→', 'edit'], ['S', 'review']]) : rows.at(-1)!; - if (limit <= 2) return [rows[0]!, controls].slice(0, limit).map(row => truncateAnsi(row, columns)); - const count = limit - 2; - const selectedRow = Math.max(1, rows.findIndex(row => row.startsWith(mark(true)))); - const start = Math.max(1, Math.min(rows.length - 1 - count, selectedRow - Math.floor(count / 2))); - return [rows[0]!, ...rows.slice(start, start + count), controls].map(row => truncateAnsi(row, columns)); + const host = state.host; + const hostRows: Array<[string, string]> = [ + ['Opacity', `${'█'.repeat(Math.max(0, Math.min(10, Math.round(host.opacity * 10))))}${'░'.repeat(10 - Math.max(0, Math.min(10, Math.round(host.opacity * 10))))} ${Math.round(host.opacity * 100)}%`], + ['Blur mode', BLUR_MODES[host.blurModeIndex]!], + ]; + if (BLUR_MODES[host.blurModeIndex] === 'Numeric') hostRows.push(['Blur', renderValueControl('number', String(host.blurStrength), host.blurStrength / 50)]); + hostRows.push(['Background', state.backgroundInput !== undefined ? `RGB: ${state.backgroundInput}_` : stripTerminalControls(host.background ?? 'Inherit host color')]); + entries.push(...hostRows.map(([label, value]) => ({group: `Host window · ${stripTerminalControls(state.hostName)}`, label, value}))); } - return rows.map(row => truncateAnsi(row, columns)); + const selectedEntry = entries[state.selected]; + return renderStudioLayout({title: 'Appearance', stacked: height === undefined, subtitle: 'Your terminal, composed · select an editor to shape it', + entries, selected: state.selected, + notice: state.hostMessage ?? (state.hostDirty ? 'Unsaved host changes · S reviews before writing' : undefined), + controls: state.backgroundInput !== undefined ? [['Enter', 'apply RGB'], ['Esc', 'cancel']] : [ + ['↑↓', 'select'], ['Enter', 'open'], ...(state.host ? [['←→', 'adjust'], ['S', 'review']] as const : []), + ...(state.host || state.canRestoreHost ? [['R', 'restore']] as const : []), ['Esc', 'close']], + details: width => [studioLabel(selectedEntry?.label ?? 'Appearance'), + ...studioWrap(selectedEntry?.value ?? '', width), '', studioMuted('Host window · configuration snapshot'), + ...studioWrap(`Host ${stripTerminalControls(state.hostName)} · CLI overrides are not visible`, width).map(studioMuted), + ...studioWrap(state.host ? 'Changes are reviewed before writing. R reviews removal of NMSh-owned appearance overrides.' : state.hostGuidance ?? `Opacity and blur are controlled by ${state.hostName}.`, width).map(studioMuted), '', + ...studioWrap(`Chroma ${configuration.presentation.preset === 'off' ? 'OFF' : 'ON'} · ${chromaPreviewNote(configuration.presentation.preset !== 'off')}`, width).map(studioMuted)], + }, columns, height ?? 72); } diff --git a/src/appearance/ShaderStudioPanel.ts b/src/appearance/ShaderStudioPanel.ts index 0f8c1382..a9118881 100644 --- a/src/appearance/ShaderStudioPanel.ts +++ b/src/appearance/ShaderStudioPanel.ts @@ -8,7 +8,9 @@ * own until a reviewed plan is confirmed; Esc discards it. */ import type {Key} from '../terminal/keys.js'; -import {renderControlRows, renderControls} from '../ui/controls.js'; +import {renderValueControl} from '../ui/controls.js'; +import {framePanel, tabCycleDelta} from '../ui/PanelShell.js'; +import {renderStudioLayout, renderStudioReview, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; import {checkMark, GLYPHS} from '../ui/glyphs.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {padCells, truncateAnsi} from '../util/text.js'; @@ -41,7 +43,7 @@ export interface ShaderStudioState(state: ShaderStudioState(state: ShaderStudioState); if (!list.length) return undefined; if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + list.length) % list.length; return undefined; } + if (tabCycleDelta(key)) { + const current = list[Math.min(state.selected, list.length - 1)]; + const category = current && current.kind !== 'placement' ? current.preset.definition.category : undefined; + const categories = CATEGORY_ORDER.filter(c => list.some(row => row.kind !== 'placement' && row.preset.definition.category === c)); + const next = categories[(categories.indexOf(category!) + tabCycleDelta(key)! + categories.length) % categories.length]; + state.selected = Math.max(0, list.findIndex(row => row.kind !== 'placement' && row.preset.definition.category === next)); + return undefined; + } if (key.kind === 'text' && key.value.toLowerCase() === 's') return {kind: 'review'}; const row = list[Math.min(state.selected, list.length - 1)]!; state.message = undefined; @@ -215,108 +225,98 @@ export function renderShaderStudio(state: ShaderStudioState stripTerminalControls(text); - if (state.confirmation) { - const review = state.confirmation; - const available = Math.max(1, (height ?? 24) - 7); - return [`${primary} Review ${host.name} shader configuration${reset}`, ' Enter confirms exactly this plan; Esc keeps every file unchanged.', - ...review.plan.preview.slice(review.offset, review.offset + available).map(line => ` ${clean(line)}`), '', ` ${subtle}${clean(review.plan.guidance)}${reset}`, - renderControls([['↑↓', 'scroll'], ['Enter', 'confirm write'], ['Esc', 'cancel']])].map(row => truncateAnsi(row, columns)); - } - const head = [`${primary} Appearance › ${host.name} shaders${reset}`]; - const reload = state.reloadPending ? [` ${secondary}${host.name} may still draw NMSh’s previous trail until you reload its configuration; Portable motion waits. Press L once you have reloaded.${reset}`] : []; + const clean = stripTerminalControls; + if (state.confirmation) return renderStudioReview(state.confirmation, `Review ${host.name} shader configuration`, columns, height); const catalog = state.catalog; - if (!catalog) return [...head, '', ` ${subtle}Reading ${host.name}’s shader support…${reset}`, '', renderControls([['Esc', 'back']])].map(row => truncateAnsi(row, columns)); - if (!catalog.supported) { - return [...head, '', ` ${secondary}${clean(catalog.reason ?? `${host.name} custom shaders are not available.`)}${reset}`, - ` ${subtle}${host.unsupportedHint}${reset}`, ...reload, ...(state.inspection?.configured ? [` ${subtle}R reviews removal of NMSh’s existing ${host.name} shader files.${reset}`] : []), - '', renderControls([['Esc', 'back'], ...(state.inspection?.configured ? [['R', 'remove'] as [string, string]] : []), ...(reload.length ? [['L', 'reloaded'] as [string, string]] : [])])].map(row => truncateAnsi(row, columns)); - } - const inspection = state.inspection; const gate = host.gate(state.gates); - const intro = [` ${subtle}${clean(host.intro(catalog))}${reset}`, - ` ${subtle}${inspection ? clean(host.status(inspection)) : `Reading ${host.name}’s configuration…`}${reset}`, - ` ${subtle}${inspection ? clean(host.userShaders(inspection)) : ''}${reset}`, - ...(gate ? [` ${secondary}${gate}${reset}`] : []), ...reload]; - const list = rows(any, host as StudioHost); + const inspection = state.inspection; + const list = catalog?.supported ? rows(any, host as StudioHost) : []; const selected = Math.min(state.selected, Math.max(0, list.length - 1)); - const mark = (active: boolean) => active ? `${accent}${GLYPHS.selection}${reset}` : ' '; + const current = list[selected]; + const preset = current && current.kind !== 'placement' ? current.preset : undefined; + const categories = CATEGORY_ORDER.filter(category => catalog?.offered.some(p => p.definition.category === category)); const order = state.draft.effects.filter(effect => effect.enabled).map(effect => effect.id); - const body: string[] = []; - let category: PresetCategory | undefined; - list.forEach((row, index) => { - const active = index === selected; - if (row.kind === 'placement') { - const value = state.draft.placement === 'after' ? 'After your shaders' : 'Before your shaders'; - body.push('', `${mark(active)} ${focusForeground(active)}${padCells('Placement', 24)}${reset}${active ? `${accent}‹ ${value} ›${reset}` : `${secondary}${value}${reset}`}`); - return; - } + const passState = (id: string) => inspection?.activePresets.includes(id) ? 'written / unverified' : 'not written'; + const entries: StudioEntry[] = list.map(row => { + if (row.kind === 'placement') return {group: 'Pipeline', label: 'Placement', value: state.draft.placement === 'after' ? 'After your shaders' : 'Before your shaders'}; + const group = `${CATEGORY_LABELS[row.preset.definition.category]}${row.preset.definition.exclusive ? ' (one)' : ''}`; if (row.kind === 'preset') { - if (row.preset.definition.category !== category) { - category = row.preset.definition.category; - body.push(...(host.placement || body.length ? [] : ['']), ` ${subtle}${CATEGORY_LABELS[category]}${category === 'cursor' ? ' (one)' : ''}${reset}`); - } const on = enabled(any, row.preset.definition.id); - const position = on && order.length > 1 ? ` ${subtle}#${order.indexOf(row.preset.definition.id) + 1}${reset}` : ''; - body.push(`${mark(active)} ${on ? accent : subtle}${checkMark(on)}${reset} ${focusForeground(active)}${row.preset.definition.label}${reset}${position}`); - return; + const position = on && order.length > 1 ? ` #${order.indexOf(row.preset.definition.id) + 1}` : ''; + return {group, label: row.preset.definition.label, render: width => { + const label = `${checkMark(on)} ${row.preset.definition.label}${position}`; + const status = `${on ? 'on' : 'off'} · ${passState(row.preset.definition.id)}`; + return width >= 50 ? `${padCells(label, Math.max(26, width - status.length - 1), 0)} ${status}` : `${label} · ${on ? 'on' : 'off'}`; + }}; } - const value = displayParam(row.param, studioParamValue(any, row.preset, row.param)); - const editing = state.colorInput && state.colorInput.preset === row.preset.definition.id && state.colorInput.param === row.param.name; - const shown = editing ? `RGB: ${state.colorInput!.text}_` : value; - body.push(`${mark(active)} ${focusForeground(active)}${padCells(row.param.label, 20)}${reset}${active && !editing && row.param.kind !== 'color' ? `${accent}‹ ${shown} ›${reset}` : `${secondary}${shown}${reset}`}`); + const value = studioParamValue(any, row.preset, row.param); + const editing = state.colorInput?.preset === row.preset.definition.id && state.colorInput.param === row.param.name; + const label = editing ? `RGB: ${state.colorInput!.text}_` : displayParam(row.param, value); + const fraction = typeof value === 'number' ? (value - (row.param.min ?? 0)) / ((row.param.max ?? 1) - (row.param.min ?? 0) || 1) : 0; + const color = Array.isArray(value) ? {red: Math.round(value[0]! * 255), green: Math.round(value[1]! * 255), blue: Math.round(value[2]! * 255)} : undefined; + return {group, label: row.param.label, render: width => `${padCells(truncateAnsi(row.param.label, Math.min(20, width - 15)), Math.min(20, width - 15))} ${editing ? label : renderValueControl(row.param.kind, label, fraction, color)}`}; }); - const current = list[selected]; - const preset = current && current.kind !== 'placement' ? current.preset : undefined; - const note = current?.kind === 'placement' ? host.placementNote - : preset ? [preset.definition.note, !preset.params.length && !preset.definition.note ? host.noParams : undefined, - preset.missing.length ? `Not offered here: ${preset.missing.join(', ')} (the installed shader does not declare them as NMSh expects).` : undefined, - current?.kind === 'param' && current.param.kind === 'color' ? 'Enter types an RGB color.' : undefined].filter(Boolean).join(' ') : ''; - // An approximate preview of the selected preset, drawn by NMSh in cells (never claimed to be the host's GPU output). const kind = preset ? previewKind(preset.definition.id, preset.definition.category) : undefined; - const sample: string[] = []; - if (preview && preset && columns >= 22) { - const values = Object.fromEntries(preset.params.map(param => [param.name, studioParamValue(any, preset, param)])); - const key = `${preset.definition.id}:${JSON.stringify(values)}`; - if (state.previewKey !== key) { state.previewKey = key; state.previewStart = preview.now; } - const label = ` ${subtle}Preview · ${preset.definition.label} · approximate, drawn by NMSh; ${host.name}’s GPU shader is the real effect${reset}`; - if (!preview.color) sample.push('', ` ${subtle}Preview needs color (NO_COLOR or a monochrome terminal).${reset}`); - else if (!kind) sample.push('', ` ${subtle}No preview for ${preset.definition.label}: NMSh would only be guessing at it.${reset}`); - else { - const frame = renderShaderPreview({kind, values: previewValues(preset.definition.id, values), width: Math.min(48, columns - 6), elapsed: preview.now - (state.previewStart ?? preview.now), - still: preview.still, colors: preview.colors, ...(preview.level ? {level: preview.level} : {})}); - state.previewBusy = frame.busy; - sample.push('', label, ...frame.rows.map(row => ` ${row}`), ...(preview.still ? [` ${subtle}Still frame (Reduced Motion or Effects Off).${reset}`] : [])); + let previewShown = false; + const reload = state.reloadPending ? `Reload pending: press L after reloading ${host.name}. Portable motion waits. The host may still draw NMSh’s previous trail.` : undefined; + const details = (width: number, room: number): string[] => { + const out: string[] = []; + const text = (value: string) => out.push(...studioWrap(value, width).map(studioMuted)); + if (preset) { + out.push(studioLabel(preset.definition.label)); + text(`${enabled(any, preset.definition.id) ? 'Enabled in draft' : 'Disabled in draft'} · ${passState(preset.definition.id)}`); + if (preset.definition.exclusive) text(`Choose one ${CATEGORY_LABELS[preset.definition.category].toLowerCase()} effect at a time.`); } - } - const replayable = Boolean(preview && kind && preview.color && !preview.still && PREVIEW_MS[kind] > 0); - const footerFor = (replay: boolean) => [ - ...(note ? ['', ` ${subtle}${clean(note)}${reset}`] : []), - ...(catalog.notOffered.length ? [` ${subtle}${host.placement ? 'Installed, not offered yet' : 'Not available in this version'}: ${clean(catalog.notOffered.join(', '))}${reset}`] : []), - ...(inspection?.problems.length ? [` ${secondary}${clean(inspection.problems[0]!)}${reset}`] : []), - ...(state.message ? [` ${secondary}${clean(state.message)}${reset}`] : []), - // Narrow terminals wrap the controls at control boundaries rather than cutting off Esc. - '', ...renderControlRows([['↑↓', 'select'], ['Enter', current?.kind === 'preset' ? 'on/off' : 'change'], ['←→', 'adjust'], ['⇧↑↓', 'order'], ['S', 'review'], ['R', 'remove'], - ...(reload.length ? [['L', 'reloaded'] as [string, string]] : []), ...(replay ? [['P', 'replay'] as [string, string]] : []), ['Esc', 'back']], Math.max(20, columns - 2)), + const note = current?.kind === 'placement' ? host.placementNote : preset?.definition.note || (preset && !preset.params.length ? host.noParams : ''); + if (note) text(note); + if (current?.kind === 'param' && current.param.kind === 'color') text('Enter types an RGB color.'); + if (preset?.missing.length) text(`Not offered here: ${preset.missing.join(', ')} (the installed shader does not declare them as NMSh expects).`); + const sample: string[] = []; + if (preview && preset && columns >= 22) { + const values = Object.fromEntries(preset.params.map(param => [param.name, studioParamValue(any, preset, param)])); + const key = `${preset.definition.id}:${JSON.stringify(values)}`; + if (state.previewKey !== key) { state.previewKey = key; state.previewStart = preview.now; } + if (!preview.color) sample.push(studioMuted('Preview needs color (NO_COLOR or a monochrome terminal).')); + else if (!kind) sample.push(...studioWrap(`No preview for ${preset.definition.label}: NMSh would only be guessing at it.`, width).map(studioMuted)); + else { + const frame = renderShaderPreview({kind, values: previewValues(preset.definition.id, values), width: Math.min(48, width), elapsed: preview.now - (state.previewStart ?? preview.now), still: preview.still, colors: preview.colors, ...(preview.level ? {level: preview.level} : {})}); + const caption = `Preview · ${preset.definition.label} · approximate, drawn by NMSh; ${host.name}’s GPU shader is the real effect`; + sample.push(...studioWrap(caption, width).map(studioMuted), ...framePanel(frame.rows, width), ...(preview.still ? [studioMuted('Still frame (Reduced Motion or Effects Off).')] : [])); + if (out.length + sample.length <= room) { state.previewBusy = frame.busy; previewShown = true; } + } + } + // The approximate sample is all-or-nothing; hidden samples cannot own a clock. + if (out.length + sample.length <= room) out.push(...sample); + text(host.intro(catalog!)); + if (inspection) { text(host.status(inspection)); text(host.userShaders(inspection)); } + if (catalog!.notOffered.length) text(`${host.placement ? 'Installed, not offered yet' : 'Not available in this version'}: ${catalog!.notOffered.join(', ')}`); + return out; + }; + const supported = catalog?.supported; + const status = !catalog ? 'Reading shader support…' : !supported ? 'Unsupported · Portable available' : gate ? 'Portable / gated · host effects restricted' : 'Native GPU · host-wide effects'; + const controls: Array = state.colorInput ? [['Enter', 'apply RGB'], ['Esc', 'cancel']] : [ + ...(supported ? [['↑↓', 'select'], ['Tab', 'category'], ['Enter', current?.kind === 'preset' ? 'on/off' : 'change'], ['←→', 'adjust'], ['⇧↑↓', 'order'], ['S', 'review']] as const : []), + ...(catalog ? [['R', 'remove']] as const : []), ...(reload ? [['L', 'reloaded']] as const : []), + // P stays a valid key even if no sample fits; the final pass adds its hint only for a visible run. + ['Esc', 'back'], ]; - // Not enough room for the preview: the list and controls win; the preview is dropped (not cut), its replay hint goes, - // and its run starts again once it can be seen. - const previewFits = height === undefined || head.length + intro.length + body.length + sample.length + footerFor(replayable && sample.length > 0).length <= height; - if (!previewFits && sample.length) { sample.length = 0; state.previewBusy = false; state.previewKey = undefined; state.previewStart = undefined; } - const footer = footerFor(replayable && sample.length > 0); - const all = [...head, ...intro, ...body, ...sample, ...footer]; - if (height === undefined || all.length <= height) return all.map(row => truncateAnsi(row, columns)); - // Short windows (the preview, if any, was already dropped above). - state.previewBusy = false; - // Short windows keep the title, a pending-reload line (Portable motion waits on it), the selected row's neighbourhood and the footer. - const compact = reload.length ? [` ${secondary}Reload pending: press L after reloading ${host.name}.${reset}`] : []; - const room = Math.max(1, height - head.length - compact.length - footer.length); - const selectedLine = body.findIndex(line => line.startsWith(mark(true))); - const start = Math.max(0, Math.min(body.length - room, selectedLine - Math.floor(room / 2))); - return [...head, ...compact, ...body.slice(start, start + room), ...footer].slice(0, Math.max(1, height)).map(row => truncateAnsi(row, columns)); + const message = state.message ?? inspection?.problems[0]; + const model = { + title: `Appearance › ${host.name} shaders`, stacked: height === undefined, + subtitle: `${host.name} ${catalog?.version || 'version unknown'} · ${status}${state.dirty ? ' · unsaved' : ''}`, + ...(categories.length && supported ? {tabs: {labels: categories.map(c => CATEGORY_LABELS[c]), selected: Math.max(0, categories.indexOf(preset?.definition.category ?? categories[0]!))}} : {}), + entries: entries.length ? entries : [{group: supported ? 'Presets' : 'Compatibility', label: !catalog ? `Reading ${host.name}’s shader support…` : clean(catalog.reason ?? 'No presets available'), render: () => !catalog ? `Reading ${host.name}’s shader support…` : clean(catalog.reason ?? 'No presets available')}], + selected, controls, notice: reload ?? message ?? gate, + details: catalog && supported ? details : (width: number) => studioWrap(catalog ? `${catalog.reason ?? ''} ${host.unsupportedHint}` : `Reading ${host.name}’s configuration…`, width).map(studioMuted), + }; + // Unbounded callers (diagnostics/tests) receive the complete catalogue and details. + const limit = height ?? Math.max(64, list.length + 45); + let result = renderStudioLayout(model, columns, limit); + if (previewShown && preview && !preview.still && kind && PREVIEW_MS[kind] > 0) { + controls.splice(controls.length - 1, 0, ['P', 'replay']); + state.previewBusy = false; previewShown = false; + result = renderStudioLayout(model, columns, limit); + } + if (!previewShown) { state.previewBusy = false; state.previewKey = undefined; state.previewStart = undefined; } + return result; } diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts new file mode 100644 index 00000000..3ef2f948 --- /dev/null +++ b/src/ui/StudioLayout.ts @@ -0,0 +1,104 @@ +import {framePanel, renderTabStrip, selectedRowBand} from './PanelShell.js'; +import {groupLines, groupedWindow} from './groupedList.js'; +import {renderControlRows} from './controls.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {GLYPHS} from './glyphs.js'; +import {displayWidth, padCells, truncateAnsi} from '../util/text.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; + +const RESET = '\u001b[0m'; +export const studioLabel = (text: string) => `${foreground(UI_COLORS.accent)}${text}${RESET}`; +export const studioMuted = (text: string) => `${foreground(UI_COLORS.subtle)}${text}${RESET}`; +export interface StudioEntry {group: string; label: string; value?: string; render?(width: number): string} +export type StudioControl = readonly [string, string]; + +/** Wrap untrusted document text in cells, preserving every visible character. */ +export function studioWrap(text: string, width: number): string[] { + const rows: string[] = []; + let row = ''; + for (const char of stripTerminalControls(text)) { + if (displayWidth(row + char) > Math.max(1, width) && row) { rows.push(row); row = ''; } + row += char; + } + rows.push(row); + return rows; +} + +export function studioColumns(columns: number, height: number): {list: number; detail: number} { + const wide = columns >= 100 && height >= 18; + const list = wide ? Math.floor((columns - 3) * 0.48) : columns; + return {list, detail: wide ? columns - list - 3 : columns}; +} + +export interface StudioLayout { + title: string; subtitle?: string; tabs?: {labels: string[]; selected: number}; + entries: StudioEntry[]; selected: number; controls: readonly StudioControl[]; + /** Important notices survive ahead of optional explanations and previews. */ + notice?: string; + /** Unbounded document/diagnostic callers can request the full stacked catalogue. */ + stacked?: boolean; + details?(width: number, height: number): string[]; + /** Coordinates of the detail body, for graphics owners that already handle image placement. */ + detailOrigin?(x: number, y: number): void; +} + +/** One responsive shell for studio browsers and editors; footer space is allocated first. */ +export function renderStudioLayout(model: StudioLayout, columns: number, height = 48): string[] { + if (height <= 0 || columns <= 0) return []; + if (height < 7 || columns < 24) return framePanel([ + truncateAnsi(model.title, columns), 'Resize to edit', + ...renderControlRows([['Enter', 'confirm'], ['Esc', 'back']], columns), + ], columns).slice(0, height); + const footer = renderControlRows(model.controls, columns); + const head = [studioLabel(model.title), + ...(model.subtitle && height >= 18 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), + ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; + const notices = model.notice ? studioWrap(model.notice, columns).slice(0, height >= 24 ? 2 : 1).map(studioMuted) : []; + const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - 1); + const widths = model.stacked ? {list: columns, detail: columns} : studioColumns(columns, height); + const wide = widths.list !== columns && Boolean(model.details); + const detailBudget = model.details && !wide && columns >= 60 && height >= 24 && budget >= 12 ? Math.min(model.stacked ? 30 : 10, budget - 5) : 0; + const listBudget = wide ? budget : budget - detailBudget; + const lines = groupLines(model.entries, entry => entry.group); + const selected = lines.findIndex(line => line.kind === 'item' && line.index === model.selected); + const window = groupedWindow(lines, selected < 0 ? undefined : selected, listBudget); + const list = lines.slice(window.start, window.end).map(line => { + if (line.kind === 'header') return studioMuted(` ${line.title}`); + const entry = line.item; + const active = line.index === model.selected; + const labelWidth = Math.min(24, Math.max(10, Math.floor(widths.list * 0.44))); + const body = entry.render ? entry.render(widths.list - 2) + : `${padCells(truncateAnsi(entry.label, labelWidth), labelWidth)}${entry.value ? ` ${entry.value}` : ''}`; + const row = `${active ? GLYPHS.selection : ' '} ${body}`; + return active ? selectedRowBand(row, widths.list) : truncateAnsi(row, widths.list); + }); + let body = list; + if (wide) { + model.detailOrigin?.(widths.list + 3, head.length + 1); + const details = model.details!(widths.detail, budget).slice(0, budget); + body = Array.from({length: Math.max(list.length, details.length)}, (_, i) => + `${padCells(list[i] ?? '', widths.list, 0)} ${truncateAnsi(details[i] ?? '', widths.detail)}`); + } else if (detailBudget) { + model.detailOrigin?.(0, head.length + 1 + list.length); + body = [...list, ...model.details!(columns, detailBudget).slice(0, detailBudget)]; + } + return framePanel([...head, ...body, ...notices, '', ...footer].map(row => truncateAnsi(row, columns)), columns); +} + +export interface StudioReview {plan: {preview: string[]; guidance: string}; offset: number; rowCount?: number} +/** Exact-plan review, shared by host appearance and shader transactions. Only callers handle keys/writes. */ +export function renderStudioReview(review: StudioReview, title: string, columns: number, height = 24): string[] { + const width = Math.max(1, columns - 4); + const document = review.plan.preview.flatMap(line => studioWrap(line, width)); + review.rowCount = document.length; + const controls = renderControlRows([['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']], columns); + if (height < 8 || columns < 24) return framePanel(['Resize to review', ...controls], columns).slice(0, height); + const guidance = studioWrap(review.plan.guidance, columns).map(studioMuted); + const head = [studioLabel(title), studioMuted('Review only · nothing written yet')]; + const available = Math.max(1, height - 1 - head.length - controls.length - Math.min(2, guidance.length) - 2); + review.offset = Math.max(0, Math.min(review.offset, Math.max(0, document.length - available))); + const visible = document.slice(review.offset, review.offset + available).map(line => ` ${line}`); + const position = `Changes · ${review.offset + 1}–${review.offset + visible.length} / ${document.length}`; + return framePanel([...head, studioMuted(position), ...framePanel(visible, columns), + ...guidance.slice(0, 2), ...controls].map(row => truncateAnsi(row, columns)), columns); +} diff --git a/src/ui/controls.ts b/src/ui/controls.ts index b2ee3f79..07d21013 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -24,3 +24,12 @@ export function renderControlRows(controls: ReadonlyArray group.length).map(group => renderControls(group)); } + +/** Compact settings affordances; values remain readable in monochrome and Safe Glyphs. */ +export function renderValueControl(kind: 'number' | 'bool' | 'color' | 'choice', label: string, fraction = 0, color?: {red: number; green: number; blue: number}): string { + if (kind === 'bool') return `${label === 'On' ? '(*)' : '( )'} ${label}`; + if (kind === 'color') return `${color ? foreground(color) : ''}##${RESET} ${label}`; + if (kind === 'choice') return `‹ ${label} ›`; + const fill = Math.round(Math.max(0, Math.min(1, fraction)) * 8); + return `${'='.repeat(fill)}${'-'.repeat(8 - fill)} ${label}`; +} From 3dc4385793487cc37255a0b0b4681012f751e57c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:11:21 +0530 Subject: [PATCH 2/5] Shape Welcome Studio into a sectioned editor with live preview and galleries --- src/ui/StudioLayout.ts | 4 +- src/welcome/editor.ts | 167 ++++++++++++++++++++++++++++++++--------- src/welcome/studio.ts | 17 +++-- 3 files changed, 146 insertions(+), 42 deletions(-) diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts index 3ef2f948..1d92033f 100644 --- a/src/ui/StudioLayout.ts +++ b/src/ui/StudioLayout.ts @@ -47,11 +47,11 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height if (height <= 0 || columns <= 0) return []; if (height < 7 || columns < 24) return framePanel([ truncateAnsi(model.title, columns), 'Resize to edit', - ...renderControlRows([['Enter', 'confirm'], ['Esc', 'back']], columns), + ...renderControlRows(model.controls.filter(([key]) => key === 'Esc' || key === 'Enter'), columns), ], columns).slice(0, height); const footer = renderControlRows(model.controls, columns); const head = [studioLabel(model.title), - ...(model.subtitle && height >= 18 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), + ...(model.subtitle && height >= 12 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; const notices = model.notice ? studioWrap(model.notice, columns).slice(0, height >= 24 ? 2 : 1).map(studioMuted) : []; const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - 1); diff --git a/src/welcome/editor.ts b/src/welcome/editor.ts index d9119e74..ceb05982 100644 --- a/src/welcome/editor.ts +++ b/src/welcome/editor.ts @@ -1,8 +1,9 @@ import type {Key} from '../terminal/keys.js'; import type {PromptConfiguration, WelcomeProviderId} from '../prompt/configuration.js'; import {WELCOME_PROVIDERS} from '../output/WelcomeProviders.js'; -import {framePanel, selectedRowBand} from '../ui/PanelShell.js'; -import {renderControlRows} from '../ui/controls.js'; +import {tabCycleDelta} from '../ui/PanelShell.js'; +import {renderStudioLayout, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; +import {renderControlRows, renderValueControl} from '../ui/controls.js'; import {truncateAnsi} from '../util/text.js'; import {composeWelcome, type WelcomeCapabilities, type WelcomeFacts, type WelcomeComposition} from './compose.js'; import {ALPHAS, DEFAULT_WELCOME_STUDIO, EFFECTS, FACTS, FITS, LAYOUTS, LOGOS, PALETTES, WELCOME_PRESETS, normalizeWelcomeStudio, welcomeText, type SavedWelcomePreset, type WelcomeStudio} from './model.js'; @@ -15,7 +16,8 @@ export interface WelcomeEditor { picker?: {directory: string; entries: AssetEntry[]; selected: number}; message?: string; paused: boolean; elapsed: number; started: number; narrow: boolean; reduced: boolean; theme: boolean; before: boolean; includePath: boolean; - captured?: string[]; previewTop?: number; preview?: WelcomeComposition; + captured?: string[]; previewTop?: number; previewLeft?: number; preview?: WelcomeComposition; + choices?: {kind: 'logo' | 'layout' | 'effect' | 'preset'; selected: number}; } export function createWelcomeEditor(config: PromptConfiguration, now = Date.now()): WelcomeEditor { return {draft: structuredClone(config.welcomeStudio), original: structuredClone(config.welcomeStudio), provider: config.welcome, @@ -25,6 +27,20 @@ export function createWelcomeEditor(config: PromptConfiguration, now = Date.now( const FIELDS = ['Provider', 'Logo', 'Asset path / picker', 'Fit', 'Alpha', 'Layout', 'Title', 'Greeting', 'Density', 'Border', 'Palette', 'Custom RGB', 'Still image effect', 'Links', ...FACTS.map(f => `Fact: ${f}${['hostname', 'user', 'path', 'git'].includes(f) ? ' (private, opt in)' : ''}`), 'Preset', 'Save preset', 'Duplicate preset', 'Reset layout', 'Import JSON file', 'Export JSON file', 'Export private path'] as const; +const SECTIONS = [ + {label: 'Logo & image', fields: [0, 1, 2, 3, 4]}, + {label: 'Typography & greeting', fields: [6, 7, 13]}, + {label: 'Layout & position', fields: [5, 8, 9]}, + {label: 'Theme & palette', fields: [10, 11]}, + {label: 'Facts/modules', fields: Array.from({length: FACTS.length}, (_, i) => i + 14)}, + {label: 'Animation', fields: [12]}, + {label: 'Presets', fields: [24, 25, 26, 27, 28, 29, 30]}, +] as const; +const FIELD_ORDER = SECTIONS.flatMap(section => [...section.fields]); +const sectionOf = (field: number) => Math.max(0, SECTIONS.findIndex(section => (section.fields as readonly number[]).includes(field))); +const choiceValues = (s: WelcomeEditor) => s.choices?.kind === 'preset' ? [...WELCOME_PRESETS, ...s.presets].map(p => p.name) + : s.choices?.kind === 'layout' ? [...LAYOUTS] : s.choices?.kind === 'logo' ? [...LOGOS] : [...EFFECTS]; + export type WelcomeEditorAction = 'close' | 'save' | 'asset' | 'picker' | 'import' | 'export' | 'provider' | undefined; const cycle = (values: readonly T[], current: T, delta: number): T => values[(values.indexOf(current) + delta + values.length) % values.length]!; export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now(), providers: readonly WelcomeProviderId[] = WELCOME_PROVIDERS.map(p => p.id)): WelcomeEditorAction { @@ -43,8 +59,24 @@ export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now( else if (kind === 'title' || kind === 'greeting' || kind === 'customColor') state.draft[kind] = value; state.draft = normalizeWelcomeStudio({...state.draft, customized: true}); state.input = undefined; return; } + if (state.choices) { + const choices = choiceValues(state); + if (key.kind === 'escape' || key.kind === 'interrupt') { state.choices = undefined; return; } + if (key.kind === 'up' || key.kind === 'down') state.choices.selected = (state.choices.selected + (key.kind === 'up' ? -1 : 1) + choices.length) % choices.length; + if (key.kind !== 'enter') return; + const {kind, selected} = state.choices; + state.choices = undefined; + if (kind === 'preset') { state.preset = selected; state.draft = structuredClone([...WELCOME_PRESETS, ...state.presets][selected]!.model); return 'asset'; } + if (kind === 'layout') state.draft.layout = LAYOUTS[selected]!; + else if (kind === 'effect') state.draft.effect = EFFECTS[selected]!; + else state.draft.logo = LOGOS[selected]!; + state.draft.customized = true; + return kind === 'logo' ? 'asset' : undefined; + } if (key.kind === 'escape' || key.kind === 'interrupt') return 'close'; - if (key.kind === 'up' || key.kind === 'down') { state.selected = (state.selected + (key.kind === 'up' ? -1 : 1) + FIELDS.length) % FIELDS.length; return; } + const tab = tabCycleDelta(key); + if (tab) { state.selected = SECTIONS[(sectionOf(state.selected) + tab + SECTIONS.length) % SECTIONS.length]!.fields[0]!; return; } + if (key.kind === 'up' || key.kind === 'down') { state.selected = FIELD_ORDER[(FIELD_ORDER.indexOf(state.selected) + (key.kind === 'up' ? -1 : 1) + FIELD_ORDER.length) % FIELD_ORDER.length]!; return; } if (key.kind === 'text') { switch (key.value.toLowerCase()) { case 's': state.draft.customized = true; return 'save'; @@ -56,6 +88,11 @@ export function welcomeEditorKey(state: WelcomeEditor, key: Key, now = Date.now( case 'm': state.reduced = !state.reduced; state.elapsed = 0; state.started = now; return; case 'b': state.before = !state.before; return; case 'o': if (state.selected === 2) return 'picker'; return; + case 'g': { + const kind = state.selected === 1 ? 'logo' : state.selected === 5 ? 'layout' : state.selected === 12 ? 'effect' : state.selected === 24 ? 'preset' : undefined; + if (kind) state.choices = {kind, selected: kind === 'preset' ? state.preset : kind === 'logo' ? LOGOS.indexOf(state.draft.logo) : kind === 'layout' ? LAYOUTS.indexOf(state.draft.layout) : EFFECTS.indexOf(state.draft.effect)}; + return; + } } } if (!['enter', 'left', 'right', 'tab'].includes(key.kind)) return; @@ -99,39 +136,99 @@ function values(s: WelcomeEditor): string[] { return [s.provider, m.logo, m.assetPath || 'Enter path; O browse', m.fit, m.alpha, m.layout, m.title, m.greeting, m.density, m.border ? 'On' : 'Off', m.palette, m.customColor, m.effect, m.links.join(' '), ...FACTS.map(f => m.modules.includes(f) ? 'On' : 'Off'), [...WELCOME_PRESETS, ...s.presets][s.preset]?.name ?? 'Vespyr', `${s.presets.length}/12 saved`, 'Copy current layout', 'Built-in defaults', 'Local JSON only', 'No bytes exported', s.includePath ? 'Include path (private)' : 'Omit path (default)']; } +/** Field-specific guidance belongs beside the editor, never in startup's composition. */ +function fieldHelp(s: WelcomeEditor): string { + if (s.selected >= 14 && s.selected <= 23) return ['hostname', 'user', 'path', 'git'].includes(FACTS[s.selected - 14]!) + ? 'Private fact · opt in to show it at startup. Enter toggles this module.' : 'A typed fact from NMSh; no shell command is run. Enter toggles this module.'; + return ({0: 'Choose the startup provider. External providers retain their own layout.', + 1: 'G opens the logo library. Images use the shared graphics service with a portable fallback.', + 2: 'Enter types a local asset path. O opens the file browser. Originals are never changed.', + 3: 'Fit the original image into its allocated area.', 4: 'Choose how transparent image pixels are presented.', + 5: 'G opens layout choices. The preview shows the current draft.', + 6: 'Edit the title; Enter applies to the draft, S saves for the next startup.', + 7: 'Write a greeting for your next session.', 8: 'Compact keeps the welcome small; expanded gives it space.', + 9: 'A text frame around the welcome card.', 10: 'Follow the theme or choose a welcome palette.', + 11: 'Enter six RGB digits for the custom palette.', 12: 'G opens animation choices. Space pauses; R replays. M tests a still frame.', + 13: 'Space-separated links shown in the welcome card.', + 24: 'G browses built-in and saved presets. Applying changes only the draft; S saves.', + 25: 'Name this draft as a reusable local preset (up to 12).', 26: 'Keep another copy under a new name.', + 27: 'Enter resets the draft to built-in defaults. S saves; Esc leaves saved settings unchanged.', + 28: 'Import a local JSON preset into the draft. Unknown fields are refused; no code is run.', + 29: 'Export JSON to a new file. Existing files are never overwritten. Asset bytes are never included.', + 30: 'Private asset paths are omitted by default. Include one only for your own use.'} as Record)[s.selected] ?? ''; +} + export function renderWelcomeEditor(s: WelcomeEditor, columns: number, height: number, facts: WelcomeFacts, caps: WelcomeCapabilities, now: number, paint?: (frame: WelcomeComposition) => string[]): string[] { - const width = Math.max(1, columns - 4); - const controls = renderControlRows(s.input ? [['Enter', 'apply'], ['Esc', 'cancel']] : s.picker ? [['↑↓', 'select'], ['Enter', 'open'], ['Esc', 'back']] : - [['↑↓', 'select'], ['←→', 'change'], ['Enter', 'edit'], ['S', 'save'], ['P', 'full preview'], ['Esc', 'back'], ['Space', 'pause'], ['R', 'replay'], ['W', 'width'], ['C', 'density'], ['T', 'theme'], ['M', 'still'], ['B', 'before']], width); - const notes = [caps.asset?.reason, s.message ?? (s.input ? 'Esc restores this field.' : 'Local assets only · originals unchanged')].filter((v): v is string => Boolean(v)); - const room = Math.max(0, height - controls.length - 5 - notes.length); - const listSize = Math.min(5, Math.floor(room / 2)); - const previewSize = Math.max(0, room - listSize); - let list: string[]; - if (s.input) list = [`${s.input.kind}: ${s.input.value}_`]; - else if (s.picker) { - const p = s.picker, start = Math.max(0, p.selected - Math.floor(listSize / 2)); - list = p.entries.slice(start, start + listSize).map((e, i) => { - const row = `${e.directory ? 'Folder: ' : ''}${welcomeText(e.name)}`; - return start + i === p.selected ? selectedRowBand(row, width) : row; - }); + s.preview = undefined; s.previewTop = undefined; s.previewLeft = undefined; + const v = values(s); + const section = sectionOf(s.selected); + const gallery = s.choices; + const picker = s.picker; + let entries: StudioEntry[]; + let selected: number; + let title = 'Welcome Studio'; + let subtitle = 'Unsaved draft · S saves for next startup or /clear'; + if (picker) { + title += ' › Open asset'; subtitle = welcomeText(picker.directory); + entries = picker.entries.map(e => ({group: 'Local files · PNG / JPEG / WebP / GIF / APNG / ANSI / text', label: welcomeText(e.name), render: () => `${e.directory ? 'Folder ' : 'Image '}${welcomeText(e.name)}`})); + if (!entries.length) entries = [{group: 'Local files', label: 'No supported assets in this folder', render: () => 'No supported assets in this folder'}]; + selected = picker.entries.length ? picker.selected : -1; + } else if (gallery) { + title += ` › Choose ${gallery.kind}`; + subtitle = 'Enter applies to draft · Esc keeps current values'; + const presets = [...WELCOME_PRESETS, ...s.presets]; + entries = choiceValues(s).map((label, i) => ({group: gallery.kind === 'preset' ? i < WELCOME_PRESETS.length ? 'Built-in layouts' : 'Your saved layouts' : 'Choices', label, + value: gallery.kind === 'preset' ? `${presets[i]!.model.logo} · ${presets[i]!.model.layout}` : undefined})); + selected = gallery.selected; + } else if (s.input) { + title += ` › ${s.input.kind === 'preset' ? 'Name preset' : s.input.kind === 'import' || s.input.kind === 'export' ? `${s.input.kind} JSON` : FIELDS[s.selected]}`; + subtitle = 'Editing draft · Enter applies · Esc restores field'; + const input = s.input; + entries = [{group: input.kind === 'export' ? `New file · ${s.includePath ? 'includes private path' : 'private path omitted'} · no asset bytes` : 'Text entry', label: input.kind, + render: width => `${studioWrap(input.value, Math.max(1, width - 1)).at(-1) ?? ''}_`}]; + selected = 0; } else { - const start = Math.max(0, Math.min(FIELDS.length - listSize, s.selected - Math.floor(listSize / 2))); - const v = values(s); - list = FIELDS.slice(start, start + listSize).map((label, i) => { - const row = `${label}: ${v[start + i]}`; - return start + i === s.selected ? selectedRowBand(row, width) : row; - }); + entries = FIELD_ORDER.map(index => ({group: SECTIONS[sectionOf(index)]!.label, label: FIELDS[index]!, + value: (index >= 14 && index <= 23 || index === 9) ? renderValueControl('bool', v[index]!) : v[index]})); + selected = FIELD_ORDER.indexOf(s.selected); } - const model = s.before ? s.original : s.draft; - const frame = composeWelcome(model, facts, {columns: s.narrow ? Math.min(40, width) : width, rows: previewSize}, - {...caps, reducedMotion: caps.reducedMotion || s.reduced, ...(s.theme ? {accent: {red: 90, green: 180, blue: 210}, chroma: {red: 230, green: 150, blue: 70}} : {})}, s.paused ? s.elapsed : now - s.started); - s.preview = frame; - s.previewTop = list.length + notes.length + 3; - let preview = s.provider === 'none' ? ['No startup welcome.'] : s.provider !== 'vespyr' ? s.captured?.slice(0, previewSize).map(r => caps.color ? r : r.replace(/\x1b\[[0-9;]*m/gu, '')) ?? ['External provider preview loading…'] : paint?.(frame) ?? frame.rows.map(r => r.ansi); - if (!previewSize) preview = []; - const rows = ['Welcome Studio', ...list, ...notes, - `Preview · ${s.before ? 'before' : 'draft'} · ${s.paused ? 'paused' : now - s.started >= 5000 ? 'settled' : 'playing'}${s.reduced ? ' · still' : ''}`, ...preview, ...controls]; - return framePanel(rows.slice(0, Math.max(1, height - 2)).map(r => truncateAnsi(r, width)), columns).slice(0, height); + const core = s.input ? [['Enter', ['export', 'import'].includes(s.input.kind) ? s.input.kind : 'apply'], ['Esc', 'cancel']] as const + : picker || gallery ? [['↑↓', 'select'], ['Enter', picker ? 'open' : 'apply'], ['Esc', 'back']] as const + : [['↑↓', 'select'], ['Tab', 'section'], ['←→', 'change'], ['Enter', 'edit'], ['S', 'save'], ['P', 'full preview'], + ...(s.selected === 2 ? [['O', 'browse']] as const : [1, 5, 12, 24].includes(s.selected) ? [['G', 'choose']] as const : []), ['Esc', 'back']] as const; + const controls: Array = [...core]; + if (!s.input && !picker && !gallery && height >= 30) controls.splice(controls.length - 1, 0, ['Space', 'pause'], ['R', 'replay'], ['W', 'width'], ['C', 'density'], ['T', 'theme'], ['M', 'still'], ['B', 'before']); + let origin = {x: 0, y: 0}; + const rows = renderStudioLayout({title, subtitle, entries, selected, controls, + ...(!s.input && !picker && !gallery ? {tabs: {labels: SECTIONS.map(section => section.label), selected: section}} : {}), + notice: s.message ?? caps.asset?.reason, + detailOrigin: (x, y) => { origin = {x, y}; }, + details: (width, room) => { + if (picker) return [studioLabel('Asset browser'), ...studioWrap(picker.entries[picker.selected]?.path ?? picker.directory, width).map(studioMuted), '', ...studioWrap('Enter opens a folder or selects an image. Only local supported assets are listed; originals stay unchanged.', width).map(studioMuted)]; + if (s.input) return [studioLabel(s.input.kind === 'export' ? 'Export review' : s.input.kind === 'import' ? 'Import a layout' : 'Field editor'), + ...studioWrap(s.input.kind === 'export' ? `Enter creates a new JSON file. ${s.includePath ? 'Includes private asset path.' : 'Private path omitted.'} No image bytes are exported.` : s.input.kind === 'import' ? 'Enter reads a bounded local JSON file into the draft. S saves imported settings.' : fieldHelp(s), width).map(studioMuted)]; + let model = s.before ? s.original : s.draft; + if (gallery) { + model = structuredClone(s.draft); + if (gallery.kind === 'preset') model = [...WELCOME_PRESETS, ...s.presets][gallery.selected]!.model; + else if (gallery.kind === 'layout') model.layout = LAYOUTS[gallery.selected]!; + else if (gallery.kind === 'logo') model.logo = LOGOS[gallery.selected]!; + else model.effect = EFFECTS[gallery.selected]!; + } + const label = `Live preview · ${s.before ? 'before' : 'draft'} · ${s.paused ? 'paused' : now - s.started >= 5000 ? 'settled' : 'playing'}${s.reduced ? ' · still' : ''}`; + const result = [studioLabel(label)]; + const frame = composeWelcome(model, facts, {columns: s.narrow ? Math.min(40, width) : width, rows: Math.max(0, room - 1)}, + {...caps, ...(model.assetPath !== s.draft.assetPath ? {asset: undefined} : {}), reducedMotion: caps.reducedMotion || s.reduced, ...(s.theme ? {accent: {red: 90, green: 180, blue: 210}, chroma: {red: 230, green: 150, blue: 70}} : {})}, s.paused ? s.elapsed : now - s.started); + s.preview = frame; s.previewTop = origin.y + 1; s.previewLeft = origin.x; + const preview = s.provider === 'none' ? ['No startup welcome.'] : s.provider !== 'vespyr' + ? s.captured?.map(row => caps.color ? row : row.replace(/\x1b\[[0-9;]*m/gu, '')) ?? ['External provider preview loading…'] + : paint?.(frame) ?? frame.rows.map(row => row.ansi); + result.push(...preview.slice(0, Math.max(0, room - 1))); + if (room - result.length >= 3) result.push('', ...studioWrap(fieldHelp(s), width).map(studioMuted)); + if (room - result.length >= 3) result.push(...renderControlRows([['P', 'startup preview · any key returns'], ['Space', 'pause'], ['R', 'replay'], ['W', 'width'], ['T', 'theme'], ['M', 'still'], ['B', 'before']], width)); + return result; + }, + }, columns, height); + return rows; } diff --git a/src/welcome/studio.ts b/src/welcome/studio.ts index ec3ccebf..e182c661 100644 --- a/src/welcome/studio.ts +++ b/src/welcome/studio.ts @@ -12,6 +12,7 @@ import {loadWelcomeAsset, listWelcomeAssets, resolveWelcomePath, type WelcomeAss import {createWelcomeEditor, renderWelcomeEditor, welcomeEditorKey, type WelcomeEditor} from './editor.js'; import {exportWelcomePreset, importWelcomePreset, normalizeWelcomeStudio, type WelcomeStudio} from './model.js'; import {inlineWelcomeReason, assetCapabilities, paintWelcome, type WelcomeOverlay} from './render.js'; +import {renderControlRows} from '../ui/controls.js'; export interface StudioHost { config(): PromptConfiguration; cwd(): string; scratch(): string; @@ -55,9 +56,15 @@ export class WelcomeStudioController { this.host.preview?.(true); } renderPreview(columns: number, height: number): string[] { - if (!this.full || this.state.provider === 'none') return []; + if (!this.full) return []; const decision = this.host.decision(); - return this.full.frame(columns, Math.min(40, height), undefined, inlineWelcomeReason(decision)).rows.map(r => r.ansi); + const rows = this.state.provider === 'none' ? [] : this.full.frame(columns, Math.min(40, height), undefined, inlineWelcomeReason(decision)).rows.map(r => r.ansi); + // Keep the startup card untouched. Use a free row for editor controls when one exists. + if (rows.length < height) { + while (rows.length < height - 1) rows.push(''); + rows.push(renderControlRows([['Any key', 'return to Welcome Studio']], columns)[0]!); + } + return rows; } async start(): Promise { await Promise.all([this.assetChanged(), this.providerChanged()]); } private async assetChanged(selectLogo = false): Promise { @@ -116,13 +123,13 @@ export class WelcomeStudioController { return; } const s = this.state; - if (!s.input && !s.picker && key.kind === 'text' && key.value.toLowerCase() === 'p') { this.beginPreview(); this.host.render(); return; } + if (!s.input && !s.picker && !s.choices && key.kind === 'text' && key.value.toLowerCase() === 'p') { this.beginPreview(); this.host.render(); return; } const before = JSON.stringify(s.draft); if (!s.input && key.kind === 'text' && ['r', ' '].includes(key.value.toLowerCase())) this.sequence.stop(); if (s.picker) { const p = s.picker; if (key.kind === 'escape' || key.kind === 'interrupt') s.picker = undefined; - else if (key.kind === 'up' || key.kind === 'down') p.selected = (p.selected + (key.kind === 'up' ? -1 : 1) + p.entries.length) % p.entries.length; + else if ((key.kind === 'up' || key.kind === 'down') && p.entries.length) p.selected = (p.selected + (key.kind === 'up' ? -1 : 1) + p.entries.length) % p.entries.length; else if (key.kind === 'enter') { const entry = p.entries[p.selected]; if (entry?.directory) await this.pick(entry.path); @@ -176,7 +183,7 @@ export class WelcomeStudioController { this.overlay = undefined; const rows = renderWelcomeEditor(this.state, columns, height, this.host.facts(this.state.before ? this.state.original : this.state.draft), {...caps, asset: assetCapabilities(asset, reason)}, Date.now(), frame => { const painted = paintWelcome(frame, asset, this.host.graphics(), this.owner, 'viewer', this.host.background()); - if (painted.overlay) this.overlay = {...painted.overlay, y: painted.overlay.y + (this.state.previewTop ?? 0), x: painted.overlay.x}; + if (painted.overlay) this.overlay = {...painted.overlay, y: painted.overlay.y + (this.state.previewTop ?? 0), x: painted.overlay.x + (this.state.previewLeft ?? 0)}; return painted.rows; }); const active = this.state.provider === 'vespyr' && !this.state.paused && !this.state.reduced && this.state.preview?.active; From 64b7e79455e271a28accc03cc221a19f14f6f9cc Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:21:11 +0530 Subject: [PATCH 3/5] Verify responsive studio states, exact reviews and preview lifecycle --- src/appearance/AppearanceHub.ts | 12 +-- src/appearance/ShaderStudioPanel.ts | 28 +++++- src/ui/StudioLayout.ts | 21 +++-- src/welcome/editor.ts | 3 +- tests/appearanceStudioLayout.test.ts | 127 +++++++++++++++++++++++++++ tests/kittyShaderPanel.test.ts | 2 +- tests/shaderPreview.test.ts | 14 +++ tests/welcomeEditor.test.ts | 67 +++++++++++++- tests/welcomeRuntime.test.ts | 25 ++++++ 9 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 tests/appearanceStudioLayout.test.ts diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index e6f0d0b7..48df53f9 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -8,9 +8,6 @@ import type {MotionSettings, PromptConfiguration} from '../prompt/configuration. import {MOTION_ITEMS, MOTION_LABELS, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js'; import {renderValueControl} from '../ui/controls.js'; import {renderStudioLayout, renderStudioReview, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; -import {GLYPHS} from '../ui/glyphs.js'; -import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; -import {padCells, truncateAnsi} from '../util/text.js'; import {BLUR_MODES, handleAppearanceKey, type AppearanceState} from './AppearancePanel.js'; import {cursorLabel} from '../cursor/CursorPanel.js'; import {renderMotionPreview, type MotionPreview} from '../motion/MotionPreview.js'; @@ -49,6 +46,7 @@ export interface AppearanceHubState { ghosttyShaders?: string; /** Who draws each animated surface now (read-only; refreshed by the app whenever the hub renders). */ routes?: SurfaceRoute[]; + routeSelected?: number; } export type HubAction = {kind: 'close'} | {kind: 'open'; destination: HubDestination} | {kind: 'motion'; motion: MotionSettings} | {kind: 'saveHost'} | {kind: 'confirmHost'} | {kind: 'restoreHost'}; @@ -93,6 +91,7 @@ export function appearanceHubKey(state: AppearanceHubState, key: Key, configurat return undefined; } if (state.view === 'routing') { + if (key.kind === 'up' || key.kind === 'down') state.routeSelected = Math.max(0, Math.min((state.routes?.length ?? 1) - 1, (state.routeSelected ?? 0) + (key.kind === 'up' ? -1 : 1))); if (key.kind === 'escape' || key.kind === 'interrupt' || key.kind === 'enter') { state.view = 'hub'; state.selected = NMSH_ROWS.findIndex(row => row.id === 'routing'); } return undefined; } @@ -159,9 +158,10 @@ export function renderAppearanceHub(state: AppearanceHubState, configuration: Pr if (state.confirmation) return renderStudioReview(state.confirmation, 'Review host configuration', columns, height); if (state.view === 'routing') { const routes = state.routes ?? []; - return renderStudioLayout({title: 'Appearance › Effects routing', subtitle: 'Who draws each effect · configuration snapshot', - entries: routes.length ? routes.map(route => ({group: route.route === 'native' ? 'Host GPU · written, unverified' : route.route === 'portable' ? 'Portable · NMSh rows' : 'Off', label: route.label, render: () => `${route.label} · ${stripTerminalControls(route.reason)}`})) : [{group: 'Routing', label: 'No routes available'}], - selected: -1, controls: [['Enter', 'back'], ['Esc', 'back']], + return renderStudioLayout({title: 'Appearance › Effects routing', subtitle: 'Who draws each effect · configuration snapshot', stacked: height === undefined, + entries: routes.length ? routes.map(route => ({group: 'Surfaces', label: route.label, value: route.route === 'native' ? 'Host GPU · written, unverified' : route.route === 'portable' ? 'Portable' : 'Off'})) : [{group: 'Routing', label: 'No routes available'}], + selected: state.routeSelected ?? 0, controls: [['↑↓', 'select'], ['Enter', 'back'], ['Esc', 'back']], + details: width => [studioLabel(routes[state.routeSelected ?? 0]?.label ?? 'Routing'), ...studioWrap(routes[state.routeSelected ?? 0]?.reason ?? 'No routes available.', width).map(studioMuted)], }, columns, height); } if (state.view === 'motion' || state.view === 'motionAdvanced') { diff --git a/src/appearance/ShaderStudioPanel.ts b/src/appearance/ShaderStudioPanel.ts index a9118881..8efe52da 100644 --- a/src/appearance/ShaderStudioPanel.ts +++ b/src/appearance/ShaderStudioPanel.ts @@ -11,8 +11,8 @@ import type {Key} from '../terminal/keys.js'; import {renderValueControl} from '../ui/controls.js'; import {framePanel, tabCycleDelta} from '../ui/PanelShell.js'; import {renderStudioLayout, renderStudioReview, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; -import {checkMark, GLYPHS} from '../ui/glyphs.js'; -import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; +import {checkMark} from '../ui/glyphs.js'; +import {UI_COLORS} from '../ui/palette.js'; import {padCells, truncateAnsi} from '../util/text.js'; import {stripTerminalControls} from '../util/terminalControls.js'; import {CATEGORY_LABELS, type OfferedParam, type PresetCategory, type ShaderValue} from './kittyShaderCatalog.js'; @@ -76,6 +76,18 @@ export type StudioAction = {kind: 'back'} | {kind: 'review'} | {kind: 'remove'} type Row = {kind: 'placement'} | {kind: 'preset'; preset: StudioPreset} | {kind: 'param'; preset: StudioPreset; param: OfferedParam}; type AnyState = ShaderStudioState; const CATEGORY_ORDER: readonly PresetCategory[] = ['cursor', 'ambient', 'interaction', 'focus', 'display']; +const PREVIEW_DESCRIPTIONS: Record = { + trail: 'A fading smear follows the cursor between positions.', + comet: 'A bright head pulls a soft tail behind the cursor.', + blaze: 'A flame-shaped trail burns behind the moving cursor.', + lightning: 'A brief jagged bolt connects cursor positions.', + 'motion-blur': 'A soft streak connects recent cursor positions.', + aurora: 'Slow color bands tint the background behind your text.', + dim: 'The terminal dims when its window loses focus.', + vignette: 'The edges darken to draw attention toward the center.', + scanlines: 'Fine horizontal lines give the display a CRT texture.', + glow: 'Bright text gains a soft halo.', +}; export function createShaderStudio(saved: D, gates: G): ShaderStudioState { return {draft: structuredClone(saved), dirty: false, selected: 0, gates}; @@ -246,7 +258,8 @@ export function renderShaderStudio(state: ShaderStudioState { const label = `${checkMark(on)} ${row.preset.definition.label}${position}`; const status = `${on ? 'on' : 'off'} · ${passState(row.preset.definition.id)}`; - return width >= 50 ? `${padCells(label, Math.max(26, width - status.length - 1), 0)} ${status}` : `${label} · ${on ? 'on' : 'off'}`; + const shown = width >= 50 ? status : `${on ? 'on' : 'off'} · ${inspection?.activePresets.includes(row.preset.definition.id) ? 'written' : 'unwritten'}`; + return `${padCells(label, Math.max(1, width - shown.length - 1), 0)} ${shown}`; }}; } const value = studioParamValue(any, row.preset, row.param); @@ -258,6 +271,7 @@ export function renderShaderStudio(state: ShaderStudioState { const out: string[] = []; @@ -266,13 +280,14 @@ export function renderShaderStudio(state: ShaderStudioState= 22) { + if (previewAllowed && preview && preset && columns >= 22) { const values = Object.fromEntries(preset.params.map(param => [param.name, studioParamValue(any, preset, param)])); const key = `${preset.definition.id}:${JSON.stringify(values)}`; if (state.previewKey !== key) { state.previewKey = key; state.previewStart = preview.now; } @@ -316,6 +331,11 @@ export function renderShaderStudio(state: ShaderStudioState key === 'P'), 1); + previewAllowed = false; + result = renderStudioLayout(model, columns, limit); + } } if (!previewShown) { state.previewBusy = false; state.previewKey = undefined; state.previewStart = undefined; } return result; diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts index 1d92033f..852058e5 100644 --- a/src/ui/StudioLayout.ts +++ b/src/ui/StudioLayout.ts @@ -17,7 +17,11 @@ export function studioWrap(text: string, width: number): string[] { const rows: string[] = []; let row = ''; for (const char of stripTerminalControls(text)) { - if (displayWidth(row + char) > Math.max(1, width) && row) { rows.push(row); row = ''; } + if (displayWidth(row + char) > Math.max(1, width) && row) { + const space = row.lastIndexOf(' '); + if (space > 0) { rows.push(row.slice(0, space + 1)); row = row.slice(space + 1); } + else { rows.push(row); row = ''; } + } row += char; } rows.push(row); @@ -45,17 +49,20 @@ export interface StudioLayout { /** One responsive shell for studio browsers and editors; footer space is allocated first. */ export function renderStudioLayout(model: StudioLayout, columns: number, height = 48): string[] { if (height <= 0 || columns <= 0) return []; - if (height < 7 || columns < 24) return framePanel([ - truncateAnsi(model.title, columns), 'Resize to edit', - ...renderControlRows(model.controls.filter(([key]) => key === 'Esc' || key === 'Enter'), columns), - ], columns).slice(0, height); + if (height < 10 || columns < 24) { + const controls = renderControlRows(model.controls.filter(([key]) => key === 'Esc' || key === 'Enter'), columns); + const entry = model.entries[model.selected]; + const row = entry ? `${GLYPHS.selection} ${entry.render?.(Math.max(1, columns - 2)) ?? `${entry.label} ${entry.value ?? ''}`}` : 'Resize to edit'; + if (height < 4) return [truncateAnsi(model.title, columns), ...controls].slice(-height); + return framePanel([truncateAnsi(model.title, columns), truncateAnsi(row, columns), ...controls], columns).slice(0, height); + } const footer = renderControlRows(model.controls, columns); const head = [studioLabel(model.title), ...(model.subtitle && height >= 12 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; const notices = model.notice ? studioWrap(model.notice, columns).slice(0, height >= 24 ? 2 : 1).map(studioMuted) : []; const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - 1); - const widths = model.stacked ? {list: columns, detail: columns} : studioColumns(columns, height); + const widths = model.stacked || !model.details ? {list: columns, detail: columns} : studioColumns(columns, height); const wide = widths.list !== columns && Boolean(model.details); const detailBudget = model.details && !wide && columns >= 60 && height >= 24 && budget >= 12 ? Math.min(model.stacked ? 30 : 10, budget - 5) : 0; const listBudget = wide ? budget : budget - detailBudget; @@ -89,7 +96,7 @@ export interface StudioReview {plan: {preview: string[]; guidance: string}; offs /** Exact-plan review, shared by host appearance and shader transactions. Only callers handle keys/writes. */ export function renderStudioReview(review: StudioReview, title: string, columns: number, height = 24): string[] { const width = Math.max(1, columns - 4); - const document = review.plan.preview.flatMap(line => studioWrap(line, width)); + const document = [...review.plan.preview, '', 'Apply & reload', review.plan.guidance].flatMap(line => studioWrap(line, width)); review.rowCount = document.length; const controls = renderControlRows([['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']], columns); if (height < 8 || columns < 24) return framePanel(['Resize to review', ...controls], columns).slice(0, height); diff --git a/src/welcome/editor.ts b/src/welcome/editor.ts index ceb05982..cc572e95 100644 --- a/src/welcome/editor.ts +++ b/src/welcome/editor.ts @@ -4,7 +4,6 @@ import {WELCOME_PROVIDERS} from '../output/WelcomeProviders.js'; import {tabCycleDelta} from '../ui/PanelShell.js'; import {renderStudioLayout, studioLabel, studioMuted, studioWrap, type StudioEntry} from '../ui/StudioLayout.js'; import {renderControlRows, renderValueControl} from '../ui/controls.js'; -import {truncateAnsi} from '../util/text.js'; import {composeWelcome, type WelcomeCapabilities, type WelcomeFacts, type WelcomeComposition} from './compose.js'; import {ALPHAS, DEFAULT_WELCOME_STUDIO, EFFECTS, FACTS, FITS, LAYOUTS, LOGOS, PALETTES, WELCOME_PRESETS, normalizeWelcomeStudio, welcomeText, type SavedWelcomePreset, type WelcomeStudio} from './model.js'; import type {AssetEntry} from './assets.js'; @@ -219,7 +218,7 @@ export function renderWelcomeEditor(s: WelcomeEditor, columns: number, height: n const label = `Live preview · ${s.before ? 'before' : 'draft'} · ${s.paused ? 'paused' : now - s.started >= 5000 ? 'settled' : 'playing'}${s.reduced ? ' · still' : ''}`; const result = [studioLabel(label)]; const frame = composeWelcome(model, facts, {columns: s.narrow ? Math.min(40, width) : width, rows: Math.max(0, room - 1)}, - {...caps, ...(model.assetPath !== s.draft.assetPath ? {asset: undefined} : {}), reducedMotion: caps.reducedMotion || s.reduced, ...(s.theme ? {accent: {red: 90, green: 180, blue: 210}, chroma: {red: 230, green: 150, blue: 70}} : {})}, s.paused ? s.elapsed : now - s.started); + {...caps, ...(gallery && model.assetPath !== s.draft.assetPath ? {asset: undefined} : {}), reducedMotion: caps.reducedMotion || s.reduced, ...(s.theme ? {accent: {red: 90, green: 180, blue: 210}, chroma: {red: 230, green: 150, blue: 70}} : {})}, s.paused ? s.elapsed : now - s.started); s.preview = frame; s.previewTop = origin.y + 1; s.previewLeft = origin.x; const preview = s.provider === 'none' ? ['No startup welcome.'] : s.provider !== 'vespyr' ? s.captured?.map(row => caps.color ? row : row.replace(/\x1b\[[0-9;]*m/gu, '')) ?? ['External provider preview loading…'] diff --git a/tests/appearanceStudioLayout.test.ts b/tests/appearanceStudioLayout.test.ts new file mode 100644 index 00000000..0bb692ff --- /dev/null +++ b/tests/appearanceStudioLayout.test.ts @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createAppearanceHub, renderAppearanceHub} from '../src/appearance/AppearanceHub.js'; +import {createGhosttyShaderPanel, renderGhosttyShaderPanel, ghosttyShaderKey} from '../src/appearance/GhosttyShaderPanel.js'; +import {buildGhosttyCatalog} from '../src/appearance/ghosttyShaderCatalog.js'; +import {DEFAULT_PROMPT_CONFIGURATION as DEFAULT} from '../src/prompt/configuration.js'; +import {stripAnsi, displayWidth} from '../src/util/text.js'; +import {createKittyShaderPanel, renderKittyShaderPanel, kittyShaderKey} from '../src/appearance/KittyShaderPanel.js'; +import {buildKittyCatalog} from '../src/appearance/kittyShaderCatalog.js'; +import {studioPreviewContext} from '../src/appearance/ShaderStudioPanel.js'; +import {renderStudioReview, studioWrap} from '../src/ui/StudioLayout.js'; + +const sizes = [[120, 40], [80, 24], [60, 20], [40, 14]] as const; +const gates = {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto' as const, cursorMotion: 'tail' as const}; +test('shader browser offers category jumps and visible focus with a persistent footer at every size', () => { + const s = createGhosttyShaderPanel(DEFAULT.hostShaders.ghostty, gates); + s.catalog = buildGhosttyCatalog('1.3.1'); + ghosttyShaderKey(s, {kind: 'complete'}); + for (const [w, h] of sizes) { + const rows = renderGhosttyShaderPanel(s, w, h); + const plain = rows.map(stripAnsi); + assert.ok(plain.some(row => row.startsWith('›') && row.includes('Aurora')), 'Tab jumps to the next category'); + assert.ok(plain.at(-1)?.includes('Esc')); + assert.ok(rows.length <= h && rows.every(row => displayWidth(row) <= w)); + } +}); +test('hub preserves focused host editing and wrapped actions at every size', () => { + const s = createAppearanceHub('Host', {opacity: 0.9, blurModeIndex: 0, blurStrength: 0, selectedIndex: 0}); + s.selected = 9; + for (const [w, h] of sizes) { + const rows = renderAppearanceHub(s, DEFAULT, w, 'Native', 'Portable', undefined, h); + assert.ok(rows.map(stripAnsi).some(row => row.startsWith('›') && row.includes('Opacity'))); + assert.match(stripAnsi(rows.join('\n')), /S review/); + assert.ok(rows.at(-1)?.includes('Esc')); + assert.ok(rows.length <= h && rows.every(row => displayWidth(row) <= w)); + } +}); + +const resources = { + 'types.slang': '// types', + 'cursor-trail-blaze.pipeline': 'startgroup\nshaders cursor-trail-blaze\nendgroup\n', + 'cursor-trail-blaze.slang': 'static const float DURATION = 0.5;\nstatic const float4 TRAIL_COLOR = float4(1.0, 0.725, 0.161, 1.0);\n', + 'spotlight.pipeline': 'startgroup\nshaders spotlight\nendgroup\n', + 'spotlight.slang': 'static const float SPOTLIGHT_RADIUS = 0.25;\nstatic const float AMBIENT_LIGHT = 0.5;\n', +}; +const plan = {preview: ['File: /private/config', '+ include nmsh.conf', 'Also changes /cursor Motion: Off → Tail', + 'Also changes /cursor renderer: Portable → Auto', "Turns Kitty's cursor_trail on (3; it would be 0)", 'Notes: Keep existing passes in their original order.', + '+ ' + 'long exact diff contents '.repeat(30), 'End of exact plan'], guidance: 'Effective after configuration reload; written is not loaded.'}; + +test('review wrapping retains every plan character, including long lines and cursor side effects', () => { + for (const [w, h] of sizes) { + for (const line of plan.preview) { + const wrapped = studioWrap(line, w - 4); + assert.equal(wrapped.join(''), line); + assert.ok(wrapped.every(row => displayWidth(row) <= w - 4)); + } + const review = {plan, offset: 0}; + const rows = renderStudioReview(review, 'Review configuration', w, h).map(stripAnsi); + assert.match(rows.join('\n'), /Enter confirm write[\s\S]*Esc cancel/); + assert.ok(rows.length <= h && rows.every(row => displayWidth(row) <= w)); + } +}); + +for (const color of ['truecolor', '16', 'none']) test(`both shader studios retain focus, actions and safe review routing (${color})`, () => { + const savedColor = process.env.NMSH_COLOR, savedNoColor = process.env.NO_COLOR; + if (color === 'none') { delete process.env.NMSH_COLOR; process.env.NO_COLOR = '1'; } + else process.env.NMSH_COLOR = color; + try { + for (const [w, h] of sizes) { + const hub = createAppearanceHub('Host', {opacity: 0.8, blurModeIndex: 0, blurStrength: 0, selectedIndex: 0}); + hub.selected = 9; + const rows = renderAppearanceHub(hub, DEFAULT, w, 'Native', 'Portable', undefined, h); + assert.ok(rows.length <= h && rows.every(row => displayWidth(row) <= w)); + assert.ok(rows.map(stripAnsi).some(row => row.startsWith('›') && row.includes('Opacity'))); + assert.match(stripAnsi(rows.at(-1)!), /Esc/); + hub.confirmation = {plan: structuredClone(plan) as never, offset: 0}; + const review = renderAppearanceHub(hub, DEFAULT, w, 'Native', 'Portable', undefined, h); + assert.ok(review.length <= h && review.every(row => displayWidth(row) <= w)); + assert.match(stripAnsi(review.join('\n')), /Enter confirm write[\s\S]*Esc cancel/); + } + for (const host of ['kitty', 'ghostty'] as const) { + const state = host === 'kitty' ? createKittyShaderPanel(DEFAULT.hostShaders.kitty, gates) : createGhosttyShaderPanel(DEFAULT.hostShaders.ghostty, gates); + // The renderer/key wrappers share one state contract; the catalog is the host's real parser result. + const render = (w: number, h: number) => host === 'kitty' + ? renderKittyShaderPanel(state as ReturnType, w, h, studioPreviewContext(100, false, color !== 'none')) + : renderGhosttyShaderPanel(state as ReturnType, w, h, studioPreviewContext(100, false, color !== 'none')); + const key = (k: Parameters[1]) => host === 'kitty' ? kittyShaderKey(state as ReturnType, k) : ghosttyShaderKey(state as ReturnType, k); + const check = (w: number, h: number, focus = true) => { + const rows = render(w, h), plain = rows.map(stripAnsi); + assert.ok(rows.length <= h, `${host} ${w}x${h} height`); + assert.ok(rows.every(row => displayWidth(row) <= w), `${host} ${w}x${h} width`); + assert.match(plain.at(-1)!, /Esc/); + if (focus) assert.ok(plain.some(row => row.startsWith('›')), `${host} ${w}x${h} focus`); + return plain; + }; + for (const [w, h] of sizes) check(w, h); + state.catalog = (host === 'kitty' ? buildKittyCatalog('0.49.2', resources) : buildGhosttyCatalog('1.3.1')) as typeof state.catalog; + for (const [w, h] of sizes) { + check(w, h); + assert.deepEqual(key({kind: 'text', value: 's'}), {kind: 'review'}); + } + // Toggle the first preset (Kitty starts on placement), then visit every parameter. + if (host === 'kitty') key({kind: 'down'}); + key({kind: 'enter'}); + for (let i = 0; i < 12; i++) { for (const [w, h] of sizes) check(w, h); key({kind: 'down'}); } + for (const [w, h] of sizes) { + state.confirmation = {plan: structuredClone(plan) as never, offset: 0}; + const seen: string[] = []; + for (let scroll = 0; scroll < 80; scroll++) { seen.push(...check(w, h, false)); key({kind: 'down'}); } + assert.match(seen.join('\n'), /End of exact plan/); + assert.match(seen.join('\n'), /Also changes \/cursor/); + assert.deepEqual(key({kind: 'enter'}), {kind: 'confirm'}); + const draft = structuredClone(state.draft); + assert.equal(key({kind: 'escape'}), undefined); + assert.equal(state.confirmation, undefined); + assert.deepEqual(state.draft, draft, 'cancel does not apply draft or write'); + } + state.catalog!.supported = false; state.catalog!.reason = 'Unsupported host version'; + for (const [w, h] of sizes) { check(w, h); assert.equal(key({kind: 'text', value: 's'}), undefined); } + assert.deepEqual(key({kind: 'text', value: 'r'}), {kind: 'remove'}); + assert.deepEqual(key({kind: 'escape'}), {kind: 'back'}); + } + } finally { + if (savedColor === undefined) delete process.env.NMSH_COLOR; else process.env.NMSH_COLOR = savedColor; + if (savedNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = savedNoColor; + } +}); diff --git a/tests/kittyShaderPanel.test.ts b/tests/kittyShaderPanel.test.ts index 11d85a24..c39b3957 100644 --- a/tests/kittyShaderPanel.test.ts +++ b/tests/kittyShaderPanel.test.ts @@ -85,7 +85,7 @@ test('panel: Shift+↑↓ reorders enabled effects; S, R and Enter map to review test('panel: honest states — unsupported Kitty, gates, missing parameters, installed-but-not-offered; fits narrow and short windows', () => { const unsupported = createKittyShaderPanel(DEFAULT_HOST_SHADERS.kitty, gates); unsupported.catalog = buildKittyCatalog('0.48.0', {}); - assert.match(plain(renderKittyShaderPanel(unsupported, 100)).join('\n'), /need Kitty 0\.49 or newer[\s\S]*Cursor effects stay with \/cursor/u); + assert.match(plain(renderKittyShaderPanel(unsupported, 100)).join('\n'), /need Kitty 0\.49 or newer[\s\S]*Cursor effects stay\s+with\s+\/cursor/u); assert.equal(kittyShaderKey(unsupported, key('text', 's')), undefined, 'nothing to review without support'); assert.deepEqual(kittyShaderKey(unsupported, key('text', 'r')), {kind: 'remove'}, 'removal still works on an unsupported Kitty'); const reduced = panel(); diff --git a/tests/shaderPreview.test.ts b/tests/shaderPreview.test.ts index be733710..1b2b8da1 100644 --- a/tests/shaderPreview.test.ts +++ b/tests/shaderPreview.test.ts @@ -99,6 +99,20 @@ test('a dropped preview shows no replay hint and restarts when it can be seen; v assert.doesNotMatch(renderGhosttyShaderPanel(state, 20, undefined, studioPreviewContext(6000, false, true)).map(stripAnsi).join('\n'), /Preview ·/u); }); +test('preview at a footer wrap boundary never advertises replay or keeps a clock for a hidden sample', () => { + for (const columns of [60, 80, 100, 120]) for (let height = 14; height <= 40; height++) { + const state = createGhosttyShaderPanel(structuredClone(DEFAULT_HOST_SHADERS.ghostty), {reducedMotion: false, effectsOff: false, cursorRenderer: 'auto', cursorMotion: 'tail'}); + state.catalog = buildGhosttyCatalog('1.3.1'); + const rows = renderGhosttyShaderPanel(state, columns, height, studioPreviewContext(1000, false, true)).map(stripAnsi); + if (!rows.some(row => row.includes('Preview ·'))) { + assert.ok(!rows.some(row => row.includes('P replay')), `${columns}x${height}`); + assert.equal(state.previewBusy, false); + } + assert.ok(rows.length <= height); + assert.match(rows.at(-1)!, /Esc/); + } +}); + test('trail styles differ in shape, not only in color: Lightning is never drawn like Blaze', () => { // The same colors for every style, so only the drawing can tell them apart. const values = {HEAD: [1, 0.8, 0.6], TAIL: [0.8, 0.1, 0.1]}; diff --git a/tests/welcomeEditor.test.ts b/tests/welcomeEditor.test.ts index 83390b7d..e1267220 100644 --- a/tests/welcomeEditor.test.ts +++ b/tests/welcomeEditor.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {createWelcomeEditor, welcomeEditorKey, renderWelcomeEditor} from '../src/welcome/editor.js'; import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; import {appearanceHubKey, createAppearanceHub} from '../src/appearance/AppearanceHub.js'; -import {displayWidth} from '../src/util/text.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; test('editor Esc cancels a field and restores draft and selection, then closes without saving', () => { const state = createWelcomeEditor(normalizePromptConfiguration({})); @@ -40,3 +40,68 @@ test('an unchanged editor preview retains the legacy Vespyr card and save explic welcomeEditorKey(state, {kind: 'text', value: 's'}, 0); assert.equal(state.draft.customized, true); }); + +test('section tabs jump without mutating the draft, and a choice gallery cancels without losing edits', () => { + const state = createWelcomeEditor(normalizePromptConfiguration({})); + const draft = structuredClone(state.draft); + welcomeEditorKey(state, {kind: 'complete'}); + assert.equal(state.selected, 6, 'next section is typography'); + assert.deepEqual(state.draft, draft); + state.selected = 24; + welcomeEditorKey(state, {kind: 'text', value: 'g'}); + const rows = renderWelcomeEditor(state, 80, 24, {}, {color: false}, 0).map(stripAnsi); + assert.match(rows.join('\n'), /Choose preset/); + assert.ok(rows.some(row => row.startsWith('›'))); + welcomeEditorKey(state, {kind: 'down'}); + welcomeEditorKey(state, {kind: 'escape'}); + assert.deepEqual(state.draft, draft); + assert.equal(state.selected, 24); +}); + +test('all editor fields and pickers keep focus and complete controls in the four requested sizes', () => { + for (const [columns, height] of [[120, 40], [80, 24], [60, 20], [40, 14]]) { + const state = createWelcomeEditor(normalizePromptConfiguration({})); + for (let selected = 0; selected < 31; selected++) { + state.selected = selected; + const rows = renderWelcomeEditor(state, columns!, height!, {}, {color: false}, 0); + assert.ok(rows.length <= height! && rows.every(row => displayWidth(row) <= columns!)); + assert.ok(rows.map(stripAnsi).some(row => row.startsWith('›')), `focus ${selected} at ${columns}x${height}`); + assert.match(stripAnsi(rows.join('\n')), /S save/); + assert.match(stripAnsi(rows.at(-1)!), /Esc/); + } + state.picker = {directory: '/assets', entries: [{name: 'logo.png', path: '/assets/logo.png', directory: false}], selected: 0}; + const rows = renderWelcomeEditor(state, columns!, height!, {}, {color: false}, 0); + assert.ok(rows.map(stripAnsi).some(row => row.startsWith('›') && row.includes('logo.png'))); + assert.match(stripAnsi(rows.at(-1)!), /Esc/); + } +}); + +test('monochrome gallery, file browser, empty state and text/error views preserve editor actions', () => { + const color = process.env.NMSH_COLOR, noColor = process.env.NO_COLOR; + delete process.env.NMSH_COLOR; process.env.NO_COLOR = '1'; + try { + for (const [columns, height] of [[120, 40], [80, 24], [60, 20], [40, 14]] as const) { + const state = createWelcomeEditor(normalizePromptConfiguration({})); + const check = (focus = true) => { + const rows = renderWelcomeEditor(state, columns, height, {}, {color: false}, 0); + assert.ok(rows.length <= height && rows.every(row => displayWidth(row) <= columns)); + assert.match(stripAnsi(rows.at(-1)!), /Esc/); + assert.doesNotMatch(rows.join(''), /\x1b\[(?:38|48);/u); + if (focus) assert.ok(rows.map(stripAnsi).some(row => row.startsWith('›'))); + }; + check(); + for (const field of [1, 5, 12, 24]) { + state.selected = field; welcomeEditorKey(state, {kind: 'text', value: 'g'}); + check(); welcomeEditorKey(state, {kind: 'escape'}); + } + state.input = {kind: 'export', value: '/local/welcome.json', before: structuredClone(state.draft), selected: 29}; + state.message = 'Could not create file; draft kept.'; check(); + welcomeEditorKey(state, {kind: 'escape'}); + state.picker = {directory: '/local', entries: [], selected: 0}; check(false); + state.picker.entries = [{name: 'cat.png', path: '/local/cat.png', directory: false}]; check(); + } + } finally { + if (color === undefined) delete process.env.NMSH_COLOR; else process.env.NMSH_COLOR = color; + if (noColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = noColor; + } +}); diff --git a/tests/welcomeRuntime.test.ts b/tests/welcomeRuntime.test.ts index 9f1420cc..112d0ed4 100644 --- a/tests/welcomeRuntime.test.ts +++ b/tests/welcomeRuntime.test.ts @@ -27,6 +27,31 @@ function host(directory: string, studio = {}): StudioHost { const enter = {kind: 'enter'} as const; const escape = {kind: 'escape'} as const; +test('full preview puts its exit hint in free space and returning preserves unsaved gallery edits', async () => { + const dir = await mkdtemp(join(tmpdir(), 'welcome-preview-ui-')); + const h = host(dir, {customized: true, title: 'Unsaved title'}); + const controller = new WelcomeStudioController(h); + h.capabilities = () => ({color: true, reducedMotion: true}); + try { + controller.state.selected = 24; + const draft = structuredClone(controller.state.draft); + await controller.key({kind: 'text', value: 'g'}); + await controller.key({kind: 'text', value: 'p'}); + assert.equal(controller.fullPreview, false, 'gallery owns P'); + await controller.key(escape); + for (const [width, height] of [[120, 40], [80, 24], [60, 20], [40, 14]] as const) { + await controller.key({kind: 'text', value: 'p'}); + const rows = controller.renderPreview(width, height); + assert.match(rows.join(''), /Any key.*return to Welcome Studio/); + assert.ok(rows.length <= height && rows.every(row => displayWidth(row) <= width)); + await controller.key(enter); + assert.equal(controller.fullPreview, false); + assert.deepEqual(controller.state.draft, draft); + assert.equal(controller.state.selected, 24); + } + } finally { controller.dispose(); await rm(dir, {recursive: true, force: true}); } +}); + test('late import respects Esc; before/after retains the original asset; disposing unsubscribes preview', async () => { const dir = await mkdtemp(join(tmpdir(), 'welcome-runtime-')); const h = host(dir, {customized: true, logo: 'image', assetPath: 'red.png', effect: 'float'}); From f581a713bf1139c2dfa7b14841350f764f50c00d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:29:39 +0530 Subject: [PATCH 4/5] Studio review fixes: no confirm of an unseen plan, every notice kept, Placement tab order, indented wrap - A review too small to show its plan ('Resize to review') offers only Esc, and Enter does nothing until the plan has been shown at a usable size (shader studios and the host review). - The studio keeps every notice (result or refusal first, then reload and gate) and ends a notice that does not fit with an ellipsis instead of cutting it silently. - Tab/Shift+Tab from Kitty's Placement row go to the first/last category. - Wrapping never leaves a row of indentation only; P replay is not advertised while typing RGB; the review's guidance heading says 'After Enter' (a plain host write reloads nothing). --- src/appearance/AppearanceHub.ts | 5 +++-- src/appearance/ShaderStudioPanel.ts | 15 +++++++++----- src/ui/StudioLayout.ts | 21 ++++++++++++++----- tests/appearanceStudioLayout.test.ts | 31 ++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/appearance/AppearanceHub.ts b/src/appearance/AppearanceHub.ts index 48df53f9..95fa1bf1 100644 --- a/src/appearance/AppearanceHub.ts +++ b/src/appearance/AppearanceHub.ts @@ -34,7 +34,7 @@ export interface AppearanceHubState { hostGuidance?: string; /** Host values were changed and not saved yet. */ hostDirty: boolean; - confirmation?: {plan: HostAppearancePlan; offset: number; rowCount?: number}; + confirmation?: {plan: HostAppearancePlan; offset: number; rowCount?: number; shown?: boolean}; backgroundInput?: string; hostMessage?: string; canRestoreHost?: boolean; @@ -76,7 +76,8 @@ export function appearanceHubKey(state: AppearanceHubState, key: Key, configurat if (state.confirmation) { if (key.kind === 'escape' || key.kind === 'interrupt') { state.confirmation = undefined; return undefined; } if (key.kind === 'up' || key.kind === 'down') state.confirmation.offset = Math.max(0, Math.min((state.confirmation.rowCount ?? state.confirmation.plan.preview.length) - 1, state.confirmation.offset + (key.kind === 'up' ? -1 : 1))); - return key.kind === 'enter' ? {kind: 'confirmHost'} : undefined; + // Enter confirms only a plan the person could see (not the 'Resize to review' stub). + return key.kind === 'enter' && state.confirmation.shown !== false ? {kind: 'confirmHost'} : undefined; } if (state.backgroundInput !== undefined) { if (key.kind === 'escape' || key.kind === 'interrupt') state.backgroundInput = undefined; diff --git a/src/appearance/ShaderStudioPanel.ts b/src/appearance/ShaderStudioPanel.ts index 8efe52da..f3504644 100644 --- a/src/appearance/ShaderStudioPanel.ts +++ b/src/appearance/ShaderStudioPanel.ts @@ -43,7 +43,7 @@ export interface ShaderStudioState(state: ShaderStudioState(state: ShaderStudioState list.some(row => row.kind !== 'placement' && row.preset.definition.category === c)); - const next = categories[(categories.indexOf(category!) + tabCycleDelta(key)! + categories.length) % categories.length]; + // From the Placement row (no category) Tab goes to the first category and Shift+Tab to the last. + const delta = tabCycleDelta(key)!; + const index = category ? categories.indexOf(category) : delta > 0 ? -1 : 0; + const next = categories[(index + delta + categories.length) % categories.length]; state.selected = Math.max(0, list.findIndex(row => row.kind !== 'placement' && row.preset.definition.category === next)); return undefined; } @@ -321,13 +325,14 @@ export function renderShaderStudio(state: ShaderStudioState CATEGORY_LABELS[c]), selected: Math.max(0, categories.indexOf(preset?.definition.category ?? categories[0]!))}} : {}), entries: entries.length ? entries : [{group: supported ? 'Presets' : 'Compatibility', label: !catalog ? `Reading ${host.name}’s shader support…` : clean(catalog.reason ?? 'No presets available'), render: () => !catalog ? `Reading ${host.name}’s shader support…` : clean(catalog.reason ?? 'No presets available')}], - selected, controls, notice: reload ?? message ?? gate, + // Every notice is kept, the refusal or result first; the layout ends a long one with an ellipsis. + selected, controls, notice: [message, reload, gate].filter(Boolean).join(' · ') || undefined, details: catalog && supported ? details : (width: number) => studioWrap(catalog ? `${catalog.reason ?? ''} ${host.unsupportedHint}` : `Reading ${host.name}’s configuration…`, width).map(studioMuted), }; // Unbounded callers (diagnostics/tests) receive the complete catalogue and details. const limit = height ?? Math.max(64, list.length + 45); let result = renderStudioLayout(model, columns, limit); - if (previewShown && preview && !preview.still && kind && PREVIEW_MS[kind] > 0) { + if (previewShown && preview && !preview.still && kind && PREVIEW_MS[kind] > 0 && !state.colorInput) { controls.splice(controls.length - 1, 0, ['P', 'replay']); state.previewBusy = false; previewShown = false; result = renderStudioLayout(model, columns, limit); diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts index 852058e5..9e04f959 100644 --- a/src/ui/StudioLayout.ts +++ b/src/ui/StudioLayout.ts @@ -18,8 +18,10 @@ export function studioWrap(text: string, width: number): string[] { let row = ''; for (const char of stripTerminalControls(text)) { if (displayWidth(row + char) > Math.max(1, width) && row) { + // Break at the last space after the text starts; an indented long word (a diff path) splits instead of leaving a row of spaces. + const lead = row.length - row.trimStart().length; const space = row.lastIndexOf(' '); - if (space > 0) { rows.push(row.slice(0, space + 1)); row = row.slice(space + 1); } + if (space > 0 && space >= lead) { rows.push(row.slice(0, space + 1)); row = row.slice(space + 1); } else { rows.push(row); row = ''; } } row += char; @@ -60,7 +62,11 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height const head = [studioLabel(model.title), ...(model.subtitle && height >= 12 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; - const notices = model.notice ? studioWrap(model.notice, columns).slice(0, height >= 24 ? 2 : 1).map(studioMuted) : []; + const wrappedNotice = model.notice ? studioWrap(model.notice, columns) : []; + const noticeRows = height >= 24 ? 2 : 1; + // A notice that does not fit ends in an ellipsis rather than being cut silently. + const notices = wrappedNotice.slice(0, noticeRows).map((row, index) => index === noticeRows - 1 && wrappedNotice.length > noticeRows + ? truncateAnsi(`${row.trimEnd()} …`, columns) : row).map(studioMuted); const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - 1); const widths = model.stacked || !model.details ? {list: columns, detail: columns} : studioColumns(columns, height); const wide = widths.list !== columns && Boolean(model.details); @@ -92,14 +98,19 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height return framePanel([...head, ...body, ...notices, '', ...footer].map(row => truncateAnsi(row, columns)), columns); } -export interface StudioReview {plan: {preview: string[]; guidance: string}; offset: number; rowCount?: number} +/** shown: false while the terminal is too small to show the plan; callers then refuse Enter (never confirm an unseen plan). */ +export interface StudioReview {plan: {preview: string[]; guidance: string}; offset: number; rowCount?: number; shown?: boolean} /** Exact-plan review, shared by host appearance and shader transactions. Only callers handle keys/writes. */ export function renderStudioReview(review: StudioReview, title: string, columns: number, height = 24): string[] { const width = Math.max(1, columns - 4); - const document = [...review.plan.preview, '', 'Apply & reload', review.plan.guidance].flatMap(line => studioWrap(line, width)); + const document = [...review.plan.preview, '', 'After Enter', review.plan.guidance].flatMap(line => studioWrap(line, width)); review.rowCount = document.length; const controls = renderControlRows([['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']], columns); - if (height < 8 || columns < 24) return framePanel(['Resize to review', ...controls], columns).slice(0, height); + if (height < 8 || columns < 24) { + review.shown = false; + return framePanel(['Resize to review', ...renderControlRows([['Esc', 'cancel']], columns)], columns).slice(0, height); + } + review.shown = true; const guidance = studioWrap(review.plan.guidance, columns).map(studioMuted); const head = [studioLabel(title), studioMuted('Review only · nothing written yet')]; const available = Math.max(1, height - 1 - head.length - controls.length - Math.min(2, guidance.length) - 2); diff --git a/tests/appearanceStudioLayout.test.ts b/tests/appearanceStudioLayout.test.ts index 0bb692ff..deee451f 100644 --- a/tests/appearanceStudioLayout.test.ts +++ b/tests/appearanceStudioLayout.test.ts @@ -125,3 +125,34 @@ for (const color of ['truecolor', '16', 'none']) test(`both shader studios retai if (savedNoColor === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = savedNoColor; } }); + +test('review fixes: an unseen plan cannot be confirmed; Shift+Tab from Placement; indented wrap; long notices end with an ellipsis', () => { + // Too small to show the plan: no "confirm write" is offered and Enter does nothing; a usable size makes Enter confirm again. + const s = createGhosttyShaderPanel(DEFAULT.hostShaders.ghostty, gates); + s.catalog = buildGhosttyCatalog('1.3.1'); + s.confirmation = {plan: {...plan, preview: ['+ custom-shader = x']} as never, offset: 0}; + const tiny = renderGhosttyShaderPanel(s, 40, 6).map(stripAnsi).join('\n'); + assert.match(tiny, /Resize to review/u); + assert.doesNotMatch(tiny, /confirm write/u); + assert.equal(ghosttyShaderKey(s, {kind: 'enter'} as never), undefined); + assert.ok(s.confirmation, 'still reviewing'); + renderGhosttyShaderPanel(s, 80, 24); + assert.equal(ghosttyShaderKey(s, {kind: 'enter'} as never)?.kind, 'confirm'); + // Kitty's Placement row has no category: Shift+Tab goes to the last category, Tab to the first. + const k = createKittyShaderPanel(DEFAULT.hostShaders.kitty, gates); + k.catalog = buildKittyCatalog('0.49.2', resources); + k.selected = 0; + kittyShaderKey(k, {kind: 'focusPrevious'} as never); + assert.ok(renderKittyShaderPanel(k, 120, 40).map(stripAnsi).some(row => row.startsWith('›') && row.includes('Spotlight')), 'Shift+Tab from Placement selects the last category'); + k.selected = 0; + kittyShaderKey(k, {kind: 'complete'} as never); + assert.ok(renderKittyShaderPanel(k, 120, 40).map(stripAnsi).some(row => row.startsWith('›') && row.includes('Blaze')), 'Tab from Placement selects the first category'); + // An indented long word splits instead of leaving a row of spaces; ordinary text still breaks at spaces. + assert.ok(studioWrap(' /a/very/long/path/name', 10).every(row => row.trim().length > 0)); + assert.deepEqual(studioWrap('alpha beta gamma', 11), ['alpha beta ', 'gamma']); + // A notice longer than its rows ends with an ellipsis. + const g = createGhosttyShaderPanel(DEFAULT.hostShaders.ghostty, gates); + g.catalog = buildGhosttyCatalog('1.3.1'); + g.message = 'word '.repeat(80); + assert.ok(renderGhosttyShaderPanel(g, 60, 20).map(stripAnsi).some(row => row.trimEnd().endsWith('…'))); +}); From b8a8139c0899633c1193bf3e8dcb6886dd8e4670 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:44:18 +0530 Subject: [PATCH 5/5] Studio layout: a list that does not fit takes the subtitle and spacer rows before scrolling --- src/ui/StudioLayout.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts index 9e04f959..d98f3f45 100644 --- a/src/ui/StudioLayout.ts +++ b/src/ui/StudioLayout.ts @@ -59,20 +59,24 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height return framePanel([truncateAnsi(model.title, columns), truncateAnsi(row, columns), ...controls], columns).slice(0, height); } const footer = renderControlRows(model.controls, columns); - const head = [studioLabel(model.title), - ...(model.subtitle && height >= 12 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []), - ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; const wrappedNotice = model.notice ? studioWrap(model.notice, columns) : []; const noticeRows = height >= 24 ? 2 : 1; // A notice that does not fit ends in an ellipsis rather than being cut silently. const notices = wrappedNotice.slice(0, noticeRows).map((row, index) => index === noticeRows - 1 && wrappedNotice.length > noticeRows ? truncateAnsi(`${row.trimEnd()} …`, columns) : row).map(studioMuted); - const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - 1); + const lines = groupLines(model.entries, entry => entry.group); + // Explanations give way before list rows: a list that does not fit first takes the subtitle's row, then the spacer above the footer. + const fixedRows = 1 + 1 + (model.tabs ? 1 : 0) + notices.length + footer.length; + const short = lines.length - (height - fixedRows - (model.subtitle && height >= 12 ? 1 : 0) - 1); + const subtitle = model.subtitle && height >= 12 && short <= 0 ? [studioMuted(truncateAnsi(model.subtitle, columns))] : []; + const spacer = model.subtitle && height >= 12 ? (short >= 2 ? [] : ['']) : (short >= 1 ? [] : ['']); + const head = [studioLabel(model.title), ...subtitle, + ...(model.tabs ? [renderTabStrip(model.tabs.labels, model.tabs.selected, columns)] : [])]; + const budget = Math.max(1, height - 1 - head.length - notices.length - footer.length - spacer.length); const widths = model.stacked || !model.details ? {list: columns, detail: columns} : studioColumns(columns, height); const wide = widths.list !== columns && Boolean(model.details); const detailBudget = model.details && !wide && columns >= 60 && height >= 24 && budget >= 12 ? Math.min(model.stacked ? 30 : 10, budget - 5) : 0; const listBudget = wide ? budget : budget - detailBudget; - const lines = groupLines(model.entries, entry => entry.group); const selected = lines.findIndex(line => line.kind === 'item' && line.index === model.selected); const window = groupedWindow(lines, selected < 0 ? undefined : selected, listBudget); const list = lines.slice(window.start, window.end).map(line => { @@ -95,7 +99,7 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height model.detailOrigin?.(0, head.length + 1 + list.length); body = [...list, ...model.details!(columns, detailBudget).slice(0, detailBudget)]; } - return framePanel([...head, ...body, ...notices, '', ...footer].map(row => truncateAnsi(row, columns)), columns); + return framePanel([...head, ...body, ...notices, ...spacer, ...footer].map(row => truncateAnsi(row, columns)), columns); } /** shown: false while the terminal is too small to show the plan; callers then refuse Enter (never confirm an unseen plan). */