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
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ Optional **local understanding** can help interpret requests when enabled. Auto

Model text is not directly executed as a shell command or filesystem operation. Configuration, mutation, and installation require confirmation of the exact action in Ask; destructive suggestions are Copy/Insert only. Copying or inserting a suggested command does not execute it. File plans and supported configuration still use their normal path, hash, and ownership checks.

**Command suggestions** are optional and Off by default. When the person turns them on (Settings → Ask → Command suggestions → Local model), a request Ask's own resolver cannot place (never one it refused) may get one shell command from the local model. The model receives only the request text, the shell and the OS family; its answer is validated strictly (one plain line, no control or escape sequences), labelled by NMSh's own command classifier rather than the model, and offered for Copy or Insert only. Inserting places it in the composer unsent; only the person's Enter runs it.

## 20. Configuration and persistence

Let `<NMSh config>` mean the directory selected by the shared path resolver:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing.
- **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`.
- **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394).
- **Command suggestions in Ask, from your local model** ([#432](https://github.com/raiseCatError/notMyShell/issues/432), first part). Off by default; Settings → Ask → Command suggestions → Local model turns them on. When Ask cannot place a request (`/btw convert every png here to webp`), your local model (set up in `/llm`) may suggest one shell command. Ask shows it with NMSh's own check (reads only, changes things, installs software, destructive), never the model's, and offers Copy or Insert only: Insert puts it in the composer unsent, and only your Enter runs it. The model is sent the request, the shell and the OS family, nothing else; requests Ask refuses stay refused. Remote providers are not part of this release.

### Fixed

Expand Down
21 changes: 21 additions & 0 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ import {shellAdapter, shellAvailability, shellInstall} from '../shell/adapters/r
import {commandReference} from '../shell/CommandReference.js';
import {gitNextSteps, gitRunAllowed, gitSummary, renderCommand} from '../ask/gitAssist.js';
import {readGitFacts} from '../ask/git.js';
import {suggestionBlock} from '../ask/commandSuggestions.js';
import {configTargets, systemConfigEnvironment} from '../ask/configTargets.js';
import {systemFileAssistEnvironment, validateAfterWrite} from '../ask/configAssist.js';
import {formatterAllowed} from '../ask/repair.js';
Expand Down Expand Up @@ -9587,7 +9588,27 @@ export class TerminalApp {
}

/** Deterministic resolution first; an optional local interpretation may refine it (see LocalUnderstanding). */
/**
* Ask's answer: its own resolver (and the optional intent model) first. Only what that cannot place (unclear or
* unsupported, never a refusal) may get one suggested command, and only when the person turned command suggestions
* on (#432). The suggestion is Copy/Insert only, labelled by NMSh; the model gets the request, shell and OS family.
*/
private async resolveAsk(text: string, state: AskState): Promise<AskOutcome> {
const outcome = await this.resolveAskOwn(text, state);
if ((outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') || this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) return outcome;
if (this.askStage) this.setAskStage('Asking your local model for a command');
const result = await this.understanding.suggestCommand({text, shell: this.shellId, os: process.platform === 'darwin' ? 'macOS' : 'Linux'});
if (this.askState !== state) return outcome;
const next = outcome.kind === 'unclear' ? outcome.categories : undefined;
if ('unavailable' in result) return {kind: 'answer', capability: 'help.command', text: result.unavailable, ...(next?.length ? {next} : {})};
// The model declined: Ask's own answer stands.
if (!result.suggestion) return outcome;
const model = this.configuration.localUnderstanding.model?.label ?? 'local model';
return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.',
block: suggestionBlock(result.suggestion, model)};
}

private async resolveAskOwn(text: string, state: AskState): Promise<AskOutcome> {
const context = await this.askContext(text);
state.repoRoot = context.repoRoot;
const commands = this.askCommands();
Expand Down
46 changes: 46 additions & 0 deletions src/ask/commandSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {analyzePaste, commandWords} from '../input/pasteGuard.js';
import {repeatRisk} from '../tasks/repeatSafety.js';
import type {CommandSuggestion} from '../understanding/tasks.js';
import type {ActionRisk, CommandBlock} from './types.js';

/**
* What NMSh itself says about a suggested command (#432), from the classifier paste review uses. The model's own
* wording never sets it: a command that deletes, needs administrator rights, signals processes or pipes into an
* interpreter is destructive; installs are installs; anything that writes, reaches the network, runs project scripts
* or is unknown changes things. "Reads only" is the one verdict a person may relax for, so it needs every part to be
* plainly read-only: no substitution (`$(…)`, backticks, `<(…)`), no wrapper that runs another command (env, xargs,
* nice…), no find action, and nothing the stricter /track gate (repeatRisk) would question, such as `git -c` or an
* option that writes a file. Otherwise it changes things.
*/
export function suggestionRisk(command: string): ActionRisk {
const parts = analyzePaste(command).commands;
const kinds = parts.flatMap(item => item.kinds);
if (kinds.some(kind => kind === 'destructive' || kind === 'privilege' || kind === 'pipeline')) return 'destructive';
if (kinds.includes('install')) return 'install';
const plain = parts.length > 0 && !SUBSTITUTION.test(command)
&& parts.every(item => !WRAPPERS.has(firstWord(item.text)) && !FIND_ACTION.test(item.text) && !repeatRisk(item.text, item.kinds));
if (plain && kinds.every(kind => kind === 'navigation')) return 'navigate';
if (plain && kinds.every(kind => kind === 'read' || kind === 'navigation')) return 'read';
return 'mutate';
}

const SUBSTITUTION = /\$\(|`|<\(|>\(/u;
/** Commands whose job is to run another command. */
const WRAPPERS = new Set(['env', 'xargs', 'nice', 'nohup', 'timeout', 'command', 'exec', 'builtin', 'eval', 'time', 'watch', 'stdbuf', 'unbuffer', 'parallel', 'script', 'caffeinate', 'flock', 'chroot', 'su']);
/** find options that run a command or delete what they find. */
const FIND_ACTION = /(?:^|\s)-(?:exec|execdir|ok|okdir|delete|fprint|fprintf|fls)\b/u;
const firstWord = (text: string) => (commandWords(text).find(word => !/^[A-Za-z_]\w*=/u.test(word)) ?? '').split('/').pop() ?? '';

/**
* A suggestion as an Ask command block: shown verbatim (`literal`), so Ask offers Copy and Insert only, never Run;
* Insert puts it in the composer unsent and only the person's Enter runs it. The note says where it came from.
*/
export function suggestionBlock(suggestion: CommandSuggestion, modelLabel: string): CommandBlock {
const risk = suggestionRisk(suggestion.command);
// NMSh's verdict first; the model's own words after it, quoted and attributed, so they cannot pose as NMSh's.
return {argv: [], literal: suggestion.command, provenance: 'suggested', risk,
note: `NMSh's check: ${RISK_WORDS[risk]}. Your local model (${modelLabel}) says: “${suggestion.explanation}” Check it before you run it.`};
}

const RISK_WORDS: Record<ActionRisk, string> = {informational: 'shows information', read: 'reads only', navigate: 'changes directory',
configure: 'changes settings', mutate: 'changes things', destructive: 'destructive', install: 'installs software'};
3 changes: 2 additions & 1 deletion src/ask/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export interface CommandBlock {
argv: string[];
/** Indices of argv that are placeholders, never filled by guessing. */
placeholders?: number[];
provenance: 'reference' | 'context';
/** suggested: a command the person's local model proposed (#432); Copy/Insert only, its risk labelled by NMSh. */
provenance: 'reference' | 'context' | 'suggested';
risk: ActionRisk;
/** One line under the command: what it does here. */
note?: string;
Expand Down
12 changes: 11 additions & 1 deletion src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,10 @@ export type OnboardingBackdrop = 'off' | 'subtle' | 'aurora';
export const STARTING_POINT_IDS = ['recommended', 'minimal', 'customize', 'existing'] as const;
export type StartingPointId = typeof STARTING_POINT_IDS[number];

/** Where Ask's command suggestions come from: nowhere (default) or the person's local model (#432). */
export type AskCommandSuggestions = 'off' | 'local';
export const ASK_COMMAND_SUGGESTIONS: readonly AskCommandSuggestions[] = ['off', 'local'];

export interface PromptConfiguration {
presentation: TreatmentSettings;
/** General NMSh UI motion (cursor motion lives in `cursor`, Chroma in `presentation`). */
Expand Down Expand Up @@ -675,6 +679,11 @@ export interface PromptConfiguration {
agentProfiles: AgentProfile[];
/** Keep Ask questions and replies with the session transcript. Approved actions follow their own history rules either way. */
askRecord: boolean;
/**
* Command suggestions in Ask (#432): off, or one suggested shell command from the person's local model when Ask's own
* resolver cannot place a request. Copy/Insert only, never run. Remote providers are a later, separately reviewed slice.
*/
askCommandSuggestions: AskCommandSuggestions;
/** How the Ask panel lays out its conversation; independent of the transcript's presentation. */
askPresentation: 'chat' | 'normal';
/** Optional local language understanding; Auto by default (deterministic first, nothing downloads without consent). Folding stays opt-in. */
Expand Down Expand Up @@ -890,6 +899,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = {
sessionNotices: true,
copy: {...DEFAULT_COPY},
askRecord: true,
askCommandSuggestions: 'off',
askPresentation: 'chat' as const,
localUnderstanding: {mode: 'auto', ask: true, folding: false},
agentActivity: true,
Expand Down Expand Up @@ -1010,7 +1020,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio
: [];
const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom);
const tooling = {introSeen: value.introSeen !== false, motion: normalizeMotion(value.motion), pastePreview: (value.pastePreview === 'always' || value.pastePreview === 'off' ? value.pastePreview : 'smart') as 'smart' | 'always' | 'off', cursor: normalizeCursor(value.cursor), statusStrip: normalizeStatusStrip(value.statusStrip), keepAwake: normalizeKeepAwakePresentation(value.keepAwake), idleVisuals: normalizeIdleVisuals(value.idleVisuals), liveActivity: normalizeLiveActivity(value.liveActivity), uiChrome: normalizeUiChrome(value.uiChrome),
sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const,
sessionNotices: value.sessionNotices !== false, copy: normalizeCopy(value.copy), agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, queueFailurePolicy: value.queueFailurePolicy === 'continue' ? 'continue' as const : 'pause' as const, queue: normalizeQueuePresentation(value.queue), workspacePanels: value.workspacePanels === true, askRecord: value.askRecord !== false, askCommandSuggestions: value.askCommandSuggestions === 'local' ? 'local' as const : 'off' as const, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const,
localUnderstanding: normalizeLocalUnderstanding(value.localUnderstanding),
shellBackend: isShellId(value.shellBackend) ? value.shellBackend : 'zsh',
openWith: OPEN_WITH_IDS.includes(value.openWith as OpenWith) ? value.openWith as OpenWith : 'auto',
Expand Down
3 changes: 3 additions & 0 deletions src/setup/SetupCat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [
{row: configRow('localUnderstanding'), note: draft => localUnderstandingNote(draft)},
{row: configRow('localUnderstandingAsk'), note: () => 'Improve Ask understanding'},
{row: configRow('localUnderstandingFolding'), note: () => 'Improve Smart Folding'},
{row: configRow('askCommandSuggestions'), note: draft => draft.askCommandSuggestions === 'local'
? 'Local model: a request Ask cannot place may get one suggested command, labelled by NMSh, to copy or insert. It never runs'
: 'Off: Ask answers on its own; nothing is sent to a model for commands'},
]},
{id: 'welcomeScreen', title: 'Welcome', intro: ['What a new session shows first. Vespyr is the NMSh cat.'], rows: [
providerRow('setupWelcome', 'Welcome', 'New-session welcome', 'Welcome', WELCOME_PROVIDERS.filter(provider => WELCOME_PROVIDER_IDS.includes(provider.id)),
Expand Down
7 changes: 5 additions & 2 deletions src/ui/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {OPEN_WITH_IDS} from '../host/HostActions.js';
import {TREATMENT_PRESETS, TREATMENT_PRESET_LABELS, TREATMENT_GEOMETRIES, TREATMENT_GEOMETRY_LABELS, TREATMENT_MOTIONS, TREATMENT_MOTION_LABELS,
TREATMENT_SPEEDS, TREATMENT_SPEED_LABELS, TREATMENT_INFLUENCES, treatmentInfluence, SEMANTIC_MODES, SEMANTIC_MODE_LABELS, TREATMENT_SCOPES, TREATMENT_SCOPE_LABELS, TREATMENT_CURVES, TREATMENT_CURVE_LABELS, DIVIDER_LINES_HELP, dividerLinesLabel, PRESET_STOPS} from '../chroma/treatment.js';
import {historicalPromptLevel} from '../output/TranscriptPanel.js';
import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js';
import {STRIP_EDGES, STRIP_SEPARATORS, STRIP_STYLES, STRIP_ZONES, PANEL_POSITIONS, DIVIDER_COLOR_LABELS, DIVIDER_COLOR_MODES, NATIVE_PALETTE_IDS, CURSOR_BLINKS, CURSOR_SHAPES, IDLE_COLOR_LABELS, IDLE_COLOR_SOURCES, IDLE_TIMEOUTS, ACTIVITY_STYLES, ACTIVITY_STYLE_LABELS, LIVE_ACTIVITY_COLORS, LIVE_ACTIVITY_COLOR_LABELS, RAM_DISPLAYS, ASK_COMMAND_SUGGESTIONS, LOCAL_UNDERSTANDING_LABELS, LOCAL_UNDERSTANDING_MODES, SHELL_MODULE_VISIBILITY, SHELL_MODULE_VISIBILITY_LABELS, applyShellModuleVisibility, shellModuleVisibility, type StatusStripSettings} from '../prompt/configuration.js';
import {IDLE_MODES, IDLE_MODE_LABELS} from '../idle/scenes.js';
import {MOTION_LABELS, MOTION_RENDERING_ITEM, MOTION_ROWS, MOTION_TUNING_ITEMS, type MotionItem} from '../motion/motionRows.js';
import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js';
Expand Down Expand Up @@ -482,6 +482,9 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [
}}),
{id: 'askRecord', label: 'Record Ask in transcript', description: 'Keep Ask questions and replies with this session\'s transcript; commands or actions you approve still follow their normal history rules', category: 'Ask',
control: 'boolean', get: config => config.askRecord, set: (config, askRecord) => ({...config, askRecord})},
enumRow({id: 'askCommandSuggestions', label: 'Command suggestions', description: 'When Ask cannot place a request, suggest one shell command from your local model (/llm). You check it, then copy or insert it; NMSh never runs it', category: 'Ask',
values: ASK_COMMAND_SUGGESTIONS, labels: ['Off', 'Local model'],
get: config => config.askCommandSuggestions, set: (config, askCommandSuggestions) => ({...config, askCommandSuggestions})}),
enumRow({id: 'askPresentation', label: 'Ask presentation', description: 'Chat puts your Ask turns on the right; Normal keeps both sides on the left. The transcript keeps its own presentation', category: 'Ask',
values: ASK_PRESENTATIONS, labels: ASK_PRESENTATIONS.map(mode => ASK_PRESENTATION_LABELS[mode]),
get: config => config.askPresentation, set: (config, askPresentation) => ({...config, askPresentation})}),
Expand Down Expand Up @@ -718,7 +721,7 @@ const CONFIG_ORDER: readonly string[] = [
'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications',
'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions',
'localUnderstanding',
'askRecord', 'askPresentation', 'outputFolding', 'semanticOutput',
'askRecord', 'askPresentation', 'askCommandSuggestions', 'outputFolding', 'semanticOutput',
];
const groupRank = (row: SettingsRow) => CONFIG_GROUPS.indexOf(configGroup(row));
/** Sort key: group, the root's place in CONFIG_ORDER, the root's own position, then the row's (so a child stays under its parent). */
Expand Down
Loading
Loading