From 318327aae0c52d13c6cc56bf5187b1638ad74964 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:04:38 +0530 Subject: [PATCH] Add Settings v2: simple/advanced view, changed markers, reset and remembered position --- src/app/TerminalApp.ts | 34 +++++++++-- src/ui/SettingsPanel.ts | 55 +++++++++++++----- tests/commandPalette.test.ts | 4 +- tests/settingsControls.test.ts | 11 ++-- tests/settingsV2.test.ts | 100 +++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 tests/settingsV2.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 0b0230c8..f082a700 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -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, @@ -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; } @@ -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]; @@ -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 @@ -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'; diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 02ed4328..f6382250 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -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; } @@ -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'; } /** @@ -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', @@ -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', @@ -137,7 +141,7 @@ 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', @@ -145,7 +149,7 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ {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})}, ]; @@ -162,6 +166,9 @@ 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[] { @@ -169,7 +176,7 @@ export function visibleSettingsRows(state: SettingsPanelState): SettingsRow[] { 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))); } @@ -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; @@ -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 { @@ -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; @@ -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}`); } diff --git a/tests/commandPalette.test.ts b/tests/commandPalette.test.ts index 3f93701f..436f96d4 100644 --- a/tests/commandPalette.test.ts +++ b/tests/commandPalette.test.ts @@ -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'; @@ -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'}); diff --git a/tests/settingsControls.test.ts b/tests/settingsControls.test.ts index 61e930a8..3399b8d8 100644 --- a/tests/settingsControls.test.ts +++ b/tests/settingsControls.test.ts @@ -77,7 +77,7 @@ test('search field is bordered, inset, and shows focus with a caret and accent b }); test('Config rows are compact, single-line, with aligned values and a pointer', () => { - const rows = plain(renderSettingsPanel(config({contentIndex: 2}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); + const rows = plain(renderSettingsPanel(config({contentIndex: 2, showAdvanced: true}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); const list = rows.filter(row => /^ {2}[› ] (Glyph style|Prompt provider|History divider|Divider density|Prompt snapshots|History colors)/u.test(row)); assert.equal(list.length, 6); const first = rows.indexOf(list[0]!); @@ -86,7 +86,7 @@ test('Config rows are compact, single-line, with aligned values and a pointer', assert.equal(new Set(valueStarts).size, 1, `values align: ${list.join('|')}`); assert.match(list[2]!, /^ {2}› History divider\s+true$/u); assert.match(list[0]!, /^ {4}Glyph style\s+Nerd Font$/u, 'enum values are plain, not ‹ › wrapped'); - const styled = renderSettingsPanel(config({contentIndex: 2}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION}); + const styled = renderSettingsPanel(config({contentIndex: 2, showAdvanced: true}), 80, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION}); const accent = foreground(UI_COLORS.accent); const selected = styled.find(row => stripAnsi(row).includes('› History divider'))!; assert.ok(selected.includes(`\u001B[1m${accent}History divider`), 'selected label is bold accent'); @@ -109,8 +109,8 @@ test('search highlights matches restrainedly, distinct from the selected row', ( test('footer follows Claude-style phrasing for the focused control', () => { const footer = (state: SettingsPanelState) => stripAnsi(renderSettingsPanel(state, 120).at(-1)!).trim(); - assert.equal(footer(config()), 'Enter/Space to change · / to search · Esc to close'); - assert.equal(footer(config({contentIndex: 1})), 'Enter to open · ←/→ to switch · / to search · Esc to close'); + assert.equal(footer(config()), 'Enter/Space to change · / to search · A show advanced · Esc to close'); + assert.equal(footer(config({contentIndex: 1})), 'Enter to open · ←/→ to switch · / to search · A show advanced · Esc to close'); assert.equal(footer(config({searchFocused: true, searchQuery: 'x'})), '↑↓ results · Enter select · Esc clear'); assert.equal(footer(config({view: 'status'})), '←/→ to switch · ↑↓ to scroll · Esc to close'); assert.ok(!footer(config()).includes('Tab')); @@ -138,6 +138,7 @@ test('Left/Right change an enum inline and persist it; Enter changes too', () => test('Space and Enter toggle a real boolean; values stay shared with Status', () => withApp(async (app, path) => { app['openSettingsPanel']('config'); + app['settingsPanelState']!.showAdvanced = true; app['handleKey']({kind: 'down'}); app['handleKey']({kind: 'down'}); app['handleKey']({kind: 'text', value: ' '}); @@ -200,7 +201,7 @@ test('panel chrome never enters transcript, resume data, or copy payload', () => test('narrow widths keep values visible and never overflow', () => { for (const columns of [24, 32, 40]) { for (const [contentIndex, value] of [[0, 'Nerd Font'], [5, 'Follow prompt'], [2, 'true']] as const) { - const rows = renderSettingsPanel(config({contentIndex}), columns, 40); + const rows = renderSettingsPanel(config({contentIndex, showAdvanced: true}), columns, 40); for (const line of rows) assert.ok(displayWidth(line) <= columns, `${columns}: ${stripAnsi(line)}`); assert.ok(rows.some(line => stripAnsi(line).includes(value)), `${columns}: ${value}`); } diff --git a/tests/settingsV2.test.ts b/tests/settingsV2.test.ts new file mode 100644 index 00000000..ad81b311 --- /dev/null +++ b/tests/settingsV2.test.ts @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import type {Key} from '../src/terminal/keys.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; +import { + renderSettingsPanel, resetSettingsRow, SETTINGS_ROWS, settingsRowChanged, visibleSettingsRows, type SettingsPanelState, +} from '../src/ui/SettingsPanel.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {getCurrentGlyphMode, setIconStyle} from '../src/ui/glyphs.js'; + +const config = (patch: Partial = {}): SettingsPanelState => + ({section: 'root', view: 'config', selectedIndex: 0, glyphStyle: 'nerd', onboarding: false, ...patch}); +const text = (value: string) => [...value].map(character => ({kind: 'text', value: character}) as Key); +const plain = (rows: string[]) => rows.map(stripAnsi); + +/** An app whose config writes land in a throwaway directory. */ +async function withApp(run: (app: TerminalApp, configPath: string) => Promise | void): Promise { + const directory = await mkdtemp(join(tmpdir(), 'nmsh-settings-')); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = directory; + const app = new TerminalApp(); + try { + app['render'] = () => {}; + await run(app, join(directory, 'nmsh', 'config.json')); + } finally { + app['stop'](0); + app['session'].kill(); + setIconStyle('nerd'); + if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous; + await rm(directory, {recursive: true, force: true}); + } +} + + +const row = (id: string) => SETTINGS_ROWS.find(item => item.id === id)!; + +test('default Config hides advanced rows; A reveals them; search always finds them', () => { + const simple = visibleSettingsRows(config()).map(item => item.id); + assert.ok(simple.includes('glyphStyle') && !simple.includes('dividerDensity')); + assert.ok(visibleSettingsRows(config({showAdvanced: true})).some(item => item.id === 'dividerDensity')); + assert.ok(visibleSettingsRows(config({searchQuery: 'density'})).some(item => item.id === 'dividerDensity')); + assert.equal(SETTINGS_ROWS.filter(item => item.level === 'advanced').length > 0, true); +}); + +test('changed-from-default is a text cue and reset targets one setting', () => { + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const, outputFolding: 'always' as const}; + assert.equal(settingsRowChanged(row('updateChecks'), DEFAULT_PROMPT_CONFIGURATION), false); + assert.equal(settingsRowChanged(row('updateChecks'), changed), true); + const reset = resetSettingsRow(row('updateChecks'), changed)!; + assert.equal(reset.updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + assert.equal(reset.outputFolding, 'always', 'other settings untouched'); + assert.equal(resetSettingsRow(row('provider'), changed), undefined); +}); + +test('rendering marks changed rows and offers reset only when it applies', () => { + const index = visibleSettingsRows(config()).findIndex(item => item.id === 'updateChecks'); + const changed = {...DEFAULT_PROMPT_CONFIGURATION, updateChecks: 'weekly' as const}; + const shown = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: changed})); + assert.ok(shown.some(line => line.includes('Update checks') && line.includes('Weekly •'))); + assert.ok(shown.at(-1)!.includes('R reset to')); + const clean = plain(renderSettingsPanel(config({contentIndex: index}), 100, Infinity, {configuration: DEFAULT_PROMPT_CONFIGURATION})); + assert.ok(!clean.some(line => line.includes('•')) && !clean.at(-1)!.includes('R reset')); + for (const columns of [24, 40]) for (const line of renderSettingsPanel(config({contentIndex: index}), columns, Infinity, {configuration: changed})) assert.ok(displayWidth(line) <= columns); +}); + +test('R resets the focused setting, A toggles advanced, and position is remembered', () => withApp(app => { + app['openSettingsPanel']('config'); + app['applySettingsConfiguration']({...app['promptConfiguration'], updateChecks: 'weekly'}); + const index = visibleSettingsRows(app['settingsPanelState']!).findIndex(item => item.id === 'updateChecks'); + app['settingsPanelState']!.contentIndex = index; + app['handleKey']({kind: 'text', value: 'r'}); + assert.equal(app['promptConfiguration'].updateChecks, DEFAULT_PROMPT_CONFIGURATION.updateChecks); + app['handleKey']({kind: 'text', value: 'a'}); + assert.equal(app['settingsPanelState']!.showAdvanced, true); + assert.equal(app['settingsPanelState']!.contentIndex, 0); + app['settingsPanelState']!.contentIndex = 3; + app['handleKey']({kind: 'down'}); + app['settingsPanelState'] = undefined; + app['openSettingsPanel']('config'); + assert.equal(app['settingsPanelState']!.contentIndex, 4); + assert.equal(app['settingsPanelState']!.showAdvanced, true); +})); + +test('the palette can target an advanced row', () => withApp(app => { + app['focusConfigRow']('dividerDensity'); + const state = app['settingsPanelState']!; + assert.equal(visibleSettingsRows(state)[state.contentIndex!]!.id, 'dividerDensity'); +})); + +test('R does nothing while searching, so it can be typed', () => withApp(app => { + app['openSettingsPanel']('config'); + app['handleKey']({kind: 'text', value: '/'}); + app['handleKey']({kind: 'text', value: 'r'}); + assert.equal(app['settingsPanelState']!.searchQuery, 'r'); +}));