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
34 changes: 28 additions & 6 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {homedir} from 'node:os';
import {GLYPHS, setIconStyle} from '../ui/glyphs.js';
import {framePanel} from '../ui/PanelShell.js';
import {
adjustSettingsRow, isInlineEditable, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination,
settingsView, statusLineCount, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState,
adjustSettingsRow, isInlineEditable, resetSettingsRow, settingsRowChanged, renderSettingsPanel, selectedSettingsRow, settingsItemCount, settingsRowDestination,
settingsView, statusLineCount, visibleSettingsRows, switchSettingsView, toggleSettingsRow, type SettingsDestination, type SettingsPanelState,
type SettingsView, type StatusSections,
SETTINGS_ENTRIES,
SETTINGS_ROWS,
Expand Down Expand Up @@ -477,7 +477,11 @@ export class TerminalApp {
return;
}
if (this.settingsPanelState) {
this.handleSettingsKey(key, this.settingsPanelState);
const settingsState = this.settingsPanelState;
this.handleSettingsKey(key, settingsState);
if (settingsState.section === 'root' && settingsView(settingsState) === 'config') {
this.settingsMemory = {contentIndex: settingsState.contentIndex ?? 0, searchQuery: settingsState.searchQuery ?? '', showAdvanced: Boolean(settingsState.showAdvanced)};
}
this.render();
return;
}
Expand Down Expand Up @@ -1002,8 +1006,7 @@ export class TerminalApp {
break;
}
case 'config':
this.openSettingsPanel('config');
this.settingsPanelState!.contentIndex = Math.max(0, SETTINGS_ROWS.findIndex(row => row.id === action.rowId));
this.focusConfigRow(action.rowId);
break;
case 'toggleComposerPosition':
config.composerPosition = ({bottom: 'top', top: 'flow', flow: 'bottom'} as const)[config.composerPosition];
Expand Down Expand Up @@ -1959,11 +1962,25 @@ export class TerminalApp {
}
}

/** Config position, search and advanced toggle survive closing and reopening within one run (never persisted). */
private settingsMemory?: {contentIndex: number; searchQuery: string; showAdvanced: boolean};

private openSettingsPanel(view: SettingsView): void {
this.settingsPanelState = {section: 'root', view, selectedIndex: 0, contentIndex: 0,
const memory = view === 'config' ? this.settingsMemory : undefined;
this.settingsPanelState = {section: 'root', view, selectedIndex: 0, contentIndex: memory?.contentIndex ?? 0,
searchQuery: memory?.searchQuery, showAdvanced: memory?.showAdvanced,
glyphStyle: this.promptConfiguration.glyphStyle, onboarding: false};
}

/** Opens Config on a specific row, revealing it if it is an advanced row; clears any remembered search. */
private focusConfigRow(rowId: string): void {
this.openSettingsPanel('config');
const state = this.settingsPanelState!;
state.searchQuery = '';
state.showAdvanced = state.showAdvanced || SETTINGS_ROWS.find(row => row.id === rowId)?.level === 'advanced';
state.contentIndex = Math.max(0, visibleSettingsRows(state).findIndex(row => row.id === rowId));
}

/**
* Keys for the shared Settings / Status / Config panel:
* - ←/→ switch views, except on an inline-editable Config row where they
Expand Down Expand Up @@ -1991,6 +2008,11 @@ export class TerminalApp {
if (editedSearch !== undefined) {
state.searchQuery = editedSearch;
state.contentIndex = 0;
} else if (key.kind === 'text' && view === 'config' && state.focus !== 'tabs' && !state.searchFocused && key.value.toLowerCase() === 'a' && !state.searchQuery?.trim()) {
state.showAdvanced = !state.showAdvanced;
state.contentIndex = 0;
} else if (key.kind === 'text' && view === 'config' && state.focus !== 'tabs' && !state.searchFocused && key.value.toLowerCase() === 'r' && row) {
if (settingsRowChanged(row, this.promptConfiguration)) this.applySettingsConfiguration(resetSettingsRow(row, this.promptConfiguration));
} else if (key.kind === 'text' && key.value === '/' && view === 'config') {
state.searchFocused = true;
state.focus = 'rows';
Expand Down
55 changes: 42 additions & 13 deletions src/ui/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface SettingsPanelState {
searchQuery?: string;
/** Only `/` focuses search; while focused, typing edits the query. */
searchFocused?: boolean;
/** Config also lists advanced rows. */
showAdvanced?: boolean;
glyphStyle: GlyphStyle;
onboarding: boolean;
}
Expand All @@ -65,6 +67,8 @@ interface SettingsRowBase {
description: string;
/** Search also matches the area a row belongs to. */
category: string;
/** Advanced rows stay out of the default Config list until the user asks for them (or searches). */
level?: 'advanced';
}

