diff --git a/.changeset/professional-tone-toggle.md b/.changeset/professional-tone-toggle.md new file mode 100644 index 000000000..356fc3db1 --- /dev/null +++ b/.changeset/professional-tone-toggle.md @@ -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. diff --git a/docs/configuration/preferences.md b/docs/configuration/preferences.md index c9827e473..a72ee1d0d 100644 --- a/docs/configuration/preferences.md +++ b/docs/configuration/preferences.md @@ -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: diff --git a/source/app/components/settings-tabs.spec.tsx b/source/app/components/settings-tabs.spec.tsx index bbf4343be..c47bc63cb 100644 --- a/source/app/components/settings-tabs.spec.tsx +++ b/source/app/components/settings-tabs.spec.tsx @@ -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); diff --git a/source/app/components/settings-tabs.tsx b/source/app/components/settings-tabs.tsx index 126f017a9..7bba0df0a 100644 --- a/source/app/components/settings-tabs.tsx +++ b/source/app/components/settings-tabs.tsx @@ -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'; @@ -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', diff --git a/source/app/prompts/sections/professional-tone.md b/source/app/prompts/sections/professional-tone.md new file mode 100644 index 000000000..6389eb041 --- /dev/null +++ b/source/app/prompts/sections/professional-tone.md @@ -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. diff --git a/source/config/preferences.spec.ts b/source/config/preferences.spec.ts index 53305cbd3..e5e3aa574 100644 --- a/source/config/preferences.spec.ts +++ b/source/config/preferences.spec.ts @@ -8,6 +8,7 @@ import { getNanocoderShape, getNotificationsPreference, getPasteThreshold, + getProfessionalTone, getReasoningExpanded, loadPreferences, resetPreferencesCache, @@ -17,6 +18,7 @@ import { updateNanocoderShape, updateNotificationsPreference, updatePasteThreshold, + updateProfessionalTone, updateReasoningExpanded, getPrivacyPreference, updatePrivacyPreference, @@ -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}); + } + } +}); diff --git a/source/config/preferences.ts b/source/config/preferences.ts index 3a77a5120..176fef301 100644 --- a/source/config/preferences.ts +++ b/source/config/preferences.ts @@ -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); +} diff --git a/source/hooks/chat-handler/conversation/conversation-loop.tsx b/source/hooks/chat-handler/conversation/conversation-loop.tsx index 3d477b4e8..9eafd0293 100644 --- a/source/hooks/chat-handler/conversation/conversation-loop.tsx +++ b/source/hooks/chat-handler/conversation/conversation-loop.tsx @@ -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'; @@ -1061,12 +1061,10 @@ export const processAssistantResponse = async ( await flushAll(); setIsGenerating(false); - const adjective = getRandomAdjective(); - const elapsed = formatElapsedTime(startTime); addToChatQueue( , diff --git a/source/types/config.ts b/source/types/config.ts index a499a613b..4da461e1a 100644 --- a/source/types/config.ts +++ b/source/types/config.ts @@ -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; } diff --git a/source/utils/completion-note.spec.ts b/source/utils/completion-note.spec.ts index aa492a58e..16d06bd07 100644 --- a/source/utils/completion-note.spec.ts +++ b/source/utils/completion-note.spec.ts @@ -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(); @@ -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\.$/); +}); diff --git a/source/utils/completion-note.ts b/source/utils/completion-note.ts index e7922142c..7fd8b99ac 100644 --- a/source/utils/completion-note.ts +++ b/source/utils/completion-note.ts @@ -1,3 +1,5 @@ +import {getProfessionalTone} from '@/config/preferences'; + const adjectives = [ 'brisk', 'swift', @@ -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}.`; +}; diff --git a/source/utils/prompt-builder.spec.ts b/source/utils/prompt-builder.spec.ts index d033e1718..c43e0a320 100644 --- a/source/utils/prompt-builder.spec.ts +++ b/source/utils/prompt-builder.spec.ts @@ -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, @@ -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')); +}); diff --git a/source/utils/prompt-builder.ts b/source/utils/prompt-builder.ts index 4ade9c49e..e1b2c0f49 100644 --- a/source/utils/prompt-builder.ts +++ b/source/utils/prompt-builder.ts @@ -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'; @@ -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));