From 9502c19adb15e49a204c8f6bbe2e3931ade19219 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:23:45 +0530 Subject: [PATCH] Add the Flow composer position Flow places the prompt and input right after the newest output inside NMSh's document, like a conventional terminal, while keeping NMSh's editor, highlighting, suggestions and structured execution. ScreenPlan gains a Flow stack: transcript sized to its rows, then the gap or live activity, the composer, and menus below the input. The PTY keeps the full capacity so output growth never resizes the shell. When scrolled back, the composer scrolls with the document and is clipped at the screen edge; the history viewport resolves against the following capacity (the new ScreenPlan.viewportRows), so a small scroll never snaps back to the bottom. Editing while scrolled back returns to the newest output in Flow; scrolling and mouse movement alone do not. Panels pin to the bottom edge. Composer position gains Flow in Config, and the palette toggle cycles Bottom, Top and Flow. Bottom and Top are unchanged. --- CHANGELOG.md | 1 + README.md | 2 +- docs/design/session-interaction-ux.md | 11 ++ src/app/TerminalApp.ts | 34 ++-- src/app/screenPlan.ts | 47 +++++- src/prompt/configuration.ts | 5 +- src/ui/CommandPalette.ts | 2 +- src/ui/SettingsPanel.ts | 4 +- tests/dockTop.test.ts | 2 +- tests/flowComposer.test.ts | 218 ++++++++++++++++++++++++++ 10 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 tests/flowComposer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab224f9..a464831 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 +- **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. - **Exactly-once archiving:** launch recovery, Kill Session and live journal checkpoints share a cross-process lock per journal, so concurrent launches never archive a session twice or overwrite a complete archive with partial state. diff --git a/README.md b/README.md index 99b78e9..b193ea0 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 -- Dock Top or Bottom composer (Config → Composer position) and Normal or Chat transcript presentation (Config → Transcript presentation), in any combination +- 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). - 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 1b18dfc..58bed02 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -61,6 +61,17 @@ Powerlevel10k has no standalone render command; it is a zsh theme that builds a Starship detection respects `STARSHIP_CONFIG`, otherwise the documented `~/.config/starship.toml` default. A missing config uses Starship defaults. Presets use Starship's supported `starship preset -o ` command; choose a new path to preserve any existing file. On macOS, NMSh offers `brew install starship` behind a confirmation screen. It does not edit `.zshrc` or run curl-based installers. Starship has no separate official interactive onboarding command, so NMSh presents this small provider setup flow. +## Composer position: Flow + +Composer position is Bottom (default), Top or Flow. Bottom and Top dock the composer to an edge. Flow makes the prompt and input part of NMSh's own document, right after the newest output, like a conventional terminal. It is not a switch to the host's scrollback: NMSh keeps its editor, highlighting, suggestions and structured execution. + +- **Following:** the composer sits directly after the newest output (with the usual breathing space, or the live activity row while a command runs). It moves down as output grows. Once output fills the screen, it lands where Bottom docks it. The shell PTY always gets the full transcript capacity, so output growth never resizes it. +- **Scrolling back:** the composer scrolls with the document. A few rows back, it moves down by that many rows and is clipped at the screen edge. A page back, it is off-screen and the cursor is hidden. The history viewport keeps the following capacity as its height, so a small scroll never snaps back to the bottom. +- **Returning:** typing, pasting, deleting, completing, history search or Enter while scrolled back returns to the newest output first, and the key still applies. Scrolling, paging and mouse movement alone never return. Bottom and Top are unchanged: typing does not move a scrolled view. +- **Placement rules:** suggestion and completion menus open below the input, like a terminal completion list. Full-width panels (settings, `/resume`, …) pin to the bottom edge, as with Bottom. +- **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. + ## 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 16d6beb..f09dc68 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -88,6 +88,9 @@ const SEPARATOR = foreground(UI_COLORS.separator); const ACCENT = foreground(UI_COLORS.accent); const SUCCESS = foreground(UI_COLORS.success); const ERROR = foreground(UI_COLORS.failure); +/** Keys that edit or submit the composer; in Flow they bring a scrolled-back view back to it. */ +const FLOW_EDIT_KEYS: ReadonlySet = new Set(['text', 'paste', 'backspace', 'delete', 'deleteWord', + 'deleteLineBefore', 'deleteLineAfter', 'enter', 'newline', 'complete', 'historySearch']); const STOPPED = foreground({red: 198, green: 156, blue: 109}); const INFO = SECONDARY; const RESET = '\u001B[0m'; @@ -587,7 +590,7 @@ export class TerminalApp { const plan = this.planFrame(columns, rows); const hit = key.y ? regionAt(plan, screenRowFromTerminal(key.y)) : undefined; if (hit?.region.kind === 'transcript') { - const outputHeight = plan.transcript.height; + const outputHeight = plan.viewportRows; const wrapped = this.output.wrapped(columns); const viewStart = this.historyViewport.resolve(wrapped.length, outputHeight); const localVisibleIndex = hit.localRow; @@ -689,6 +692,11 @@ export class TerminalApp { this.historyViewport.latest(); return; } + // Flow keeps the composer in the document: editing while scrolled back + // returns to it first. Scrolling and mouse navigation alone never do. + if (this.promptConfiguration.composerPosition === 'flow' && this.historyViewport.detached && FLOW_EDIT_KEYS.has(key.kind)) { + this.historyViewport.latest(); + } if (key.kind === 'historySearch') { if (!this.running) { @@ -809,7 +817,7 @@ export class TerminalApp { } const {columns, rows} = this.dimensions(); - const outputHeight = this.planFrame(columns, rows).transcript.height; + const outputHeight = this.planFrame(columns, rows).viewportRows; const wrapped = this.output.wrapped(columns); const wrappedIndex = wrapped.findIndex(r => this.focusedActivityId @@ -976,7 +984,7 @@ export class TerminalApp { this.settingsPanelState!.contentIndex = Math.max(0, SETTINGS_ROWS.findIndex(row => row.id === action.rowId)); break; case 'toggleComposerPosition': - config.composerPosition = config.composerPosition === 'top' ? 'bottom' : 'top'; + config.composerPosition = ({bottom: 'top', top: 'flow', flow: 'bottom'} as const)[config.composerPosition]; this.applySettingsConfiguration(config); break; case 'toggleTranscriptPresentation': @@ -2391,7 +2399,7 @@ export class TerminalApp { /** Scroll paging needs at least one row even when the plan leaves the transcript empty. */ private transcriptViewportHeight(): number { const {columns, rows} = this.dimensions(); - return Math.max(1, this.planFrame(columns, rows).transcript.height); + return this.planFrame(columns, rows).viewportRows; } private formatCommandAnsi(command: string, startId: number | null, sgr = this.syntaxSgr): string[] { @@ -2478,7 +2486,7 @@ export class TerminalApp { panelRows = this.settingsPanelActive ? this.settingsPanelRows(columns).length : undefined, ): ScreenPlan { const transcriptRows = this.output.wrapped(columns).length; - return planScreen({ + const input = { rows, inputRows: fullInput.allRows.length, suggestions, @@ -2491,7 +2499,13 @@ export class TerminalApp { hasVisibleContext: this.hasVisibleProviderPrompt(), composerLayout: this.promptConfiguration.composerLayout, panelRows, - }); + }; + if (input.composerPosition !== 'flow' || !input.detached || panelRows !== undefined) return planScreen(input); + // Flow scrolled back: where the view starts decides how much of the composer + // is still on screen, and it is resolved against the following capacity. + const following = planScreen({...input, detached: false}); + const viewStart = this.historyViewport.resolve(transcriptRows, following.viewportRows); + return this.historyViewport.detached ? planScreen({...input, viewStart}) : following; } private render(): void { @@ -2515,9 +2529,10 @@ export class TerminalApp { this.session.resize(columns, plan.ptyRows); } - const outputHeight = plan.transcript.height; const wrapped = this.output.wrapped(columns); - const viewStart = this.historyViewport.resolve(wrapped.length, outputHeight); + const viewStart = this.historyViewport.resolve(wrapped.length, plan.viewportRows); + // Flow's viewport scrolls by its capacity; the region shows only what is on screen. + const outputHeight = plan.transcript.height; const presenter = this.output.presenter; const interaction = {hoveredLineIndex: this.hoveredLineIndex, focusedLineIndex: this.focusedLineIndex, focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, now: Date.now()}; @@ -2605,7 +2620,8 @@ export class TerminalApp { columns, cursorRow: terminalRowFromScreen(cursorScreenRow(plan, input.caretRow)), cursorColumn: Math.max(1, Math.min(columns, input.caretColumn + 1)), - cursorVisible: !plan.panelActive, + // Flow can scroll the input row off screen. + cursorVisible: !plan.panelActive && plan.inputHeight > 0, }); } diff --git a/src/app/screenPlan.ts b/src/app/screenPlan.ts index a5af5c5..1230a18 100644 --- a/src/app/screenPlan.ts +++ b/src/app/screenPlan.ts @@ -48,10 +48,12 @@ export interface ScreenPlanInput { composerLayout: ComposerLayout; /** Rows of an active full-width panel; undefined when no panel owns the screen. */ panelRows?: number; - /** Dock Bottom (default) or Dock Top. */ + /** Dock Bottom (default), Dock Top, or Flow. */ composerPosition?: ComposerPosition; - /** Presented transcript rows; Dock Top uses it to keep activity next to the newest output. */ + /** Presented transcript rows; Dock Top and Flow use it to keep what follows next to the newest output. */ transcriptRows?: number; + /** Flow while scrolled back: the first transcript row in view (the composer follows the transcript's end). */ + viewStart?: number; } export interface ScreenPlan { @@ -65,6 +67,12 @@ export interface ScreenPlan { suggestionCount: number; /** Rows given to the shell PTY: the transcript capacity (the viewport height under Dock Bottom). */ ptyRows: number; + /** + * Height the history viewport resolves and scrolls by. The transcript region's + * height, except in Flow, where the composer scrolls with the document and + * the viewport keeps the following capacity so scrolling back never snaps. + */ + viewportRows: number; composerPosition: ComposerPosition; panelActive: boolean; } @@ -83,8 +91,9 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { const panelHeight = Math.min(rows, Math.max(0, input.panelRows)); const panel: Array<[RegionKind, number]> = [['panel', panelHeight]]; const transcript: Array<[RegionKind, number]> = [['transcript', rows - panelHeight]]; + // Flow pins panels to the bottom edge, like Bottom. return build(rows, top ? [...panel, ...transcript] : [...transcript, ...panel], - {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: top ? 'top' : 'bottom'}); + {inputHeight: 0, suggestionCount: 0, panelActive: true, composerPosition: input.composerPosition ?? 'bottom'}); } const layout = calculateScreenLayout( rows, @@ -116,6 +125,36 @@ export function planScreen(input: ScreenPlanInput): ScreenPlan { ], {inputHeight: layout.inputHeight, suggestionCount: layout.suggestionCount, panelActive: false, composerPosition: 'top'}); return {...plan, ptyRows: capacity}; } + if (input.composerPosition === 'flow') { + // Flow: the composer is part of the document, right after the newest output. + // Geometry is measured as if following, so the PTY never resizes as output + // grows or the view scrolls back. + const followLayout = input.detached + ? calculateScreenLayout(rows, input.inputRows, input.suggestions, input.running, false, input.hasOutput, + input.contextPlacement, input.hasVisibleContext, input.composerLayout) + : layout; + const capacity = followLayout.outputHeight; + const total = Math.max(0, input.transcriptRows ?? capacity); + // Following: the newest rows up to capacity. Scrolled back: from viewStart to + // the end of output, and whatever composer rows still fit are clipped below. + const shown = input.detached + ? Math.min(rows, Math.max(0, total - (input.viewStart ?? 0))) + : Math.min(capacity, total); + const plan = build(rows, [ + ['transcript', shown], + ['gap', Number(followLayout.showGap)], + ['activity', followLayout.showLiveActivity ? 2 : 0], + ['composerBorder', Number(followLayout.showComposerTopBorder)], + ['prompt', Number(followLayout.showPrompt)], + ['input', followLayout.inputHeight], + ['separator', Number(followLayout.showSeparator)], + // Menus open below the input, as a conventional terminal's completion list does. + ['suggestions', followLayout.suggestionCount], + ], {inputHeight: followLayout.inputHeight, suggestionCount: followLayout.suggestionCount, panelActive: false, composerPosition: 'flow'}); + // Only what is on screen: a composer scrolled partly off shows its first rows. + return {...plan, ptyRows: capacity, viewportRows: Math.max(1, capacity), + inputHeight: regionOf(plan, 'input')?.height ?? 0, suggestionCount: regionOf(plan, 'suggestions')?.height ?? 0}; + } return build(rows, [ ['transcript', layout.outputHeight], ['gap', Number(layout.showGap)], @@ -145,7 +184,7 @@ function build( if (height > 0) regions.push(region); top += height; } - return {rows, regions, transcript, ptyRows: transcript.height, ...extra}; + return {rows, regions, transcript, ptyRows: transcript.height, viewportRows: Math.max(1, transcript.height), ...extra}; } export function regionOf(plan: ScreenPlan, kind: RegionKind): Region | undefined { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 89b246f..36abada 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -27,7 +27,8 @@ export const WELCOME_PROVIDER_IDS: readonly WelcomeProviderId[] = ['vespyr', 'fa export type ContextPlacement = 'header' | 'composer'; export type ComposerLayout = 'oneLine' | 'twoLine'; -export type ComposerPosition = 'bottom' | 'top'; +/** 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'; export type GlyphStyle = 'nerd' | 'safe'; export type SessionRetention = 100 | 500 | 1000 | 5000 | null; @@ -327,7 +328,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio const placement: ContextPlacement = value.placement === 'composer' ? 'composer' : 'header'; const composerLayout: ComposerLayout = value.composerLayout === 'oneLine' ? 'oneLine' : 'twoLine'; - const composerPosition: ComposerPosition = value.composerPosition === 'top' ? 'top' : 'bottom'; + const composerPosition: ComposerPosition = value.composerPosition === 'top' || value.composerPosition === 'flow' ? value.composerPosition : 'bottom'; const transcriptPresentation: TranscriptPresentation = value.transcriptPresentation === 'chat' ? 'chat' : 'normal'; const spacing = typeof value.spacing === 'number' && Number.isFinite(value.spacing) ? Math.max(0, Math.min(3, Math.round(value.spacing))) diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 3af5c6f..1aaf23f 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -52,7 +52,7 @@ export function paletteItems(): PaletteItem[] { action: {kind: 'config', rowId: row.id}}); } items.push( - {id: 'layout:position', label: 'Toggle composer position', detail: 'Dock the composer at the bottom or the top', category: 'Layout', + {id: 'layout:position', label: 'Toggle composer position', detail: 'Cycle Bottom, Top, and Flow after the newest output', category: 'Layout', action: {kind: 'toggleComposerPosition'}}, {id: 'layout:presentation', label: 'Toggle Chat presentation', detail: 'Switch transcript between Normal and Chat', category: 'Layout', action: {kind: 'toggleTranscriptPresentation'}}, diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 7957b7e..445c211 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -119,8 +119,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'promptStyle', label: 'Prompt style', description: 'NMSh Native look: Powerline, Soft, Minimal, or Outline', category: 'Prompt', 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 the top', category: 'Layout', - values: ['bottom', 'top'] as const, labels: ['Bottom', 'Top'], + 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'], 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'], diff --git a/tests/dockTop.test.ts b/tests/dockTop.test.ts index 5c8cd9f..3e609ee 100644 --- a/tests/dockTop.test.ts +++ b/tests/dockTop.test.ts @@ -53,7 +53,7 @@ test('composer position persists as bottom (default) or top and is editable in / assert.equal(normalizePromptConfiguration({composerPosition: 'left'}).composerPosition, 'bottom'); const row = SETTINGS_ROWS.find(candidate => candidate.id === 'composerPosition')!; assert.ok(row.control === 'enum'); - assert.deepEqual(row.options, ['Bottom', 'Top']); + assert.deepEqual(row.options, ['Bottom', 'Top', 'Flow']); }); test('Dock Top render, cursor, mouse hover/click and PTY rows all follow the plan', () => { diff --git a/tests/flowComposer.test.ts b/tests/flowComposer.test.ts new file mode 100644 index 0000000..8363c14 --- /dev/null +++ b/tests/flowComposer.test.ts @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {cursorScreenRow, planScreen, regionAt, regionOf, terminalRowFromScreen, type ScreenPlan, type ScreenPlanInput} from '../src/app/screenPlan.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import {stripAnsi} from '../src/util/text.js'; + +const base: ScreenPlanInput = {rows: 24, inputRows: 1, suggestions: 0, running: false, detached: false, hasOutput: true, + contextPlacement: 'header', hasVisibleContext: true, composerLayout: 'twoLine', composerPosition: 'flow'}; +const kinds = (plan: ScreenPlan) => plan.regions.map(region => region.kind); +const contiguous = (plan: ScreenPlan) => { + assert.ok(plan.regions.reduce((sum, region) => sum + region.height, 0) <= plan.rows); + plan.regions.forEach((region, index) => index > 0 && assert.equal(region.top, plan.regions[index - 1]!.top + plan.regions[index - 1]!.height)); +}; + +test('Flow places the composer right after the newest output and follows its growth', () => { + const bottom = planScreen({...base, composerPosition: 'bottom'}); + const short = planScreen({...base, transcriptRows: 3}); + // The existing breathing-space row separates output from the composer, as in Bottom. + assert.deepEqual(kinds(short), ['transcript', 'gap', 'prompt', 'input', 'separator']); + assert.equal(short.transcript.height, 3); + assert.equal(regionOf(short, 'prompt')!.top, 4, 'prompt directly after the output and its gap'); + assert.equal(cursorScreenRow(short, 0), regionOf(short, 'input')!.top); + const longer = planScreen({...base, transcriptRows: 10}); + assert.equal(regionOf(longer, 'input')!.top, regionOf(short, 'input')!.top + 7, 'the composer moves down with output'); + const full = planScreen({...base, transcriptRows: 500}); + assert.equal(regionOf(full, 'input')!.top, regionOf(bottom, 'input')!.top, 'a full screen lands where Bottom docks'); + for (const plan of [short, longer, full]) { + assert.equal(plan.ptyRows, bottom.ptyRows, 'the shell always gets the full capacity; output growth never resizes it'); + contiguous(plan); + } + const empty = planScreen({...base, hasOutput: false, transcriptRows: 0}); + assert.equal(regionOf(empty, 'prompt')!.top, 0, 'a fresh session starts at the top like a terminal'); +}); + +test('Flow menus open below the input; activity sits between output and composer', () => { + const plan = planScreen({...base, suggestions: 3, running: true, transcriptRows: 4}); + // While running, the activity row brings its own spacer instead of the gap. + assert.deepEqual(kinds(plan), ['transcript', 'activity', 'prompt', 'input', 'separator', 'suggestions']); + assert.equal(regionOf(plan, 'activity')!.top, 4); + const full = planScreen({...base, suggestions: 3, running: true, transcriptRows: 500}); + assert.deepEqual(kinds(full), ['transcript', 'activity', 'prompt', 'input', 'separator', 'suggestions']); + assert.equal(full.regions.at(-1)!.top + full.regions.at(-1)!.height, full.rows, 'still fits when the screen is full'); +}); + +test('Flow scrolled back: the composer scrolls with the document and PTY and viewport rows stay fixed', () => { + const following = planScreen({...base, running: true, suggestions: 2, transcriptRows: 500}); + const followInput = regionOf(following, 'input')!; + // Three rows back: everything below the transcript moves down three rows, clipped at the edge. + const nudged = planScreen({...base, running: true, suggestions: 2, detached: true, transcriptRows: 500, + viewStart: 500 - following.viewportRows - 3}); + assert.equal(nudged.transcript.height, following.transcript.height + 3); + assert.equal(regionOf(nudged, 'activity')!.top, regionOf(following, 'activity')!.top + 3); + assert.equal(nudged.ptyRows, following.ptyRows, 'scrolling back never resizes the shell'); + assert.equal(nudged.viewportRows, following.viewportRows, 'the viewport keeps the following capacity, so it never snaps back'); + contiguous(nudged); + // A page back: the composer is entirely below the view. + const paged = planScreen({...base, running: true, suggestions: 2, detached: true, transcriptRows: 500, viewStart: 100}); + assert.deepEqual(kinds(paged), ['transcript']); + assert.equal(paged.transcript.height, paged.rows); + assert.equal(paged.inputHeight, 0); + assert.equal(regionOf(paged, 'input'), undefined); + assert.ok(followInput.top > 0); +}); + +test('Flow panels pin to the bottom edge; narrow terminals and long input never overflow', () => { + const panel = planScreen({...base, panelRows: 8, transcriptRows: 3}); + assert.deepEqual(kinds(panel), ['transcript', 'panel']); + assert.equal(panel.composerPosition, 'flow'); + assert.equal(regionAt(panel, 23)?.region.kind, 'panel'); + for (let rows = 1; rows <= 14; rows += 1) { + for (const transcriptRows of [0, 2, 400]) { + for (const [detached, viewStart] of [[false, 0], [true, 0], [true, 395], [true, 399]] as const) { + const plan = planScreen({...base, rows, inputRows: 12, suggestions: 4, running: true, detached, transcriptRows, viewStart}); + contiguous(plan); + assert.ok(cursorScreenRow(plan, 0) < rows, `rows ${rows}`); + } + } + } +}); + +test('composer position accepts Flow', () => { + assert.equal(normalizePromptConfiguration({composerPosition: 'flow'}).composerPosition, 'flow'); + assert.equal(normalizePromptConfiguration({composerPosition: 'Flow!'}).composerPosition, 'bottom'); +}); + +function flowApp(columns = 80, rows = 20) { + const app = new TerminalApp(); + app['promptConfiguration'].composerPosition = 'flow'; + // Long test output must stay scrollable rather than fold into a summary. + app['output'].setOutputFolding('never'); + Object.defineProperty(app, 'dimensions', {value: () => ({columns, rows})}); + Object.defineProperty(app, 'fetchSuggestions', {value: async () => {}}); + const frames: Array<{rows: string[]; cursorRow: number; cursorVisible: boolean}> = []; + const sizes: number[] = []; + app['renderer'].render = ((frame: {rows: string[]; cursorRow: number; cursorVisible: boolean}) => { frames.push(frame); }) as never; + app['session'].resize = ((_columns: number, ptyRows: number) => { sizes.push(ptyRows); }) as never; + app['session'].write = (() => {}) as never; + return {app, frames, sizes}; +} + +const output = (app: TerminalApp, lines: number) => { + app['output'].beginCommand('seq', ['❯ seq']); + app['output'].write(Array.from({length: lines}, (_, index) => `line-${index}\n`).join('')); + app['output'].complete(0); +}; +const wheelUp = '\u001B[<64;2;2M'; + +test('Flow render: the input row follows the newest output and the PTY size stays fixed', () => { + const {app, frames, sizes} = flowApp(); + try { + output(app, 2); + app['render'](); + const shortPlan: ScreenPlan = app['planFrame'](80, 20); + const shortInput = regionOf(shortPlan, 'input')!; + assert.ok(shortInput.top < 12, 'short output: composer near the top'); + assert.equal(frames.at(-1)!.cursorRow, terminalRowFromScreen(shortInput.top)); + assert.equal(stripAnsi(frames.at(-1)!.rows[shortPlan.transcript.top + shortPlan.transcript.height - 1]!), + app['output'].wrapped(80).at(-1)!.plain, 'newest output directly above the composer'); + output(app, 60); + app['render'](); + const fullPlan: ScreenPlan = app['planFrame'](80, 20); + assert.ok(regionOf(fullPlan, 'input')!.top > shortInput.top, 'composer followed the output down'); + assert.equal(new Set(sizes).size, 1, 'PTY rows never changed as output grew'); + } finally { + app['stop'](0); + app['session'].kill(); + } +}); + +test('Flow: scrolling moves the composer with the document; wheel alone keeps the view; typing returns to it', () => { + const {app, frames} = flowApp(); + try { + output(app, 80); + app['render'](); + const followInput = regionOf(app['planFrame'](80, 20), 'input')!; + app['onInput'](wheelUp); + app['render'](); + assert.equal(app['historyViewport'].detached, true, 'a small scroll back stays detached'); + const nudged = regionOf(app['planFrame'](80, 20), 'input'); + assert.ok(!nudged || nudged.top === followInput.top + 3, 'the composer moved down with the document'); + app['onInput']('\u001B[5~'); // Page Up + app['render'](); + assert.equal(regionOf(app['planFrame'](80, 20), 'input'), undefined, 'a page back: composer below the view'); + assert.equal(frames.at(-1)!.cursorVisible, false, 'no input row on screen'); + app['onInput'](wheelUp); + app['onInput']('\u001B[<35;2;5M'); // mouse move + assert.equal(app['historyViewport'].detached, true, 'scroll and mouse navigation alone stay detached'); + app['onInput']('e'); + assert.equal(app['historyViewport'].detached, false, 'typing returns to the newest output'); + assert.equal(app['editor'].text, 'e', 'the key is still typed'); + app['render'](); + assert.equal(frames.at(-1)!.cursorVisible, true); + assert.equal(regionOf(app['planFrame'](80, 20), 'input')!.top, followInput.top); + } finally { + app['stop'](0); + app['session'].kill(); + } +}); + +test('Bottom and Top keep their existing behavior: typing does not change the scrolled view', () => { + for (const position of ['bottom', 'top'] as const) { + const {app} = flowApp(); + try { + app['promptConfiguration'].composerPosition = position; + output(app, 80); + app['render'](); + app['onInput'](wheelUp); + app['onInput']('e'); + assert.equal(app['historyViewport'].detached, true, position); + } finally { + app['stop'](0); + app['session'].kill(); + } + } +}); + +test('Flow + Chat: the live composer renders normally while history renders right-aligned', () => { + const {app, frames} = flowApp(); + try { + app['promptConfiguration'].transcriptPresentation = 'chat'; + app['output'].presenter.setLayout('chat'); + output(app, 2); + app['onInput']('echo hi'); + app['render'](); + const plan: ScreenPlan = app['planFrame'](80, 20); + const inputRow = stripAnsi(frames.at(-1)!.rows[regionOf(plan, 'input')!.top]!); + assert.match(inputRow, /^\S*\s?echo hi/, 'composer input stays left-aligned'); + const historyRow = frames.at(-1)!.rows.slice(plan.transcript.top, plan.transcript.top + plan.transcript.height) + .map(row => stripAnsi(row)).find(row => row.includes('seq')); + assert.ok(historyRow && /^\s{10,}/u.test(historyRow), 'historical command right-aligned in Chat'); + } finally { + app['stop'](0); + app['session'].kill(); + } +}); + +test('Flow end to end: fullscreen apps stay raw, and returning restores the document and composer', async () => { + const {LiveSandbox, until} = await import('./helpers/liveFrontend.js'); + const sandbox = new LiveSandbox({composerPosition: 'flow'}); + try { + const app = sandbox.launch(); + await app.waitFor(/❯/); + await app.run('echo FLOW-BEFORE', /FLOW-BEFORE/); + let mark = app.mark; + 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'); + await app.waitFor(/❯/, app.output.indexOf('\u001b[?1049l', mark)); + 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 { + await sandbox.dispose(); + } +});