/**
Expand Down Expand Up @@ -105,18 +109,18 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [
control: 'child', destination: 'prompt', value: config => providerLabel(config.provider)},
{id: 'divider', label: 'History divider', description: 'Rule drawn above each past command', category: 'Transcript',
control: 'boolean', get: config => config.transcript.divider, set: (config, divider) => withTranscript(config, {divider})},
enumRow({id: 'dividerDensity', label: 'Divider density', description: 'Spacing around history dividers', category: 'Transcript',
enumRow({id: 'dividerDensity', level: 'advanced', label: 'Divider density', description: 'Spacing around history dividers', category: 'Transcript',
values: DENSITIES, labels: ['Normal', 'Compact'],
get: config => config.transcript.dividerDensity, set: (config, dividerDensity) => withTranscript(config, {dividerDensity})}),
{id: 'historicalPrompt', label: 'Prompt snapshots', description: 'Show the prompt each past command ran under', category: 'Transcript',
control: 'boolean', get: config => config.transcript.historicalPrompt,
set: (config, historicalPrompt) => withTranscript(config, {historicalPrompt})},
enumRow({id: 'historyColors', label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript',
enumRow({id: 'historyColors', level: 'advanced', label: 'History colors', description: 'How past prompt snapshots are colored', category: 'Transcript',
values: COLOR_MODES, labels: ['Follow prompt', 'Theme', 'Grayscale'],
get: config => config.transcript.historyColors, set: (config, historyColors) => withTranscript(config, {historyColors})}),
{id: 'syntaxHighlighting', label: 'Syntax highlighting', description: 'Color commands while typing and in new history', category: 'Syntax',
control: 'boolean', get: config => config.syntax.highlighting, set: (config, highlighting) => ({...config, syntax: {...config.syntax, highlighting}})},
enumRow({id: 'syntaxColors', label: 'Syntax colors', description: 'Follow prompt theme, a chosen theme, or grayscale', category: 'Syntax',
enumRow({id: 'syntaxColors', level: 'advanced', label: 'Syntax colors', description: 'Follow prompt theme, a chosen theme, or grayscale', category: 'Syntax',
values: COLOR_MODES, labels: ['Follow prompt', 'Theme', 'Grayscale'],
get: config => config.syntax.colors, set: (config, colors) => ({...config, syntax: {...config.syntax, colors}})}),
enumRow({id: 'promptStyle', label: 'Prompt style', description: 'NMSh Native look: Powerline, Soft, Minimal, or Outline', category: 'Prompt',
Expand All @@ -128,7 +132,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [
enumRow({id: 'transcriptPresentation', label: 'Transcript presentation', description: 'Normal rows, or Chat with commands on the right', category: 'Layout',
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',
enumRow({id: 'outputFolding', level: 'advanced', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript',
values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'],
get: config => config.outputFolding, set: (config, outputFolding) => ({...config, outputFolding})}),
enumRow({id: 'updateChecks', label: 'Update checks', description: 'Quietly check GitHub for new releases; /update checks on demand', category: 'Updates',
Expand All @@ -137,15 +141,15 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [
enumRow({id: 'liveSessionStartup', label: 'Startup restore', description: 'Resume a detached live session at launch: Ask, Always, or Never (never ends none)', category: 'Sessions',
values: LIVE_SESSION_STARTUP, labels: ['Ask', 'Always', 'Never'],
get: config => config.liveSessionStartup, set: (config, liveSessionStartup) => ({...config, liveSessionStartup})}),
enumRow({id: 'liveSessionMultiple', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions',
enumRow({id: 'liveSessionMultiple', level: 'advanced', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions',
values: LIVE_SESSION_MULTIPLE, labels: ['Ask which', 'Open all'],
get: config => config.liveSessionMultiple, set: (config, liveSessionMultiple) => ({...config, liveSessionMultiple})}),
{id: 'welcome', label: 'Welcome provider', description: 'What new sessions show first: Vespyr, Fastfetch, Neofetch, or None', category: 'Welcome',
control: 'child', destination: 'welcome', value: config => welcomeProvider(config.welcome).label},
{id: 'suggestions', label: 'Suggestions', description: 'Ghost-text prediction provider: NMSh Native, Deja, or None', category: 'Suggestions',
control: 'child', destination: 'suggestions',
value: config => SUGGESTION_PROVIDERS.find(provider => provider.id === config.suggestions)?.label ?? config.suggestions},
{id: 'suggestionsOnEmpty', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions',
{id: 'suggestionsOnEmpty', level: 'advanced', label: 'Empty-prompt prediction', description: 'Suggest the likely next command before typing', category: 'Suggestions',
control: 'boolean', get: config => config.suggestionsOnEmpty, set: (config, suggestionsOnEmpty) => ({...config, suggestionsOnEmpty})},
];

Expand All @@ -162,14 +166,17 @@ export const SETTINGS_ENTRIES: readonly SettingsRow[] = [
{id: 'layout', label: 'Layout', description: 'Preview and choose composer position and transcript presentation', category: 'Layout', control: 'child', destination: 'layout'},
];

/** Text cue (not color) that a value differs from its default. */
const CHANGED_MARK = () => (getCurrentGlyphMode() === 'nerd' ? '•' : '*');

