From e309da8090a0404d9a31f9a05f4b1c2afb009023 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:51:14 +0530 Subject: [PATCH] Add the /layout showcase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /layout (also Config → Layout) previews composer position (Bottom, Top, Flow) and transcript presentation (Normal, Chat) before choosing. The preview renders a fixed sample (a folded build, a multi-line command, a failure and a running command) through the real transcript presenter and planScreen, in its own OutputBuffer, so nothing runs and nothing reaches the transcript, journal or /copy. It is sized to show the whole sample when the terminal allows, so Flow's prompt visibly follows the output, with a caption describing each position. Up/Down move, Left/Right change, Enter saves and applies live through the existing settings path, Esc cancels. The choices come from shared value lists that the Config rows now use too, so there is one configuration. Also: the Flow end-to-end test resends its exit key until the fullscreen app takes it, instead of assuming passthrough was already active. --- CHANGELOG.md | 1 + README.md | 2 +- docs/design/session-interaction-ux.md | 9 ++ src/app/TerminalApp.ts | 42 ++++- src/commands/slashCommands.ts | 3 + src/prompt/configuration.ts | 5 + src/ui/LayoutPanel.ts | 174 +++++++++++++++++++++ src/ui/SettingsPanel.ts | 10 +- tests/fixtures/layout-preview.txt | 216 ++++++++++++++++++++++++++ tests/flowComposer.test.ts | 12 +- tests/layoutPanel.test.ts | 154 ++++++++++++++++++ 11 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 src/ui/LayoutPanel.ts create mode 100644 tests/fixtures/layout-preview.txt create mode 100644 tests/layoutPanel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a464831..1b53945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **`/layout` showcase:** preview composer position (Bottom, Top, Flow) × transcript presentation (Normal, Chat) on sample content through the real renderer, then save and apply live. Also under Config → Layout. Nothing in the preview runs or reaches the transcript, journal or `/copy`. - **Flow composer:** Config → Composer position → Flow (the command palette's Toggle composer position cycles Bottom, Top and Flow). The prompt and input follow the newest output inside NMSh's document, like a conventional terminal, and scroll with it. Typing while scrolled back returns to them; scrolling alone does not. Menus open below the input, panels pin to the bottom, and Chat presentation and fullscreen passthrough work as before. - **Startup restore is your choice:** Config → Sessions → Startup restore (Ask, the default; Always; or Never) and Multiple detached sessions (Ask which, or Open all). With one detached session, NMSh asks: Resume, Not now, Always or Don't resume at startup. With several, a picker restores the ones you select: this window takes one, and the others open in new Ghostty, Terminal.app or kitty windows. Where a host can't open windows, NMSh names the `nmsh --attach` command for each. Never only skips restoring at launch; it never ends a session. - **Live-session hardening:** live sessions that end while no window is attached are archived at the next launch or `/resume`, with the real exit code or a note that the service stopped or the system restarted. Output captured while detached is kept. A frontend that loses its service reports it and archives the transcript. `/resume` shows how long each idle live session has been at its prompt. diff --git a/README.md b/README.md index b193ea0..6b2d5f1 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ NMSh provides a richer interactive frontend without throwing away the proven rob - Command lifecycle rows with activity animation and nested Node TAP activity - Scrollable history with muted snapshots of each command's prompt; tune dividers and history colors with `/transcript` - Output folding (Config → Output folding: Off / Smart / Always): long output collapses to its first and last lines around `› N lines hidden · Ctrl+O`; Smart keeps failures and useful output expanded; `/copy` and `/resume` always keep the full output -- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). +- Composer position Bottom, Top, or Flow (Config → Composer position). Flow places the prompt and input right after the newest output, like a conventional terminal, and they scroll with it. Combine any position with Normal or Chat transcript presentation (Config → Transcript presentation). `/layout` (also Config → Layout) previews every combination with sample content before you choose. - Welcome providers: Vespyr (default), Fastfetch, Neofetch (legacy, if installed), or None (`/settings` → Welcome) - Command palette: `/palette`, F1, or Ctrl+Shift+P (Cmd+Shift+P where the terminal reports it) to search NMSh commands, settings, and actions - Sticky command headers keep the current command visible while scrolling diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index 58bed02..210f277 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -72,6 +72,15 @@ Composer position is Bottom (default), Top or Flow. Bottom and Top dock the comp - **Chat:** with Chat presentation, the live composer renders normally and historical commands render right-aligned. - **Passthrough:** fullscreen apps get the raw terminal as before. Returning repaints the document and the composer from the plan. +## `/layout` showcase + +`/layout` (also `/settings` → Config → Layout) previews composer position (Bottom, Top, Flow) × transcript presentation (Normal, Chat) before you choose. It has two rows, Composer position and Transcript: ↑↓ move, ←→ change, Enter saves and applies live, and Esc cancels. + +- **Sample content:** a folded build, a multi-line command, a failure and a running command with its activity row. It lives in its own `OutputBuffer` and never runs anything or reaches the transcript, journal or `/copy`. Completion times use a fixed date, so the preview is deterministic. +- **Real rendering:** the preview goes through the real transcript presenter and `planScreen`, the same geometry the live screen uses. Only the prompt and input text are placeholders. +- **Size:** the preview is sized to show the whole sample with spare rows, so Flow's prompt visibly follows the output. A short terminal gets a shorter preview in which, just as on a real full screen, Flow looks like Bottom. A caption describes the selected position. +- **One configuration:** the choices come from the same value lists as the Config rows and the palette toggles, and saving writes the same `composerPosition` and `transcriptPresentation` settings. + ## Rich text paste Large multiline text-only pastes appear as one editable logical atom, labeled in current editor order (for example, `[Text #1 · 7 lines]`). Small pastes remain ordinary text. Cursor movement and adjacent deletion treat an atom as one unit; Ctrl+O optionally unwraps the atom beside the caret back into editable source. Enter submits the exact underlying source of every atom in place, together with typed prefix, interstitial, and suffix text; visual labels are presentation-only and never reach zsh. Bracketed paste and multiline submission remain supported. Image clipboard behavior is out of scope. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index f09dc68..a68561f 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -64,6 +64,7 @@ import {installGhosttyKeybinding} from '../keyboard/ghosttyKeyboard.js'; import {detectGhosttyConfigPath, readGhosttySettings, saveGhosttySettings} from '../appearance/ghostty.js'; import {Highlighter} from '../input/Highlighter.js'; import {handleSyntaxPanelKey, renderSyntaxPanel, type SyntaxPanelState} from '../input/SyntaxPanel.js'; +import {createLayoutPanel, handleLayoutPanelKey, renderLayoutPanel, type LayoutPanelState} from '../ui/LayoutPanel.js'; import {syntaxCharStyles, syntaxSgrForConfiguration, type SyntaxSgr} from '../input/syntaxTheme.js'; import {SemanticService} from '../shell/SemanticService.js'; import {chooseShellHandoff, type ShellHandoffDecision} from '../shell/ShellHandoff.js'; @@ -149,6 +150,7 @@ export class TerminalApp { /** Bumped by every new or restored presentation so a late capture never lands in the wrong one. */ private welcomeGeneration = 0; private syntaxPanelState?: SyntaxPanelState; + private layoutPanelState?: LayoutPanelState; private settingsPanelState?: SettingsPanelState; private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string}; private hoveredLineIndex?: number; @@ -470,6 +472,16 @@ export class TerminalApp { this.render(); return; } + if (this.layoutPanelState) { + if (key.kind === 'escape' || key.kind === 'interrupt') { + this.layoutPanelState = undefined; + this.returnFromPanel(); + this.render(); + } else if (key.kind === 'enter') { + this.saveLayoutSettings(); + } else if (handleLayoutPanelKey(key, this.layoutPanelState)) this.render(); + return; + } if (this.syntaxPanelState) { if (key.kind === 'escape' || key.kind === 'interrupt') { this.syntaxPanelState = undefined; @@ -945,6 +957,7 @@ export class TerminalApp { else if (slash.kind === 'settings') this.openSettingsPanel(slash.view); else if (slash.kind === 'transcript') { this.panelOrigin = undefined; this.startTranscriptSettings(); } else if (slash.kind === 'syntax') { this.panelOrigin = undefined; this.startSyntaxSettings(); } + else if (slash.kind === 'layout') { this.panelOrigin = undefined; this.startLayoutSettings(); } else if (slash.kind === 'keyboard') { this.panelOrigin = undefined; await this.startKeyboard(); } else if (slash.kind === 'zsh') this.leaveForOrdinaryZsh(); else if (slash.kind === 'version') this.output.addFrontendInteraction(command, formatBuildIdentity(this.buildIdentity), INFO); @@ -1827,7 +1840,7 @@ export class TerminalApp { } private get settingsPanelActive(): boolean { - return Boolean(this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.settingsPanelState + return Boolean(this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState || this.resumeBrowser || this.appearanceState || this.keyboardState); } @@ -1836,6 +1849,9 @@ export class TerminalApp { return renderSettingsPanel(this.settingsPanelState, columns, this.dimensions().rows, {configuration: this.promptConfiguration, status: settingsView(this.settingsPanelState) === 'status' ? this.statusSections() : undefined}); } + if (this.layoutPanelState) { + return framePanel(renderLayoutPanel(this.layoutPanelState, columns, this.dimensions().rows - 4), columns); + } if (this.syntaxPanelState) { return framePanel(renderSyntaxPanel(this.syntaxPanelState, columns, this.promptConfiguration.nmsh.palette, this.dimensions().rows - 4), columns); } @@ -2009,6 +2025,7 @@ export class TerminalApp { else if (destination === 'prompt') void this.startPromptSettings(false); else if (destination === 'transcript') this.startTranscriptSettings(); else if (destination === 'syntax') this.startSyntaxSettings(); + else if (destination === 'layout') this.startLayoutSettings(); else if (destination === 'welcome' || destination === 'suggestions') this.startProviderPanel(destination); else void this.startKeyboard(); } @@ -2279,6 +2296,29 @@ export class TerminalApp { this.render(); } + private startLayoutSettings(): void { + const {composerPosition, transcriptPresentation} = this.promptConfiguration; + this.layoutPanelState = createLayoutPanel({composerPosition, transcriptPresentation}); + } + + /** Persist the chosen layout and apply it live; the preview itself never touched the real transcript. */ + private saveLayoutSettings(): void { + const state = this.layoutPanelState; + if (!state) return; + const next = {...structuredClone(this.promptConfiguration), ...state.draft}; + this.applySettingsConfiguration(next); + if (this.promptConfiguration !== next) { + state.message = 'Could not save the layout; check that the NMSh configuration directory is writable.'; + this.render(); + return; + } + this.layoutPanelState = undefined; + this.historyViewport.latest(); + this.returnFromPanel(); + this.output.addHistoryLine(`${SUCCESS}Layout saved.${RESET}`); + this.render(); + } + private startSyntaxSettings(): void { const saved = structuredClone(this.promptConfiguration.syntax); this.syntaxPanelState = {selectedIndex: 0, draft: structuredClone(saved), saved}; diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index f679b42..3f7b378 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -13,6 +13,7 @@ export const slashCommands: readonly SlashCommand[] = [ {name: '/config', insertion: '/config', description: 'Open NMSh settings (Config view)'}, {name: '/status', insertion: '/status', description: 'Show NMSh status'}, {name: '/syntax', insertion: '/syntax', description: 'Configure syntax highlighting'}, + {name: '/layout', insertion: '/layout', description: 'Preview and choose composer position and transcript presentation'}, {name: '/transcript', insertion: '/transcript', description: 'Configure historical prompts and dividers'}, {name: '/keyboard', insertion: '/keyboard', description: 'Configure keyboard integration'}, {name: '/zsh', insertion: '/zsh', description: 'Return to an ordinary interactive zsh'}, @@ -33,6 +34,7 @@ export type ParsedSlashCommand = | {kind: 'settings'; view: 'config' | 'status'} | {kind: 'transcript'} | {kind: 'syntax'} + | {kind: 'layout'} | {kind: 'keyboard'} | {kind: 'zsh'} | {kind: 'version'} @@ -54,6 +56,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/status\s*$/u.test(input)) return {kind: 'settings', view: 'status'}; if (/^\/transcript\s*$/u.test(input)) return {kind: 'transcript'}; if (/^\/syntax\s*$/u.test(input)) return {kind: 'syntax'}; + if (/^\/layout\s*$/u.test(input)) return {kind: 'layout'}; if (/^\/keyboard\s*$/u.test(input)) return {kind: 'keyboard'}; if (/^\/zsh\s*$/u.test(input)) return {kind: 'zsh'}; if (/^\/version\s*$/u.test(input)) return {kind: 'version'}; diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 36abada..4dadbb5 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -30,6 +30,11 @@ export type ComposerLayout = 'oneLine' | 'twoLine'; /** Bottom and Top dock the composer; Flow places it right after the newest output, inside the document. */ export type ComposerPosition = 'bottom' | 'top' | 'flow'; export type TranscriptPresentation = 'normal' | 'chat'; +/** Implemented layout choices, shared by Config rows and the /layout showcase. */ +export const COMPOSER_POSITIONS: readonly ComposerPosition[] = ['bottom', 'top', 'flow']; +export const COMPOSER_POSITION_LABELS: Record = {bottom: 'Bottom', top: 'Top', flow: 'Flow'}; +export const TRANSCRIPT_PRESENTATIONS: readonly TranscriptPresentation[] = ['normal', 'chat']; +export const TRANSCRIPT_PRESENTATION_LABELS: Record = {normal: 'Normal', chat: 'Chat'}; export type GlyphStyle = 'nerd' | 'safe'; export type SessionRetention = 100 | 500 | 1000 | 5000 | null; export type ContextModuleId = 'project' | 'cwd' | 'gitBranch' | 'gitStatus' | 'toolchain' | 'exitStatus' | 'kubeContext' | 'dockerContext'; diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts new file mode 100644 index 0000000..a30fcd3 --- /dev/null +++ b/src/ui/LayoutPanel.ts @@ -0,0 +1,174 @@ +import {planScreen, type RegionKind} from '../app/screenPlan.js'; +import {OutputBuffer} from '../output/OutputBuffer.js'; +import { + COMPOSER_POSITION_LABELS, + COMPOSER_POSITIONS, + TRANSCRIPT_PRESENTATION_LABELS, + TRANSCRIPT_PRESENTATIONS, + type ComposerPosition, + type TranscriptPresentation, +} from '../prompt/configuration.js'; +import {completedActivity, liveActivityParts} from '../status/activity.js'; +import type {Key} from '../terminal/keys.js'; +import {truncateAnsi, repeatToWidth} from '../util/text.js'; +import {renderControls} from './controls.js'; +import {GLYPHS} from './glyphs.js'; +import {foreground, UI_COLORS} from './palette.js'; + +export interface LayoutChoice { + composerPosition: ComposerPosition; + transcriptPresentation: TranscriptPresentation; +} + +export interface LayoutPanelState { + selectedIndex: number; + draft: LayoutChoice; + /** The layout in effect; the draft is only a preview until saved. */ + saved: LayoutChoice; + message?: string; +} + +const PRIMARY = foreground(UI_COLORS.primary); +const SECONDARY = foreground(UI_COLORS.secondary); +const ACCENT = foreground(UI_COLORS.accent); +const SUBTLE = foreground(UI_COLORS.subtle); +const SEPARATOR = foreground(UI_COLORS.separator); +const RESET = '\u001B[0m'; + +/** A fixed date so completion times in the preview never change between renders. */ +const FIXTURE_TIME = new Date(2026, 0, 1, 9, 41, 0); + +/** + * The showcase transcript: a folded build, a multi-line command, a failure and + * a command still running. It lives in its own OutputBuffer, + * so nothing here runs, and nothing reaches the real transcript, journal or /copy. + */ +function fixture(presentation: TranscriptPresentation): {output: OutputBuffer; running: string} { + const output = new OutputBuffer(); + output.setOutputFolding('smart'); + output.presenter.setLayout(presentation); + const finish = (command: string, exitCode: number, elapsedMs: number) => { + output.complete(exitCode); + const parts = completedActivity(command, elapsedMs, FIXTURE_TIME, exitCode, false); + output.setCompletionLifecycle(`${parts.main}${parts.detail}`); + output.addHistoryLine(`${parts.main}${parts.detail}`); + }; + output.beginCommand('npm run build', ['❯ npm run build']); + output.write(Array.from({length: 40}, (_, index) => `compiled module ${index + 1}/40\r\n`).join('')); + finish('npm run build', 0, 2_400); + output.beginCommand('for f in *.log\ndo gzip "$f"\ndone', ['❯ for f in *.log', ' do gzip "$f"', ' done']); + finish('for f in *.log\ndo gzip "$f"\ndone', 0, 310); + output.beginCommand('cat missing.txt', ['❯ cat missing.txt']); + output.write('cat: missing.txt: No such file or directory\r\n'); + finish('cat missing.txt', 1, 8); + const running = 'npm test'; + output.beginCommand(running, [`❯ ${running}`]); + output.write('▶ suite\r\n ✔ renders the layout (3ms)\r\n'); + return {output, running}; +} + +/** + * A deterministic mini-screen for one layout: the fixture through the real + * presenter, placed by the real ScreenPlan. Only the composer's own text is a + * fixed placeholder. + */ +/** Rows the whole fixture needs with a composer and spare rows below it, so Flow is visibly different. */ +export function layoutPreviewHeight(columns: number): number { + return fixture('normal').output.wrapped(Math.max(10, columns)).length + 8; +} + +const POSITION_CAPTIONS: Record = { + bottom: 'Bottom: the composer stays docked at the bottom edge.', + top: 'Top: the composer stays at the top, and the transcript reads downward below it.', + flow: 'Flow: the prompt follows the newest output and scrolls with it; it reaches the bottom once output fills the screen.', +}; + +export function renderLayoutPreview(choice: LayoutChoice, columns: number, rows: number): string[] { + const width = Math.max(10, columns); + const height = Math.max(4, rows); + const {output, running} = fixture(choice.transcriptPresentation); + const wrapped = output.wrapped(width); + const plan = planScreen({rows: height, inputRows: 1, suggestions: 0, running: true, detached: false, hasOutput: wrapped.length > 0, + contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine', composerPosition: choice.composerPosition, + transcriptRows: wrapped.length}); + const start = Math.max(0, wrapped.length - plan.viewportRows); + const visible = wrapped.slice(start, start + plan.transcript.height).map(row => + output.presenter.decorate(row, row.lineIndex === undefined ? undefined : output.lineTypes.get(row.lineIndex), {now: 0})); + const activity = liveActivityParts(running, 12_000); + const separator = `${SEPARATOR}${repeatToWidth(GLYPHS.separator, width)}${RESET}`; + const paint: Record = { + transcript: visible, + gap: [], + jump: [], + panel: [], + suggestions: [], + activity: plan.composerPosition === 'top' + ? ['', `${SECONDARY}${activity.phrase}${SUBTLE}${activity.duration}${RESET}`] + : [`${SECONDARY}${activity.phrase}${SUBTLE}${activity.duration}${RESET}`, ''], + composerBorder: [separator], + prompt: [`${ACCENT} ~/Projects/demo${RESET} ${SUBTLE}main${RESET}`], + input: [`${ACCENT}${GLYPHS.prompt}${RESET} ${PRIMARY}git push${RESET}`], + separator: [separator], + }; + const frame = new Array(plan.rows).fill(''); + for (const region of plan.regions) { + const content = paint[region.kind]; + for (let index = 0; index < region.height; index += 1) frame[region.top + index] = content[index] ?? ''; + } + return frame.map(row => truncateAnsi(row, width)); +} + +type Row = 'position' | 'presentation'; +const ROWS: readonly Row[] = ['position', 'presentation']; + +function cycle(values: readonly T[], current: T, delta: number): T { + const index = Math.max(0, values.indexOf(current)); + return values[(index + delta + values.length) % values.length]!; +} + +export function createLayoutPanel(saved: LayoutChoice): LayoutPanelState { + return {selectedIndex: 0, draft: {...saved}, saved: {...saved}}; +} + +export function layoutDraftChanged(state: LayoutPanelState): boolean { + return state.draft.composerPosition !== state.saved.composerPosition + || state.draft.transcriptPresentation !== state.saved.transcriptPresentation; +} + +export function handleLayoutPanelKey(key: Key, state: LayoutPanelState): boolean { + if (key.kind === 'up' || key.kind === 'down') { + state.selectedIndex = (state.selectedIndex + (key.kind === 'up' ? -1 : 1) + ROWS.length) % ROWS.length; + } else if (key.kind === 'left' || key.kind === 'right') { + const delta = key.kind === 'left' ? -1 : 1; + if (ROWS[state.selectedIndex] === 'position') state.draft.composerPosition = cycle(COMPOSER_POSITIONS, state.draft.composerPosition, delta); + else state.draft.transcriptPresentation = cycle(TRANSCRIPT_PRESENTATIONS, state.draft.transcriptPresentation, delta); + } else return false; + state.message = undefined; + return true; +} + +export function renderLayoutPanel(state: LayoutPanelState, columns: number, rowsAvailable: number): string[] { + const {draft, saved} = state; + const value = (text: string, savedText: string) => (text === savedText ? `‹ ${text} ›` : `‹ ${text} › ${SUBTLE}saved: ${savedText}`); + const labels: Record = { + position: `Composer position ${value(COMPOSER_POSITION_LABELS[draft.composerPosition], COMPOSER_POSITION_LABELS[saved.composerPosition])}`, + presentation: `Transcript ${value(TRANSCRIPT_PRESENTATION_LABELS[draft.transcriptPresentation], TRANSCRIPT_PRESENTATION_LABELS[saved.transcriptPresentation])}`, + }; + const out = [`${PRIMARY} Layout${RESET}`, '']; + ROWS.forEach((row, index) => { + const selected = index === state.selectedIndex; + out.push(`${selected ? `${ACCENT}›` : ' '} ${selected ? ACCENT : SECONDARY}${labels[row]}${RESET}`); + }); + const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const header = ['', `${PRIMARY}Preview${RESET} ${layoutDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET} ${SUBTLE}sample content; nothing runs${RESET}`, + ` ${SUBTLE}${POSITION_CAPTIONS[draft.composerPosition]}${RESET}`]; + const message = state.message ? [`${SECONDARY}${state.message}${RESET}`] : []; + // The preview gets whatever height is left, between rules so its edges read as a screen. + const previewRows = Math.max(0, Math.min(layoutPreviewHeight(columns - 2), + rowsAvailable - out.length - header.length - controls.length - message.length - 2)); + const rule = `${SUBTLE}${'┄'.repeat(Math.max(1, columns - 2))}${RESET}`; + const preview = previewRows >= 4 + ? [rule, ...renderLayoutPreview(draft, columns - 2, previewRows).map(row => `${SUBTLE}│${RESET} ${row}`), rule] + : [` ${SUBTLE}Enlarge the window to see the preview.${RESET}`]; + return [...out, ...header, ...preview, ...message, ...controls].map(row => truncateAnsi(row, columns)); +} diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 445c211..7013209 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -1,6 +1,7 @@ import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; import {UPDATE_CHECK_FREQUENCIES} from '../update/update.js'; -import {LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP} from '../prompt/configuration.js'; +import {COMPOSER_POSITIONS, COMPOSER_POSITION_LABELS, LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP, TRANSCRIPT_PRESENTATIONS, + TRANSCRIPT_PRESENTATION_LABELS} from '../prompt/configuration.js'; import { DEFAULT_PROMPT_CONFIGURATION, type DividerDensity, @@ -54,7 +55,7 @@ export function switchSettingsView(state: SettingsPanelState, delta: -1 | 1): vo } /** Where Enter leads: `glyph` is the rich glyph preview inside the panel, the rest are full panels. */ -export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'keyboard' | 'welcome' | 'suggestions'; +export type SettingsDestination = 'glyph' | 'appearance' | 'prompt' | 'transcript' | 'syntax' | 'layout' | 'keyboard' | 'welcome' | 'suggestions'; interface SettingsRowBase { id: string; @@ -120,10 +121,10 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ values: PROMPT_STYLES, labels: PROMPT_STYLES.map(style => PROMPT_STYLE_LABELS[style]), get: config => config.nmsh.style, set: (config, style) => ({...config, nmsh: {...config.nmsh, style}})}), enumRow({id: 'composerPosition', label: 'Composer position', description: 'Dock the composer at the bottom or top, or Flow it after the newest output', category: 'Layout', - values: ['bottom', 'top', 'flow'] as const, labels: ['Bottom', 'Top', 'Flow'], + values: COMPOSER_POSITIONS, labels: COMPOSER_POSITIONS.map(position => COMPOSER_POSITION_LABELS[position]), get: config => config.composerPosition, set: (config, composerPosition) => ({...config, composerPosition})}), enumRow({id: 'transcriptPresentation', label: 'Transcript presentation', description: 'Normal rows, or Chat with commands on the right', category: 'Layout', - values: ['normal', 'chat'] as const, labels: ['Normal', 'Chat'], + values: TRANSCRIPT_PRESENTATIONS, labels: TRANSCRIPT_PRESENTATIONS.map(presentation => TRANSCRIPT_PRESENTATION_LABELS[presentation]), get: config => config.transcriptPresentation, set: (config, transcriptPresentation) => ({...config, transcriptPresentation})}), enumRow({id: 'outputFolding', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'], @@ -156,6 +157,7 @@ export const SETTINGS_ENTRIES: readonly SettingsRow[] = [ {id: 'keyboard', label: 'Keyboard', description: 'Terminal key bindings', category: 'Keyboard', control: 'child', destination: 'keyboard'}, {id: 'welcome', label: 'Welcome', description: 'Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome', control: 'child', destination: 'welcome'}, {id: 'suggestionsPanel', label: 'Suggestions', description: 'Ghost-text prediction provider', category: 'Suggestions', control: 'child', destination: 'suggestions'}, + {id: 'layout', label: 'Layout', description: 'Preview and choose composer position and transcript presentation', category: 'Layout', control: 'child', destination: 'layout'}, ]; export const PLANNED_AREAS = ['Layout', 'Blocks', 'Tools', 'Completion', 'Chroma'] as const; diff --git a/tests/fixtures/layout-preview.txt b/tests/fixtures/layout-preview.txt new file mode 100644 index 0000000..0095870 --- /dev/null +++ b/tests/fixtures/layout-preview.txt @@ -0,0 +1,216 @@ +=== bottom / normal +|❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +|❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +|❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +|❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +| +| +| +|✳ Running npm test · 12.0s +| +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +=== bottom / chat +| ❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +| ❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +| ❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +| ❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +| +| +| +|✳ Running npm test · 12.0s +| +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +=== top / normal +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +|❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +|❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +|❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +|❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +| +|✳ Running npm test · 12.0s +| +| +| +=== top / chat +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +| ❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +| ❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +| ❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +| ❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +| +|✳ Running npm test · 12.0s +| +| +| +=== flow / normal +|❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +|❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +|❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +|❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +|✳ Running npm test · 12.0s +| +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +| +| +| +=== flow / chat +| ❯ npm run build +|compiled module 1/40 +|compiled module 2/40 +|compiled module 3/40 +|32 lines hidden · Ctrl+O › +|compiled module 36/40 +|compiled module 37/40 +|compiled module 38/40 +|compiled module 39/40 +|compiled module 40/40 +| +|✔ Completed · 2.4s · 09:41 +| +| ❯ for f in *.log +| do gzip "$f" +| done +| +|✔ Completed · 310 ms · 09:41 +| +| ❯ cat missing.txt +|cat: missing.txt: No such file or directory +| +|✘ Command failed · exit 1 · 8 ms · 09:41 +| +| ❯ npm test +|▶ suite +| ✔ renders the layout (3ms) +|✳ Running npm test · 12.0s +| +| ~/Projects/demo main +|❯ git push +|──────────────────────────────────────────────────────────── +| +| +| diff --git a/tests/flowComposer.test.ts b/tests/flowComposer.test.ts index 8363c14..e781af6 100644 --- a/tests/flowComposer.test.ts +++ b/tests/flowComposer.test.ts @@ -206,10 +206,16 @@ test('Flow end to end: fullscreen apps stay raw, and returning restores the docu app.pty.write(`printf '\\e[?1049hFULL-SCREEN'; read -k1 _; printf '\\e[?1049l'\r`); await app.waitFor(/FULL-SCREEN/, mark); mark = app.mark; - app.pty.write('q'); - // The app leaves the alternate screen itself; then NMSh repaints its composer. - await until(() => app.output.indexOf('\u001b[?1049l', mark) !== -1, 15000, 'fullscreen exit'); + // Resend until the app has taken it: a key typed before NMSh enters + // passthrough stays in the composer. The app then leaves the alternate + // screen itself, and NMSh repaints its composer. + await until(() => { + if (app.output.indexOf('\u001b[?1049l', mark) !== -1) return true; + app.pty.write('q'); + return false; + }, 15000, 'fullscreen exit'); await app.waitFor(/❯/, app.output.indexOf('\u001b[?1049l', mark)); + app.pty.write('\u0015'); // Ctrl+U: drop any q that reached the composer first await app.run('echo FLOW-AFTER', /FLOW-AFTER/); assert.match(app.output.slice(mark).replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ''), /FLOW-BEFORE/, 'the document was repainted'); } finally { diff --git a/tests/layoutPanel.test.ts b/tests/layoutPanel.test.ts new file mode 100644 index 0000000..534d31a --- /dev/null +++ b/tests/layoutPanel.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {regionOf} from '../src/app/screenPlan.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {COMPOSER_POSITIONS, TRANSCRIPT_PRESENTATIONS} from '../src/prompt/configuration.js'; +import {createLayoutPanel, handleLayoutPanelKey, layoutPreviewHeight, renderLayoutPanel, renderLayoutPreview} from '../src/ui/LayoutPanel.js'; +import {SETTINGS_ENTRIES, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const SNAPSHOT = fileURLToPath(new URL('./fixtures/layout-preview.txt', import.meta.url)); +const key = (kind: string): Key => ({kind} as Key); + +/** Every implemented combination, rendered through the real presenter and ScreenPlan. */ +function snapshot(): string { + const width = 60; + const height = layoutPreviewHeight(width); + return COMPOSER_POSITIONS.flatMap(composerPosition => TRANSCRIPT_PRESENTATIONS.map(transcriptPresentation => [ + `=== ${composerPosition} / ${transcriptPresentation}`, + ...renderLayoutPreview({composerPosition, transcriptPresentation}, width, height).map(row => `|${stripAnsi(row).trimEnd()}`), + ].join('\n'))).join('\n') + '\n'; +} + +test('layout previews match the snapshot for every combination (UPDATE_SNAPSHOTS=1 rewrites it)', () => { + const actual = snapshot(); + if (process.env.UPDATE_SNAPSHOTS === '1' || !existsSync(SNAPSHOT)) { + mkdirSync(join(SNAPSHOT, '..'), {recursive: true}); + writeFileSync(SNAPSHOT, actual); + } + assert.equal(actual, readFileSync(SNAPSHOT, 'utf8')); + assert.equal(snapshot(), actual, 'rendering is deterministic'); + const first = renderLayoutPreview({composerPosition: 'flow', transcriptPresentation: 'chat'}, 60, 20); + assert.deepEqual(renderLayoutPreview({composerPosition: 'flow', transcriptPresentation: 'chat'}, 60, 20), first, 'styled output too'); +}); + +test('the preview shows what distinguishes each combination', () => { + const height = layoutPreviewHeight(60); + const plain = (composerPosition: 'bottom' | 'top' | 'flow', transcriptPresentation: 'normal' | 'chat') => + renderLayoutPreview({composerPosition, transcriptPresentation}, 60, height).map(row => stripAnsi(row)); + const promptRow = (rows: string[]) => rows.findIndex(row => row.includes('~/Projects/demo')); + const bottom = plain('bottom', 'normal'); + const top = plain('top', 'normal'); + const flow = plain('flow', 'normal'); + assert.equal(promptRow(top), 0, 'Top: composer first'); + assert.equal(promptRow(bottom), height - 3, 'Bottom: composer docked at the bottom edge'); + assert.ok(promptRow(flow) < promptRow(bottom), 'Flow: composer follows the newest output'); + assert.match(flow[promptRow(flow) - 2]!, /Running npm test/, 'Flow: right after the running command'); + for (const rows of [bottom, top, flow]) { + const text = rows.join('\n'); + assert.match(text, /lines hidden/, 'a folded block'); + assert.match(text, /for f in \*\.log[\s\S]*do gzip[\s\S]*done/, 'a multi-line command'); + assert.match(text, /Command failed · exit 1/, 'a failure'); + assert.match(text, /Running npm test/, 'an activity row'); + } + const chat = plain('flow', 'chat'); + assert.match(chat.find(row => row.includes('❯ cat missing.txt'))!, /^\s{10,}❯ cat missing\.txt$/, 'Chat right-aligns history'); + assert.match(chat[promptRow(chat) + 1]!, /^❯ git push/, 'the live composer stays normal in Chat'); +}); + +test('layout panel keys: move rows, cycle values in both directions, and mark unsaved changes', () => { + const state = createLayoutPanel({composerPosition: 'bottom', transcriptPresentation: 'normal'}); + assert.ok(handleLayoutPanelKey(key('right'), state)); + assert.equal(state.draft.composerPosition, 'top'); + handleLayoutPanelKey(key('right'), state); + assert.equal(state.draft.composerPosition, 'flow'); + handleLayoutPanelKey(key('right'), state); + assert.equal(state.draft.composerPosition, 'bottom', 'wraps around'); + handleLayoutPanelKey(key('left'), state); + assert.equal(state.draft.composerPosition, 'flow'); + handleLayoutPanelKey(key('down'), state); + handleLayoutPanelKey(key('right'), state); + assert.equal(state.draft.transcriptPresentation, 'chat'); + assert.equal(state.saved.composerPosition, 'bottom', 'saved stays until Enter'); + assert.equal(handleLayoutPanelKey(key('enter'), state), false, 'Enter and Esc belong to the app'); + const text = renderLayoutPanel(state, 80, 60).map(row => stripAnsi(row)).join('\n'); + assert.match(text, /Composer position +‹ Flow › +saved: Bottom/); + assert.match(text, /Transcript +‹ Chat › +saved: Normal/); + assert.match(text, /unsaved preview/); + assert.match(text, /Flow: the prompt follows the newest output/); +}); + +test('layout panel fits any size; the preview needs room and says so otherwise', () => { + const state = createLayoutPanel({composerPosition: 'flow', transcriptPresentation: 'chat'}); + for (const [columns, rows] of [[80, 60], [80, 24], [40, 20], [24, 10]] as const) { + const lines = renderLayoutPanel(state, columns, rows); + assert.ok(lines.length <= Math.max(rows, 12), `${columns}x${rows}`); + for (const line of lines) assert.ok(displayWidth(stripAnsi(line)) <= columns, `${columns}x${rows}: ${stripAnsi(line)}`); + } + assert.match(renderLayoutPanel(state, 80, 10).map(row => stripAnsi(row)).join('\n'), /Enlarge the window/); +}); + +test('/layout is a slash command and a /settings Layout destination', () => { + assert.deepEqual(parseSlashCommand('/layout'), {kind: 'layout'}); + assert.ok(slashCommands.some(command => command.name === '/layout')); + assert.ok(SETTINGS_ENTRIES.some(entry => entry.control === 'child' && entry.destination === 'layout')); + const position = SETTINGS_ROWS.find(row => row.id === 'composerPosition')!; + assert.ok(position.control === 'enum'); + assert.deepEqual(position.options, ['Bottom', 'Top', 'Flow'], 'Config and /layout share one value list'); +}); + +test('saving from /layout persists, applies live, and the preview never touches the transcript', () => { + const directory = mkdtempSync(join(tmpdir(), 'nmsh-layout-')); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = directory; + const app = new TerminalApp(); + try { + Object.defineProperty(app, 'dimensions', {value: () => ({columns: 80, rows: 50})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + app['renderer'].render = (() => {}) as never; + app['session'].resize = (() => {}) as never; + app['promptConfiguration'].composerPosition = 'bottom'; + app['promptConfiguration'].transcriptPresentation = 'normal'; + const before = JSON.stringify(app['output'].transcript()); + app['startLayoutSettings'](); + app['render'](); + app['onInput']('\u001B[C'); // Top + app['onInput']('\u001B[C'); // Flow + app['onInput']('\u001B[B'); + app['onInput']('\u001B[C'); // Chat + app['render'](); + assert.equal(JSON.stringify(app['output'].transcript()), before, 'previewing adds nothing to the transcript'); + assert.equal(app['output'].recent(1), undefined, 'nothing for /copy'); + assert.equal(app['promptConfiguration'].composerPosition, 'bottom', 'nothing applies before Enter'); + + app['onInput']('\r'); + assert.equal(app['layoutPanelState'], undefined); + assert.equal(app['promptConfiguration'].composerPosition, 'flow'); + assert.equal(app['promptConfiguration'].transcriptPresentation, 'chat'); + assert.equal(app['output'].presenter.layout, 'chat', 'presentation applied live'); + assert.equal(app['planFrame'](80, 50).composerPosition, 'flow', 'the screen plan uses it immediately'); + assert.ok(regionOf(app['planFrame'](80, 50), 'input')); + const saved = JSON.parse(readFileSync(join(directory, 'nmsh', 'config.json'), 'utf8')) as Record; + assert.equal(saved.composerPosition, 'flow'); + assert.equal(saved.transcriptPresentation, 'chat'); + assert.equal(app['output'].recent(1), undefined, 'only a status line was added, no command record'); + + app['startLayoutSettings'](); + app['onInput']('\u001B[C'); + app['onInput']('\u001B'); + app['onInput']('\u001B[A'); // flush the pending Esc + assert.equal(app['layoutPanelState'], undefined, 'Esc closes without saving'); + assert.equal(app['promptConfiguration'].composerPosition, 'flow'); + } finally { + app['stop'](0); + app['session'].kill(); + if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous; + rmSync(directory, {recursive: true, force: true}); + } +});