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}); + } +});