export const PLANNED_AREAS = ['Layout', 'Blocks', 'Tools', 'Completion', 'Chroma'] as const;

export function visibleSettingsRows(state: SettingsPanelState): SettingsRow[] {
const view = settingsView(state);
if (view === 'status') return [];
if (view === 'settings') return [...SETTINGS_ENTRIES];
const query = state.searchQuery?.trim().toLowerCase();
if (!query) return [...SETTINGS_ROWS];
if (!query) return SETTINGS_ROWS.filter(row => state.showAdvanced || row.level !== 'advanced');
return SETTINGS_ROWS.filter(row => [row.label, row.description, row.category].some(text => text.toLowerCase().includes(query)));
}

Expand Down Expand Up @@ -197,6 +204,25 @@ export function toggleSettingsRow(row: SettingsRow, config: PromptConfiguration)
return adjustSettingsRow(row, config, 1);
}

/** True when an inline-editable row differs from the shipped default. */
export function settingsRowChanged(row: SettingsRow, config: PromptConfiguration): boolean {
if (row.control === 'enum') return row.index(config) !== row.index(DEFAULT_PROMPT_CONFIGURATION);
if (row.control === 'boolean') return row.get(config) !== row.get(DEFAULT_PROMPT_CONFIGURATION);
return false;
}

/** The row's default display value, for "reset to ..." help. */
export function settingsRowDefaultLabel(row: SettingsRow): string | undefined {
return settingsRowValue(row, DEFAULT_PROMPT_CONFIGURATION);
}

/** Configuration with only this row reset to its default; undefined when the row has no inline value. */
export function resetSettingsRow(row: SettingsRow, config: PromptConfiguration): PromptConfiguration | undefined {
if (row.control === 'enum') return row.select(config, row.index(DEFAULT_PROMPT_CONFIGURATION));
if (row.control === 'boolean') return row.set(config, row.get(DEFAULT_PROMPT_CONFIGURATION));
return undefined;
}

