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
- **`/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.
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
- 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
Expand Down
9 changes: 9 additions & 0 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 41 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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};
Expand Down
3 changes: 3 additions & 0 deletions src/commands/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand All @@ -33,6 +34,7 @@ export type ParsedSlashCommand =
| {kind: 'settings'; view: 'config' | 'status'}
| {kind: 'transcript'}
| {kind: 'syntax'}
| {kind: 'layout'}
| {kind: 'keyboard'}
| {kind: 'zsh'}
| {kind: 'version'}
Expand All @@ -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'};
Expand Down
5 changes: 5 additions & 0 deletions src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerPosition, string> = {bottom: 'Bottom', top: 'Top', flow: 'Flow'};
export const TRANSCRIPT_PRESENTATIONS: readonly TranscriptPresentation[] = ['normal', 'chat'];
export const TRANSCRIPT_PRESENTATION_LABELS: Record<TranscriptPresentation, string> = {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';
Expand Down
Loading
Loading