Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
504f2b3
feat(status): restore and expand decorative activity dictionary (679 …
raiseCatError Oct 10, 2026
468dd72
Merge branch 'feature/compare-output' into feature/expressive-activit…
raiseCatError Oct 10, 2026
1845615
Activity wording: Expressive (default) and Classic, with a curated 66…
raiseCatError Oct 10, 2026
bf0cbf6
Merge feature/compare-output into feature/expressive-activity-vocabulary
raiseCatError Oct 10, 2026
77e789d
Merge feature/compare-output into feature/expressive-activity-vocabulary
raiseCatError Oct 10, 2026
66e164d
Merge feature/compare-output into feature/expressive-activity-vocabulary
raiseCatError Oct 10, 2026
d4619e4
Retain expressive activity choices through live reattachment
raiseCatError Oct 11, 2026
d016545
Merge branch 'fix/workflow-compare-checkpoint' into fix/workflow-voca…
raiseCatError Oct 11, 2026
fdf6eb3
Merge branch 'fix/workflow-compare-resume' into fix/workflow-vocabula…
raiseCatError Oct 11, 2026
0b2e886
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 11, 2026
25dfcae
merge: propagate report and portable verification fixtures
raiseCatError Oct 11, 2026
cd20d8a
merge: propagate isolated home and completion fixtures
raiseCatError Oct 11, 2026
be037a0
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 11, 2026
e8f1c2e
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
a6b8bce
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
e36bfda
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
e8db487
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
3616b9e
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
e5e2918
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
e7006ec
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
99019ae
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
a3b016b
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
ed7a68c
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
f3428b6
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
370af16
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
182fb3b
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 12, 2026
949a367
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 13, 2026
26bf696
Merge branch 'fix/workflow-indigo' into fix/workflow-willow
raiseCatError Oct 13, 2026
8b08bcf
Merge commit '6fad6aae6806ae5fce83a24d47fd8901792c0db4' into fix/work…
raiseCatError Oct 13, 2026
24cb4fb
Merge commit '0f46a0769a9783c33cb2a20f3a7745b341b3f5bf' into fix/work…
raiseCatError Oct 13, 2026
f6dbf7c
Merge verified fullscreen fixture correction into willow
raiseCatError Oct 13, 2026
9dc1022
test: preserve deterministic startup checks in willow
raiseCatError Oct 13, 2026
0dbd527
fix: preserve startup signal cleanup in willow
raiseCatError Oct 13, 2026
312aa5f
Merge commit 'c01b6e7428626d83d71b0ab067640c416a883309' into fix/work…
raiseCatError Oct 13, 2026
7f4470e
Merge commit '4c744895b2d18cb9d191127df201de1d598573f3' into fix/work…
raiseCatError Oct 14, 2026
cb25f73
Merge commit 'a86ada3d891a0eb14a7799075323cc1ca6f3150a' into fix/work…
raiseCatError Oct 14, 2026
5009033
Merge clipboard lifecycle and startup fixture corrections
raiseCatError Oct 14, 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
24 changes: 24 additions & 0 deletions docs/design/activity-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Expressive activity wording

Tracked by [#362](https://github.com/raiseCatError/notMyShell/issues/362) and
[#367](https://github.com/raiseCatError/notMyShell/pull/367).

Expressive is the default. Classic remains available in Activity settings and Setup;
both persist and transfer with the existing `idle` settings category.

The dictionary contains paired active/completed phrases. Quality checks reject technical
outcome claims, NMSh feature names, repeated phrases, incorrect conjugation and excessive
width. The phrases describe decoration; shell exit codes, interruption and measured
timing decide the actual lifecycle. Waiting-input and failure displays remain factual.
Stored lifecycle text, reports and clipboard copies use factual wording.

A command selects its pair before rendering. Selection uses its text and start time,
avoiding the preceding pair. The live journal stores dictionary indices for the running
and preceding choices, so redraws and reattachment with that journal retain the choice.
These indices have no effect on execution or status. Invalid indices are ignored. An old
or missing journal falls back to deterministic selection; it cannot reconstruct a
previously displaced choice. Archived completed transcripts display factual wording.

Automated coverage includes forced adjacent-choice collisions, settings persistence and
transfer, live detach/reattach, factual clipboard bytes, failures, interruption, dictionary
quality and narrow widths. Physical terminal appearance still requires human QA.
55 changes: 46 additions & 9 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ import {TaskProgress} from '../status/TaskProgress.js';
import {renderStatusStrip, stripStatsFromFacts, stripVisible, type StripModuleItem} from '../status/StatusStrip.js';
import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js';
import {completedActivity, liveActivityParts} from '../status/activity.js';
import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js';
import {OpenLine, promptShaped} from '../session/openLine.js';
import {completionWaitFact, directInputPlaceholder, inputActivityRows, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js';
import {pauseReason, pausedGlyph, queueGlyph, queueRow} from '../status/queueStatus.js';
Expand Down Expand Up @@ -722,6 +723,8 @@ export class TerminalApp {
private readonly commandModes = new AlternateScreenTracker();
private settingsPanelState?: SettingsPanelState;
private running?: {command: string; startedAt: number; interrupted: boolean; cleared: boolean; startId: number; cwd: string; historyAllowed?: number; awaitingExec?: boolean;
/** The decorative phrase pair chosen for this command, once (Expressive activity wording). */
activity?: ActivityVerbPair;
/** Started from the command queue rather than typed at the prompt. */
queued?: boolean;
/** When you sent Ctrl+C or Ctrl+Z: for a moment the command is ending at your request, so what you type next is
Expand Down Expand Up @@ -754,6 +757,8 @@ export class TerminalApp {
* are type-ahead for the shell and stay in the composer.
*/
private runningDraft?: {startId: number; at: number; answering: boolean};
/** The previous command's phrase pair, so two commands in a row never get the same one. */
private lastActivityPair?: ActivityVerbPair;
/** This session's command queue as its owner (the session service or in-process client) last reported it. */
private queueState: QueueState = {entries: []};
/** Ctrl+Q while a program reads keys directly: the composer takes typing for one queue entry, never the program. */
Expand Down Expand Up @@ -969,11 +974,13 @@ export class TerminalApp {
this.presentationStartCwd = journal.startCwd;
if (journal.live) {
this.streamSeq = Math.max(this.streamSeq, journal.live.seq);
this.lastActivityPair = ACTIVITY_VERBS[journal.live.previousActivityIndex ?? -1];
const running = journal.live.running;
if (running) {
const {cleared} = this.output.resumeActive(running.command, running.startId, running.outputStartId, mode => this.onActiveModeChange(mode));
this.running = {command: running.command, startedAt: running.startedAt, interrupted: false, cleared,
startId: running.startId, cwd: running.cwd, historyAllowed: running.historyAllowed,
activity: ACTIVITY_VERBS[running.activityIndex ?? -1],
// A submission checkpoint can precede the very first shell event.
// Its replayed readiness prompt must not complete the queued command.
awaitingExec: this.streamSeq === 0};
Expand Down Expand Up @@ -1067,6 +1074,7 @@ export class TerminalApp {
if (this.running) {
this.running.awaitingExec = false;
this.running.historyAllowed = historyAllowed;
this.chooseActivityPair(this.running);
if (!this.replaying && !this.startupPending && !this.passthrough && shouldPassthrough(command)) {
this.cancelPresentation();
this.passthrough = true;
Expand All @@ -1091,6 +1099,7 @@ export class TerminalApp {
this.noteForeignScreen();
this.handOverTerminal();
}
this.chooseActivityPair(this.running);
this.scheduleJournal();
if (!this.replaying) this.render();
}
Expand All @@ -1105,9 +1114,13 @@ export class TerminalApp {
if (!this.sessionId || this.shellEnded) return undefined;
const running = this.running;
const outputStartId = this.output.activeOutputStartId;
const previousActivityIndex = this.lastActivityPair ? ACTIVITY_VERBS.indexOf(this.lastActivityPair) : -1;
const activityIndex = running?.activity ? ACTIVITY_VERBS.indexOf(running.activity) : -1;
return {sessionId: this.sessionId, seq: this.streamSeq,
...(previousActivityIndex < 0 ? {} : {previousActivityIndex}),
...(running && outputStartId !== undefined ? {running: {command: running.command, startedAt: running.startedAt,
cwd: running.cwd, startId: this.output.activeStartId ?? running.startId, outputStartId, historyAllowed: running.historyAllowed}} : {})};
cwd: running.cwd, startId: this.output.activeStartId ?? running.startId, outputStartId, historyAllowed: running.historyAllowed,
...(activityIndex < 0 ? {} : {activityIndex})}} : {})};
}

/** Protect input before any program prompt is visible, including synchronous attach replay. */
Expand Down Expand Up @@ -3517,13 +3530,19 @@ export class TerminalApp {
const facts = extractFacts(command.command, outputText);
const isInterrupted = command.interrupted || exitCode === 130;
const displayCompletedAt = presentationCompletionTime(completedAt);
const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts);
const failure = isInterrupted ? undefined : classifyShellFailure(command.command, exitCode, outputText);
if (failure) parts.main = parts.main.replace('Command failed', failure === 'command-not-found' ? 'Command not found' : 'Shell syntax error');
else if (agent) parts.main = agentCompletionText(agent.id, elapsed, exitCode, isInterrupted);
parts.main += completionWaitFact(this.completedInputTiming);
if (command.queued) parts.main += ' · from queue';
this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`);
const finish = (pair?: ActivityVerbPair) => {
const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts, pair);
if (failure) parts.main = parts.main.replace('Command failed', failure === 'command-not-found' ? 'Command not found' : 'Shell syntax error');
else if (agent) parts.main = agentCompletionText(agent.id, elapsed, exitCode, isInterrupted);
parts.main += completionWaitFact(this.completedInputTiming);
if (command.queued) parts.main += ' · from queue';
return parts;
};
// What is stored (copies, reports, restored records) is always the factual wording; only the drawn row is playful.
const factual = finish();
this.output.setCompletionLifecycle(`${factual.main}${factual.detail}`);
const parts = agent || failure ? factual : finish(this.activityPairFor(command));
const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS);
this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`);
// The raw shell error stays; a curated command NMSh knows gets a factual follow-up (identity only, no lookup or install).
Expand Down Expand Up @@ -5857,7 +5876,7 @@ export class TerminalApp {
const customStops = editor.gradient.stops.length >= MIN_CUSTOM_STOPS ? [...editor.gradient.stops] : [...PRESET_STOPS.lavender];
return editor.target === 'idle'
? {...config, idleVisuals: {...config.idleVisuals, colorSource: 'custom', customStops}}
: {...config, liveActivity: {colors: 'custom', customStops}};
: {...config, liveActivity: {...config.liveActivity, colors: 'custom', customStops}};
}

/** Shared stop editor keys; Esc (when not typing a hex) saves the stops and returns. */
Expand Down Expand Up @@ -9484,12 +9503,30 @@ export class TerminalApp {
if (bytes) this.session.write(bytes);
}

/** This command's phrase pair under Expressive wording: chosen once from the command and its start, so it never changes while it runs. */
private activityPair(): ActivityVerbPair | undefined {
if (!this.running || this.promptConfiguration.liveActivity.style !== 'expressive') return undefined;
return this.chooseActivityPair(this.running);
}

private chooseActivityPair(command: {command: string; startedAt: number; activity?: ActivityVerbPair}): ActivityVerbPair {
if (!command.activity) command.activity = selectActivityVerb(command.command, command.startedAt, this.lastActivityPair);
this.lastActivityPair = command.activity;
return command.activity;
}

/** The pair for a command that just finished: the one it showed while running, or the same deterministic choice if it never drew. */
private activityPairFor(command: {command: string; startedAt: number; activity?: ActivityVerbPair}): ActivityVerbPair | undefined {
if (this.promptConfiguration.liveActivity.style !== 'expressive') return undefined;
return this.chooseActivityPair(command);
}

private currentActivity(): string {
if (!this.running) return '';
const elapsed = this.activityAnimationNow - this.running.startedAt;
const isActive = (Date.now() - this.lastOutputTime) < 750;
const animationElapsed = this.decorativeMotionAllowed() ? presentationAnimationElapsed(elapsed) : 0;
const parts = liveActivityParts(this.running.command, elapsed, animationElapsed);
const parts = liveActivityParts(this.running.command, elapsed, animationElapsed, this.activityPair());
// A soft light sweep over the working phrase: its own colors lifted in place, never moved; faster while output arrives.
// Colors come from Live activity colors; the sweep is the same light sweep everywhere.
const still = !this.decorativeMotionAllowed() || sweepStill(this.promptConfiguration);
Expand Down
12 changes: 9 additions & 3 deletions src/prompt/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,14 +550,20 @@ export function normalizeIdleVisuals(value: unknown): IdleVisualSettings {
export const LIVE_ACTIVITY_COLORS = ['appearance', 'lavender', 'grayscale', 'custom'] as const;
export type LiveActivityColors = typeof LIVE_ACTIVITY_COLORS[number];
export const LIVE_ACTIVITY_COLOR_LABELS: Record<LiveActivityColors, string> = {appearance: 'Follow appearance', lavender: 'Native Lavender', grayscale: 'Grayscale', custom: 'Custom'};
export interface LiveActivitySettings {colors: LiveActivityColors; customStops: string[]}
export const DEFAULT_LIVE_ACTIVITY: LiveActivitySettings = {colors: 'appearance', customStops: []};
/** Expressive: a playful working phrase and its past-tense counterpart on success. Classic: plain "Running" and "Completed". Failures and interrupts read the same in both. */
export const ACTIVITY_STYLES = ['expressive', 'classic'] as const;
export type ActivityStyle = typeof ACTIVITY_STYLES[number];
export const ACTIVITY_STYLE_LABELS: Record<ActivityStyle, string> = {expressive: 'Expressive', classic: 'Classic'};

export interface LiveActivitySettings {colors: LiveActivityColors; customStops: string[]; style: ActivityStyle}
export const DEFAULT_LIVE_ACTIVITY: LiveActivitySettings = {colors: 'appearance', customStops: [], style: 'expressive'};

export function normalizeLiveActivity(value: unknown): LiveActivitySettings {
const v = isRecord(value) ? value : {};
const customStops = validCustomStops(v.customStops) ? v.customStops.map(stop => stop.toLowerCase()) : [];
const colors = LIVE_ACTIVITY_COLORS.includes(v.colors as LiveActivityColors) ? v.colors as LiveActivityColors : 'appearance';
return {colors: colors === 'custom' && !customStops.length ? 'appearance' : colors, customStops};
const style = ACTIVITY_STYLES.includes(v.style as ActivityStyle) ? v.style as ActivityStyle : DEFAULT_LIVE_ACTIVITY.style;
return {colors: colors === 'custom' && !customStops.length ? 'appearance' : colors, customStops, style};
}

export interface PromptConfiguration {
Expand Down
11 changes: 9 additions & 2 deletions src/sessions/TranscriptStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ export interface LiveLink {
sessionId: string;
/** Last shell stream event reflected in this transcript. */
seq: number;
/** Presentation-only dictionary index; never execution status. */
previousActivityIndex?: number;
/** The command in flight when this checkpoint was taken. */
running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number};
running?: {command: string; startedAt: number; cwd: string; startId: number; outputStartId: number; historyAllowed?: number; activityIndex?: number};
}

function parseLive(value: unknown): LiveLink | undefined {
Expand All @@ -52,8 +54,13 @@ function parseLive(value: unknown): LiveLink | undefined {
const running = live.running as Record<string, unknown> | undefined;
const validRunning = running && typeof running.command === 'string' && typeof running.cwd === 'string'
&& ['startedAt', 'startId', 'outputStartId'].every(key => Number.isSafeInteger(running[key]));
const index = (value: unknown): number | undefined => Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) < 10_000 ? value as number : undefined;
const previousActivityIndex = index(live.previousActivityIndex);
const activityIndex = index(running?.activityIndex);
const {activityIndex: rawActivityIndex, ...runningFacts} = running ?? {};
return {sessionId: live.sessionId, seq: live.seq as number,
...(validRunning ? {running: running as unknown as NonNullable<LiveLink['running']>} : {})};
...(previousActivityIndex === undefined ? {} : {previousActivityIndex}),
...(validRunning ? {running: {...runningFacts, ...(activityIndex === undefined ? {} : {activityIndex})} as unknown as NonNullable<LiveLink['running']>} : {})};
}

export type TranscriptSummary = Omit<TranscriptSession, 'transcript' | 'preview' | 'live'> & {
Expand Down
1 change: 1 addition & 0 deletions src/setup/SetupCat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [
{row: configRow('idleRunBusy')},
{row: configRow('idleColor')},
routeRow('setupIdleColors', 'Edit idle colors', 'The idle visuals\' own gradient stops, with a live preview', 'idleColors', 'Idle visuals'),
{row: configRow('activityStyle'), note: draft => draft.liveActivity.style === 'expressive' ? 'Cooking… then Cooked for 18s; failures and interrupts keep their plain wording and exit code' : 'Running and Completed, as before'},
{row: configRow('activityColors'), note: () => 'The running-command line only; finished commands show their plain result'},
routeRow('setupActivityColors', 'Edit live activity colors', 'Gradient stops for the live activity line', 'activityColors', 'Live activity'),
]},
Expand Down
18 changes: 16 additions & 2 deletions src/status/activity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import stringWidth from 'string-width';
import {formatDuration, formatLocalTime} from './commandTiming.js';
import {GLYPHS, SPINNER_FRAMES} from '../ui/glyphs.js';
import type {ActivityVerbPair} from './activityVerbs.js';

export const ACTIVITY_GLYPH_INTERVAL_MS = 165;

Expand All @@ -9,7 +10,12 @@ export function activityGlyph(elapsedMs: number): string {
return frames[Math.floor(Math.max(0, elapsedMs) / ACTIVITY_GLYPH_INTERVAL_MS) % frames.length];
}

export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs): {phrase: string; duration: string} {
/**
* The working line. Classic names what runs ("Running ls -la"); Expressive (a pair given) shows only the decorative
* phrase, since the command is already on the line above it. Neither says anything about the work being verified.
*/
export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs, pair?: ActivityVerbPair): {phrase: string; duration: string} {
if (pair) return {phrase: `${activityGlyph(animationElapsedMs)} ${pair.active}…`, duration: ` · ${formatDuration(elapsedMs)}`};
let flattenedCommand = command.trim().replace(/\r?\n/g, ' ⏎ ');
if (flattenedCommand.length > 50) {
flattenedCommand = flattenedCommand.slice(0, 49) + '…';
Expand All @@ -20,7 +26,15 @@ export function liveActivityParts(command: string, elapsedMs: number, animationE
};
}

export function completedActivity(command: string, elapsedMs: number, completedAt: Date, exitCode: number, interrupted: boolean, facts?: string[]): {main: string; detail: string} {
/**
* The finished line. With a pair (Expressive), only a clean exit uses the decorative wording ("✔ Cooked for 18s ·
* done 14:46"); a failure or interrupt keeps the factual text with its exit code, so it can never read as success.
*/
export function completedActivity(command: string, elapsedMs: number, completedAt: Date, exitCode: number, interrupted: boolean, facts?: string[], pair?: ActivityVerbPair): {main: string; detail: string} {
if (pair && !interrupted && exitCode === 0) {
const detailFacts = facts && facts.length > 0 ? ` · ${facts.join(' · ')} · ${formatDuration(elapsedMs)}` : ` for ${formatDuration(elapsedMs)}`;
return {main: `${GLYPHS.success} ${pair.complete}${detailFacts}`, detail: ` · done ${formatLocalTime(completedAt)}`};
}
let statusText = 'Completed';
let icon = GLYPHS.success;

Expand Down
Loading
Loading