/** Enter on a row that opens something. */
export function settingsRowDestination(row: SettingsRow): SettingsDestination | undefined {
return row.control === 'child' || row.control === 'action' ? row.destination : undefined;
Expand Down Expand Up @@ -297,16 +323,18 @@ function renderRows(rows: readonly SettingsRow[], selected: number | undefined,
return out;
}

function footerText(state: SettingsPanelState, row: SettingsRow | undefined): string {
function footerText(state: SettingsPanelState, row: SettingsRow | undefined, changedDefault?: string): string {
const view = settingsView(state);
if (state.searchFocused) return '↑↓ results · Enter select · Esc clear';
if (view === 'status') return '←/→ to switch · ↑↓ to scroll · Esc to close';
if (state.focus === 'tabs') return '←/→ to switch · ↓ to select · Esc to close';
const search = view === 'config' ? ' · / to search' : '';
const escape = state.searchQuery?.trim() ? 'Esc to clear' : 'Esc to close';
if (isInlineEditable(row)) return `Enter/Space to change${search} · ${escape}`;
if (row) return `Enter to open · ←/→ to switch${search} · ${escape}`;
return `←/→ to switch${search} · ${escape}`;
const advanced = view === 'config' && !state.searchQuery?.trim() ? ` · A ${state.showAdvanced ? 'hide' : 'show'} advanced` : '';
const reset = changedDefault !== undefined ? ` · R reset to ${changedDefault}` : '';
if (isInlineEditable(row)) return `Enter/Space to change${reset}${search}${advanced} · ${escape}`;
if (row) return `Enter to open · ←/→ to switch${search}${advanced} · ${escape}`;
return `←/→ to switch${search}${advanced} · ${escape}`;
}

function toneColor(tone: StatusItem['tone']): string {
Expand Down Expand Up @@ -358,7 +386,8 @@ export function renderSettingsPanel(state: SettingsPanelState, columns: number,
const rows = visibleSettingsRows(state);
const selectedIndex = Math.min(state.contentIndex ?? 0, Math.max(0, rows.length - 1));
const selectedRow = tabsFocused ? undefined : rows[selectedIndex];
const footer = ['', `${MARGIN}${SUBTLE}${footerText(state, selectedRow)}${RESET}`];
const changedDefault = view === 'config' && selectedRow && settingsRowChanged(selectedRow, config) ? settingsRowDefaultLabel(selectedRow) : undefined;
const footer = ['', `${MARGIN}${SUBTLE}${footerText(state, selectedRow, changedDefault)}${RESET}`];
// Separator + header + footer; short terminals drop the footer first.
const chrome = 1 + header.length + footer.length;
const tight = maxRows - chrome < 3;
Expand All @@ -381,7 +410,7 @@ export function renderSettingsPanel(state: SettingsPanelState, columns: number,
const listBudget = Math.max(1, available - 3);
if (rows.length) {
body.push(...renderRows(rows, tabsFocused ? undefined : selectedIndex, columns, query,
row => settingsRowValue(row, config) ?? '', listBudget));
row => `${settingsRowValue(row, config) ?? ''}${settingsRowChanged(row, config) ? ` ${CHANGED_MARK()}` : ''}`, listBudget));
if (selectedRow && listBudget - rows.length >= 2) {
body.push('', `${MARGIN} ${highlightMatches(selectedRow.description, query, SUBTLE, SEARCH_MATCH)}${RESET}`);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/commandPalette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {join} from 'node:path';
import {TerminalApp} from '../src/app/TerminalApp.js';
import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js';
import {createPalette, filterPalette, handlePaletteKey, paletteItems, renderPalette} from '../src/ui/CommandPalette.js';
import {SETTINGS_ENTRIES, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js';
import {SETTINGS_ENTRIES, SETTINGS_ROWS, visibleSettingsRows} from '../src/ui/SettingsPanel.js';
import {decodeKeys} from '../src/terminal/keys.js';
import {stripAnsi} from '../src/util/text.js';

Expand Down Expand Up @@ -77,7 +77,7 @@ test('the palette opens, runs only the chosen action, and never executes shell t
app['handleKey']({kind: 'enter'});
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(app['settingsPanelState']?.view, 'config');
assert.equal(SETTINGS_ROWS[app['settingsPanelState']!.contentIndex!]!.id, 'composerPosition');
assert.equal(visibleSettingsRows(app['settingsPanelState']!)[app['settingsPanelState']!.contentIndex!]!.id, 'composerPosition');
app['settingsPanelState'] = undefined;

app['handleKey']({kind: 'palette'});
Expand Down
Loading
Loading