Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/professional-tone-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": minor
---

Added a `professionalTone` preference (`/settings` → Behavior → Professional Tone). When on, the end-of-turn note drops its random adjective ("Completed in 12s." instead of "Worked for a plucky 12s.") and the system prompt gains a TONE section instructing the model to stay terse and strictly functional — no filler, no preamble, no celebratory wrap-ups.
18 changes: 18 additions & 0 deletions docs/configuration/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ You can change this by editing the preferences file directly:

Reasoning traces can also be toggled dynamically with the Ctrl+R keyboard shortcut.

### Professional Tone

Professional ("boring") tone is stored in the preferences file with the `professionalTone` field:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `professionalTone` | boolean | `false` | When true, progress text is strictly functional (`Completed in 12s.` instead of `Worked for a plucky 12s.`) and the system prompt gains a TONE section telling the model to be terse — no filler, no preamble, no celebratory wrap-ups. |

You can change this via `/settings` → **Behavior** → **Professional Tone**, or by editing the preferences file directly:

```json
{
"professionalTone": true
}
```

The progress text changes immediately. The system prompt section is picked up the next time the prompt is rebuilt — on a mode or model switch, or on restart.

### Notification Configuration

Desktop notification preferences are stored under the `nanocoder.notifications` namespace:
Expand Down
1 change: 1 addition & 0 deletions source/app/components/settings-tabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ test('each tab lists its expected setting rows', async t => {
stdin.write(RIGHT);
await tick();
await expectRow('Tool Results and Thinking');
await expectRow('Professional Tone');

// Advanced.
stdin.write(RIGHT);
Expand Down
9 changes: 9 additions & 0 deletions source/app/components/settings-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
getNotificationsPreference,
getPasteThreshold,
getPrivacyPreference,
getProfessionalTone,
getReasoningExpanded,
updateAlternateScreen,
updateProfessionalTone,
} from '@/config/preferences';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
import {useTheme} from '@/hooks/useTheme';
Expand Down Expand Up @@ -184,6 +186,13 @@ function buildRowsForTab(
value: getReasoningExpanded() ? 'expanded' : 'collapsed',
panel: 'reasoning-traces',
},
{
kind: 'boolean',
id: 'professional-tone',
label: 'Professional Tone',
value: getProfessionalTone(),
onToggle: () => updateProfessionalTone(!getProfessionalTone()),
},
{
kind: 'managed',
id: 'default-mode',
Expand Down
9 changes: 9 additions & 0 deletions source/app/prompts/sections/professional-tone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## TONE

The user has enabled professional tone. Output must be strictly functional.

- **No filler**: No preambles ("Sure!", "Great question", "Let me..."), no sign-offs, no restating the request back.
- **No commentary about the work**: Don't narrate what you are about to do or celebrate what you finished. Report the result.
- **Terse**: Answer in the fewest words that are still complete and correct. Prefer a sentence over a paragraph, a fragment over a sentence.
- **Neutral register**: No enthusiasm, humor, emojis, or exclamation marks. Never praise the user or your own work.
- **Facts only**: State what changed, what failed, and what remains. Omit anything the user did not ask for.
60 changes: 60 additions & 0 deletions source/config/preferences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getNanocoderShape,
getNotificationsPreference,
getPasteThreshold,
getProfessionalTone,
getReasoningExpanded,
loadPreferences,
resetPreferencesCache,
Expand All @@ -17,6 +18,7 @@ import {
updateNanocoderShape,
updateNotificationsPreference,
updatePasteThreshold,
updateProfessionalTone,
updateReasoningExpanded,
getPrivacyPreference,
updatePrivacyPreference,
Expand Down Expand Up @@ -1562,3 +1564,61 @@ test.serial('full workflow: update and retrieve privacy preference', t => {
}
}
});

// ============================================================================
// professionalTone Tests
// ============================================================================

test.serial('getProfessionalTone returns false when not set', t => {
const preferencesPath = getTestPreferencesPath();
writeFileSync(
preferencesPath,
JSON.stringify({lastProvider: 'test'}, null, 2),
'utf-8',
);

try {
t.is(getProfessionalTone(), false);
} finally {
if (existsSync(preferencesPath)) {
rmSync(preferencesPath, {force: true});
}
}
});

test.serial('updateProfessionalTone persists the value', t => {
const preferencesPath = getTestPreferencesPath();
if (existsSync(preferencesPath)) {
rmSync(preferencesPath, {force: true});
}

try {
updateProfessionalTone(true);
t.is(getProfessionalTone(), true);
t.is(loadPreferences().professionalTone, true);

updateProfessionalTone(false);
t.is(getProfessionalTone(), false);
} finally {
if (existsSync(preferencesPath)) {
rmSync(preferencesPath, {force: true});
}
}
});

test.serial('updateProfessionalTone preserves other preferences', t => {
const preferencesPath = getTestPreferencesPath();
savePreferences({lastProvider: 'ollama', lastModel: 'qwen'});

try {
updateProfessionalTone(true);
const preferences = loadPreferences();
t.is(preferences.lastProvider, 'ollama');
t.is(preferences.lastModel, 'qwen');
t.is(preferences.professionalTone, true);
} finally {
if (existsSync(preferencesPath)) {
rmSync(preferencesPath, {force: true});
}
}
});
18 changes: 18 additions & 0 deletions source/config/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,21 @@ export function updateAlternateScreen(value: boolean): void {
preferences.alternateScreen = value;
savePreferences(preferences);
}

/**
* Get the professional ("boring") tone preference. When on, progress text is
* strictly functional and the model is instructed to keep responses terse.
*/
export function getProfessionalTone(): boolean {
const preferences = loadPreferences();
return preferences.professionalTone ?? false;
}

/**
* Save the professional tone preference
*/
export function updateProfessionalTone(value: boolean): void {
const preferences = loadPreferences();
preferences.professionalTone = value;
savePreferences(preferences);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import type {
} from '@/types/core';
import {buildResponseUsageBounded} from '@/usage/response-usage';
import {performAutoCompact} from '@/utils/auto-compact';
import {formatElapsedTime, getRandomAdjective} from '@/utils/completion-note';
import {buildCompletionNote} from '@/utils/completion-note';
import {MessageBuilder} from '@/utils/message-builder';
import {capMessagesForModel} from '@/utils/message-capping';
import {compressMessages} from '@/utils/message-compression';
Expand Down Expand Up @@ -1061,12 +1061,10 @@ export const processAssistantResponse = async (
await flushAll();

setIsGenerating(false);
const adjective = getRandomAdjective();
const elapsed = formatElapsedTime(startTime);
addToChatQueue(
<InfoMessage
key={generateKey('completion-time')}
message={`Worked for a ${adjective} ${elapsed}.`}
message={buildCompletionNote(startTime)}
hideBox={true}
marginBottom={2}
/>,
Expand Down
7 changes: 7 additions & 0 deletions source/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,11 @@ export interface UserPreferences {
* content. Also switchable per-run with the --no-alt-screen flag.
*/
alternateScreen?: boolean;
/**
* "Boring" output mode. false (default): playful touches stay, e.g. the
* "Worked for a plucky 12s." completion note. true: progress text is
* strictly functional and the system prompt gains a section telling the
* model to be terse — no filler, no preamble, no celebratory wrap-ups.
*/
professionalTone?: boolean;
}
21 changes: 20 additions & 1 deletion source/utils/completion-note.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import test from 'ava';
import {formatElapsedTime, getRandomAdjective} from './completion-note';
import {
buildCompletionNote,
formatElapsedTime,
getRandomAdjective,
} from './completion-note';

test('formatElapsedTime returns seconds only when under a minute', t => {
const now = Date.now();
Expand Down Expand Up @@ -29,3 +33,18 @@ test('getRandomAdjective returns a non-empty string', t => {
t.is(typeof adjective, 'string');
t.true(adjective.length > 0);
});

test('buildCompletionNote includes a random adjective by default', t => {
const note = buildCompletionNote(Date.now() - 30_000, false);
t.regex(note, /^Worked for a [a-z]+ \d+s\.$/);
});

test('buildCompletionNote drops the adjective under professional tone', t => {
const note = buildCompletionNote(Date.now() - 30_000, true);
t.regex(note, /^Completed in \d+s\.$/);
});

test('buildCompletionNote keeps minute formatting under professional tone', t => {
const note = buildCompletionNote(Date.now() - 90_000, true);
t.regex(note, /^Completed in \d+m \d+s\.$/);
});
16 changes: 16 additions & 0 deletions source/utils/completion-note.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {getProfessionalTone} from '@/config/preferences';

const adjectives = [
'brisk',
'swift',
Expand Down Expand Up @@ -39,3 +41,17 @@ export const formatElapsedTime = (startTime: number): string => {
}
return `${seconds}s`;
};

/**
* Build the end-of-turn progress note. Professional tone strips the random
* adjective so the line stays strictly functional.
*/
export const buildCompletionNote = (
startTime: number,
professionalTone: boolean = getProfessionalTone(),
): string => {
const elapsed = formatElapsedTime(startTime);
return professionalTone
? `Completed in ${elapsed}.`
: `Worked for a ${getRandomAdjective()} ${elapsed}.`;
};
45 changes: 45 additions & 0 deletions source/utils/prompt-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import test from 'ava';
import {existsSync, mkdtempSync, rmSync, writeFileSync} from 'fs';
import {tmpdir} from 'os';
import {join} from 'path';
import {resetPreferencesCache} from '@/config/preferences';
import {
buildSystemPrompt,
getLastBuiltPrompt,
Expand Down Expand Up @@ -546,3 +547,47 @@ test('buildSystemPrompt - replace systemPrompt updates getLastBuiltPrompt cache'
buildSystemPrompt('normal', undefined, ALL_TOOLS, false, override);
t.is(getLastBuiltPrompt(), 'cached override prompt');
});

// ============================================================================
// Professional tone
// ============================================================================

/**
* Build a prompt with `professionalTone` forced to a known value by pointing
* the preferences loader at a throwaway config dir.
*/
function buildWithProfessionalTone(enabled: boolean): string {
const dir = mkdtempSync(join(tmpdir(), 'nanocoder-tone-'));
const previousDir = process.env.NANOCODER_CONFIG_DIR;
process.env.NANOCODER_CONFIG_DIR = dir;
resetPreferencesCache();
writeFileSync(
join(dir, 'nanocoder-preferences.json'),
JSON.stringify({professionalTone: enabled}),
'utf-8',
);
try {
return buildSystemPrompt('normal', undefined, ALL_TOOLS);
} finally {
if (previousDir === undefined) {
delete process.env.NANOCODER_CONFIG_DIR;
} else {
process.env.NANOCODER_CONFIG_DIR = previousDir;
}
resetPreferencesCache();
rmSync(dir, {recursive: true, force: true});
}
}

test.serial('professional tone section is omitted when the preference is off', t => {
const result = buildWithProfessionalTone(false);
t.false(result.includes('## TONE'));
});

test.serial('professional tone section is included when the preference is on', t => {
const result = buildWithProfessionalTone(true);
t.true(result.includes('## TONE'));
t.true(result.includes('professional tone'));
// Placed before the system info block so it stays close to the end.
t.true(result.indexOf('## TONE') < result.indexOf('SYSTEM INFORMATION'));
});
7 changes: 7 additions & 0 deletions source/utils/prompt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {existsSync, readFileSync} from 'fs';
import {homedir, platform, release} from 'os';
import {basename, dirname, isAbsolute, join, normalize, resolve} from 'path';
import {fileURLToPath} from 'url';
import {getProfessionalTone} from '@/config/preferences';
import {isNanoProfile, isSingleToolProfile} from '@/tools/tool-profiles';
import type {SystemPromptConfig, TuneConfig} from '@/types/config';
import {TUNE_DEFAULTS} from '@/types/config';
Expand Down Expand Up @@ -314,6 +315,12 @@ ${getSubagentDescriptions()}`;
sections.push(subagentInfo);
}

// Professional ("boring") tone — user preference, opt-in. Placed last among
// the static sections so it overrides the register of anything above it.
if (getProfessionalTone()) {
sections.push(loadSection('professional-tone'));
}

// System info (dynamic) — slim variant under nano
sections.push(generateSystemInfo(nano));

Expand Down
Loading