Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b302cd9
Command queue: prepare the next commands while one runs; they run in …
raiseCatError Oct 10, 2026
15147d1
Queue: command text is drawn, never interpreted
raiseCatError Oct 10, 2026
a257438
Queue: the type-ahead window after Ctrl+C or Ctrl+Z is a moment, not …
raiseCatError Oct 10, 2026
5d1c737
Test scheduling: weigh the live queue, input-awareness and typing-aft…
raiseCatError Oct 10, 2026
b600c7f
Live tests: type an answer only once the question is open, and the ne…
raiseCatError Oct 10, 2026
fd882f6
Live tests: build the Fish read prompt in the shell so it is not in t…
raiseCatError Oct 10, 2026
7fb6b5a
Live queue tests: wider margins so a slow runner cannot end the runni…
raiseCatError Oct 10, 2026
f0db588
Hand Fish builtin readers authenticated terminal ownership
raiseCatError Oct 11, 2026
dfb242f
Submit multiline queues in native shell groups and synchronize input …
raiseCatError Oct 11, 2026
eaeb281
Merge branch 'fix/workflow-input-checkpoint' into fix/workflow-fish-r…
raiseCatError Oct 11, 2026
796f0a0
Protect hidden input throughout frontend startup and reattach
raiseCatError Oct 11, 2026
97e4270
Merge branch 'fix/workflow-input-checkpoint' into fix/workflow-fish-r…
raiseCatError Oct 11, 2026
2d24743
Merge branch 'fix/workflow-input-checkpoint' into fix/workflow-fish-r…
raiseCatError Oct 11, 2026
4636672
Keep whole-paste queue fixtures compatible with batch review
raiseCatError Oct 11, 2026
8b25ad0
Merge branch 'fix/workflow-cedar' into fix/workflow-birch
raiseCatError Oct 11, 2026
f081c42
merge: propagate portable verification fixtures
raiseCatError Oct 11, 2026
2dd8b80
merge: propagate isolated home and completion fixtures
raiseCatError Oct 11, 2026
e2adc1e
Synchronize queue fixtures with real command ownership
raiseCatError Oct 11, 2026
3adc451
Merge branch 'fix/workflow-cedar' into fix/workflow-birch
raiseCatError Oct 11, 2026
7b9de87
Merge branch 'fix/workflow-cedar' into fix/workflow-birch
raiseCatError Oct 12, 2026
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
3 changes: 3 additions & 0 deletions scripts/test-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ const suites = {
const runtimeWeights = {
"bundledCatalog.test.ts": 5,
"compatibilityHarness.test.ts": 28,
"commandQueueLive.test.ts": 68,
"configuredCompletion.test.ts": 6,
"ctrlZJobControl.test.ts": 12,
"detachedOutput.test.ts": 22,
"fullChromaQa.test.ts": 5,
"hostProfiles.test.ts": 10,
"inputAwarenessLive.test.ts": 59,
"interactiveCli.test.ts": 14,
"liveHardening.test.ts": 45,
"liveStatus.test.ts": 5,
Expand All @@ -41,6 +43,7 @@ const runtimeWeights = {
"startupBlocked.test.ts": 29,
"startupDiscovery.test.ts": 22,
"startupLaunch.test.ts": 8,
"typingAfterRead.test.ts": 17,
"themeBridgeLive.test.ts": 12
};

Expand Down
301 changes: 258 additions & 43 deletions src/app/TerminalApp.ts

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ export const CONCEPTS: readonly Concept[] = [
{id: 'leave', label: 'Leave NMSh for an ordinary shell', support: 'actionable', capability: 'shell.leave', covers: ['/zsh', '/fish', '/bash', '/exit'],
aliases: ['leave nmsh', 'exit nmsh', 'quit nmsh', 'ordinary shell', 'plain shell', 'regular shell'],
description: '/zsh, /fish and /bash leave NMSh for an ordinary shell (the session waits; `nmsh` returns). /exit uses your default shell.'},
{id: 'queue', label: 'Command queue', support: 'actionable', open: '/queue', covers: ['/queue'],
aliases: ['command queue', 'queue a command', 'queue commands', 'run after this finishes', 'run next'],
description: 'While a command runs, Enter queues the next command (Ctrl+Q always queues; Ctrl+S sends to the program). Entries run in order in the same shell after each finishes; a failure or Ctrl+C pauses the rest. /queue shows, edits, reorders, pauses, resumes or clears them.'},
{id: 'sessions', label: 'Live sessions', support: 'actionable', capability: 'session.list', open: '/sessions', covers: ['/sessions', '/rename', 'settings:Sessions'],
aliases: ['live sessions', 'running sessions', 'detached sessions', 'current sessions', 'open sessions', 'other sessions', 'sessions', 'session', 'other windows', 'startup restore'],
description: 'Live sessions are NMSh sessions running right now, attached or detached. /sessions switches to or ends one.'},
Expand Down Expand Up @@ -341,7 +344,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [
{id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']},
{id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']},
{id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'transcripts']},
{id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']},
{id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']},
{id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']},
{id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'],
notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']},
Expand Down
4 changes: 4 additions & 0 deletions src/commands/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [
{name: '/nmsh', insertion: '/nmsh raw ', description: 'Raw provider escape: /nmsh raw claude or codex with native CLI args'},
{name: '/guide', insertion: '/guide', description: 'Interactive guide to everything NMSh can do'},
{name: '/rename', insertion: '/rename ', description: 'Name this live session (display only); /rename alone returns to its familiar signature'},
{name: '/queue', insertion: '/queue', description: 'Commands queued to run after the current one: view, edit, reorder, pause or clear (/queue pause|resume|clear)'},
{name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'},
{name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'},
{name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'},
Expand Down Expand Up @@ -189,6 +190,7 @@ export type ParsedSlashCommand =
| {kind: 'llm'}
| {kind: 'doctor'}
| {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string}
| {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear'}
| {kind: 'rename'; name: string}
| {kind: 'directories', query: string}
| {kind: 'history', query: string}
Expand Down Expand Up @@ -306,6 +308,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined
if (family) return {kind: 'providers', family: (family === 'pickers' ? 'picker' : family) as 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'};
if (/^\/(?:llm|localllm)\s*$/u.test(input)) return {kind: 'llm'};
if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'};
const queue = /^\/queue(?:\s+(pause|resume|clear))?\s*$/u.exec(input);
if (queue) return {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear'};
const watch = /^\/watch(?:\s+(.*))?$/u.exec(input);
if (watch) {
const rest = (watch[1] ?? '').trim();
Expand Down
2 changes: 1 addition & 1 deletion src/configuration/portability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const PORTABLE_CATEGORIES = {
idle: ['idleVisuals', 'liveActivity'],
notifications: ['notifications', 'sessionNotices'],
tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateMode', 'updateFrequency'],
sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention', 'terminalTitle'],
sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention', 'terminalTitle', 'commandQueue'],
agents: ['agentActivity'],
shell: ['shellBackend'],
editor: ['openWith'],
Expand Down
5 changes: 4 additions & 1 deletion src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,8 @@ export interface PromptConfiguration {
localUnderstanding: LocalUnderstandingSettings;
/** Local-only agent CLI activity stats (durations and counts; never content). */
agentActivity: boolean;
/** Enter while a command runs queues the next command (Ctrl+S sends to the program); off: Enter sends to the program. */
commandQueue: boolean;
/** Shell backend for new sessions; /shell switches only the current session unless saved as default. */
shellBackend: ShellId;
/** Where /open and /open-diff delegate: the surrounding editor (auto), Zed, VS Code, or $VISUAL/$EDITOR. */
Expand Down Expand Up @@ -743,6 +745,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = {
askPresentation: 'chat' as const,
localUnderstanding: {mode: 'auto', ask: true, folding: false},
agentActivity: true,
commandQueue: true,
shellBackend: 'zsh',
openWith: 'auto',
terminalTitle: 'off',
Expand Down Expand Up @@ -838,7 +841,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio
: [];
const promptSymbolCustom = normalizeCustomGlyph(value.promptSymbolCustom);
const tooling = {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, agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, askRecord: value.askRecord !== false, askPresentation: value.askPresentation === 'normal' ? 'normal' as const : 'chat' as const,
sessionNotices: value.sessionNotices !== false, agentProfiles: normalizeProfiles(value.agentProfiles), agentActivity: value.agentActivity !== false, commandQueue: value.commandQueue !== false, askRecord: value.askRecord !== false, 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
90 changes: 90 additions & 0 deletions src/queue/QueuePanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type {Key} from '../terminal/keys.js';
import type {QueueOp, QueueState} from '../session/CommandQueue.js';
import {renderControls} from '../ui/controls.js';
import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js';
import {GLYPHS} from '../ui/glyphs.js';
import {displayWidth, truncateAnsi, truncateText} from '../util/text.js';
import {pauseReason, pausedGlyph, queueGlyph, queuePreview} from '../status/queueStatus.js';
import {displaySafe} from '../input/PasteReview.js';

/**
* `/queue`: the session's command queue as a keyboard-first list. It holds no queue of its own: every change is a
* QueueOp sent to the queue's owner, and the list redraws from the owner's next state, so what you see is what will
* run.
*/
export interface QueuePanelState {
selected: number;
/** Waiting for y/n before clearing everything. */
confirmClear?: boolean;
}

export type QueuePanelAction =
| {kind: 'close'}
| {kind: 'change'; change: QueueOp}
/** Load the entry into the composer for editing in place. */
| {kind: 'edit'; id: number};

export function queuePanelKey(panel: QueuePanelState, key: Key, queue: QueueState): QueuePanelAction | undefined {
const entries = queue.entries;
panel.selected = Math.max(0, Math.min(panel.selected, entries.length - 1));
if (panel.confirmClear) {
panel.confirmClear = false;
if (key.kind === 'text' && key.value.toLowerCase() === 'y') return {kind: 'change', change: {op: 'clear'}};
return undefined;
}
if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value === 'q')) return {kind: 'close'};
const entry = entries[panel.selected];
if (key.kind === 'up' || key.kind === 'down') {
if (entries.length) panel.selected = (panel.selected + (key.kind === 'up' ? -1 : 1) + entries.length) % entries.length;
return undefined;
}
if (key.kind === 'text' && key.value === 'p') return {kind: 'change', change: {op: queue.paused ? 'resume' : 'pause'}};
if (!entry) return undefined;
if (key.kind === 'selectUp' || key.kind === 'selectDown') {
const to = Math.max(0, Math.min(entries.length - 1, panel.selected + (key.kind === 'selectUp' ? -1 : 1)));
if (to === panel.selected) return undefined;
panel.selected = to;
return {kind: 'change', change: {op: 'move', id: entry.id, to}};
}
if (key.kind === 'enter' || (key.kind === 'text' && key.value === 'e')) return {kind: 'edit', id: entry.id};
if (key.kind === 'delete' || key.kind === 'backspace' || (key.kind === 'text' && key.value === 'd')) return {kind: 'change', change: {op: 'remove', id: entry.id}};
if (key.kind === 'text' && key.value === 'c') { panel.confirmClear = true; return undefined; }
return undefined;
}

/** The panel's rows (unframed): what runs now, what runs next, and every queued command in order. */
export function renderQueuePanel(panel: QueuePanelState, queue: QueueState, running: string | undefined, columns: number, height: number): string[] {
const primary = foreground(UI_COLORS.primary);
const secondary = foreground(UI_COLORS.secondary);
const subtle = foreground(UI_COLORS.subtle);
const accent = foreground(UI_COLORS.accent);
const reset = '\u001b[0m';
const width = Math.max(10, columns - 4);
const entries = queue.entries;
const status = queue.paused ? `${pausedGlyph()} paused · ${pauseReason(queue.paused)}` : entries.length ? `${queueGlyph()} runs in order, one at a time` : 'empty';
const out = [` ${primary}Command queue${reset} ${queue.paused ? accent : subtle}${truncateText(status, Math.max(4, width - 15))}${reset}`];
out.push(` ${subtle}${truncateText(running ? `Running: ${queuePreview(running, width)}` : 'Nothing running', width)}${reset}`, '');
if (!entries.length) {
out.push(` ${secondary}Nothing queued. While a command runs, type the next one and press Enter (or Ctrl+Q).${reset}`);
}
const listRoom = Math.max(1, height - 12);
const first = Math.max(0, Math.min(panel.selected - Math.floor(listRoom / 2), entries.length - listRoom));
entries.slice(first, first + listRoom).forEach((entry, offset) => {
const index = first + offset;
const selected = index === panel.selected;
const label = index === 0 && !queue.paused ? 'next' : `${index + 1}`;
const editing = queue.editing === entry.id ? ' (editing)' : '';
const text = queuePreview(entry.text, Math.max(4, width - displayWidth(label) - displayWidth(editing) - 4));
out.push(`${selected ? `${accent}${GLYPHS.selection}${reset}` : ' '} ${subtle}${label.padStart(4)}${reset} ${focusForeground(selected)}${text}${reset}${subtle}${editing}${reset}`);
});
const selected = entries[panel.selected];
if (selected && selected.text.includes('\n') && height >= out.length + 8) {
const lines = selected.text.split('\n');
out.push('', ` ${subtle}Runs as one block:${reset}`, ...lines.slice(0, 5).map(line => ` ${secondary}${truncateText(displaySafe(line), width)}${reset}`));
if (lines.length > 5) out.push(` ${subtle}… ${lines.length - 5} more line${lines.length === 6 ? '' : 's'}${reset}`);
}
out.push('');
if (panel.confirmClear) out.push(` ${accent}Clear ${entries.length} queued command${entries.length === 1 ? '' : 's'}? y / n${reset}`);
else out.push(renderControls([['↑↓', 'select'], ['Shift+↑↓', 'move'], ['Enter', 'edit'], ['Del', 'remove'], ['p', queue.paused ? 'resume' : 'pause'], ['c', 'clear'], ['Esc', 'close']]));
return out.map(line => truncateAnsi(line, columns));
}
Loading
Loading