Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> -o <path>` 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.
Expand Down
34 changes: 25 additions & 9 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Key['kind']> = 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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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()};
Expand Down Expand Up @@ -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,
});
}

Expand Down
47 changes: 43 additions & 4 deletions src/app/screenPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
Expand Down
2 changes: 1 addition & 1 deletion src/ui/CommandPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}},
Expand Down
4 changes: 2 additions & 2 deletions src/ui/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
2 changes: 1 addition & 1 deletion tests/dockTop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading