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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ docs/superpowers/
.superpowers/
*.profraw

# SearXNG container dumps upstream defaults here on each start
sandbox/search-box/searxng/settings.yml.new

2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The loader is forgiving and never crashes the app on user config:

- Missing file → defaults seeded and written. (Only fatal failure path is the seed write itself.)
- Missing fields/sections → `#[serde(default)]` fills from compiled defaults.
- Empty/whitespace strings → replaced with compiled defaults.
- Empty/whitespace strings → replaced with compiled defaults. Exception: `prompt.system` is a deliberate user override; an empty value is preserved and means "send no persona" (only the slash-command appendix is composed into `resolved_system`).
- Out-of-bounds numerics → reset to default with a stderr warning.
- Unparseable TOML → file renamed `config.toml.corrupt-<unix_ts>` and a fresh defaults file written.

Expand Down
13 changes: 7 additions & 6 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ keep_warm_inactivity_minutes = 0
num_ctx = 16384

[prompt]
# Leave empty to use the built-in secretary persona.
# Thuki always appends the generated slash-command appendix at runtime,
# whether or not this field is set, so slash commands keep working.
system = ""
# The full secretary persona prompt. Seeded on first run so this file is the
# single source of truth: edit it to tune behavior. Clearing it sends only
# the slash-command appendix, which Thuki always appends at runtime so slash
# commands keep working.
system = "..."

[window]
overlay_width = 600
Expand Down Expand Up @@ -131,8 +132,8 @@ Controls the personality and instructions Thuki gives to the AI at the start of

| Constant | Default | Tunable? | Why not tunable | Bounds | Description |
| :------------------------------ | :------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `system` | `""` | Yes | — | any string | Your custom personality or instructions for the AI (for example, "You are a terse Rust expert"). Leave this empty to use Thuki's built-in secretary personality. The list of slash commands is always added on top, so `/search` etc. work either way. |
| `DEFAULT_SYSTEM_PROMPT_BASE` | `prompts/system_prompt.txt` | No | This is the fallback used when `system` is empty. To customize, set `system` instead, edit it in your `config.toml` rather than this file. | — | The built-in secretary personality Thuki uses when you have not set a custom `system` prompt. |
| `system` | full built-in body (~17 KB) | Yes | — | any string | The full secretary personality prompt. Seeded into your `config.toml` on first run so the file is the single source of truth: edit, tweak, or replace it. Clearing the field sends no persona at all. The slash-command appendix is always added on top, so `/search` etc. work either way. |
| `DEFAULT_SYSTEM_PROMPT_BASE` | `prompts/system_prompt.txt` | No | The shipped seed for `system` on first run. Once your `config.toml` exists, only the file matters; this constant is no longer consulted at runtime. | — | Source-of-truth file used to seed `system` on first run. |
| `SLASH_COMMAND_PROMPT_APPENDIX` | `prompts/generated/slash_commands.txt` | No | Auto-generated from the slash-command registry at build time. Editing by hand would desync the AI's understanding of the commands from the real ones. | — | The list of slash commands (`/search`, `/screen`, etc.) Thuki tells the AI about so it knows what each one does. Always added on top of your `system` prompt. |

### `[window]`
Expand Down
20 changes: 10 additions & 10 deletions src-tauri/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ use super::defaults::{
DEFAULT_QUOTE_MAX_DISPLAY_CHARS, DEFAULT_QUOTE_MAX_DISPLAY_LINES,
DEFAULT_READER_BATCH_TIMEOUT_S, DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL,
DEFAULT_ROUTER_TIMEOUT_S, DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS,
DEFAULT_SEARXNG_URL, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS,
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
SLASH_COMMAND_PROMPT_APPENDIX,
DEFAULT_SEARXNG_URL, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS,
DEFAULT_UPDATER_MANIFEST_URL, SLASH_COMMAND_PROMPT_APPENDIX,
};
use super::error::ConfigError;
use super::schema::AppConfig;
Expand Down Expand Up @@ -153,13 +152,12 @@ pub(crate) fn resolve(config: &mut AppConfig) {
"inference.num_ctx",
);

// Prompt section: empty base -> built-in. Compose resolved_system.
let base = if config.prompt.system.trim().is_empty() {
DEFAULT_SYSTEM_PROMPT_BASE
} else {
&config.prompt.system
};
config.prompt.resolved_system = compose_system_prompt(base, SLASH_COMMAND_PROMPT_APPENDIX);
// Prompt section: compose the user's persona with the slash-command
// appendix into the runtime-only `resolved_system`. The on-disk `system`
// string is the single source of truth; if the user has cleared it, no
// persona is sent (only the appendix).
config.prompt.resolved_system =
compose_system_prompt(&config.prompt.system, SLASH_COMMAND_PROMPT_APPENDIX);

// Window section.
clamp_f64(
Expand Down Expand Up @@ -300,6 +298,8 @@ pub fn compose_system_prompt(base: &str, appendix: &str) -> String {
let appendix = appendix.trim();
if appendix.is_empty() {
base.to_string()
} else if base.is_empty() {
appendix.to_string()
} else {
format!("{base}\n\n{appendix}")
}
Expand Down
32 changes: 20 additions & 12 deletions src-tauri/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ use super::defaults::{
DEFAULT_QUOTE_MAX_CONTEXT_LENGTH, DEFAULT_QUOTE_MAX_DISPLAY_CHARS,
DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_READER_BATCH_TIMEOUT_S,
DEFAULT_READER_PER_URL_TIMEOUT_S, DEFAULT_READER_URL, DEFAULT_ROUTER_TIMEOUT_S,
DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL, DEFAULT_TOP_K_URLS,
DEFAULT_UPDATER_AUTO_CHECK, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
DEFAULT_SEARCH_TIMEOUT_S, DEFAULT_SEARXNG_MAX_RESULTS, DEFAULT_SEARXNG_URL,
DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TOP_K_URLS, DEFAULT_UPDATER_AUTO_CHECK,
DEFAULT_UPDATER_CHECK_INTERVAL_HOURS, DEFAULT_UPDATER_MANIFEST_URL,
};

/// Static, user-tunable inference daemon configuration.
Expand Down Expand Up @@ -61,25 +62,32 @@ impl Default for InferenceSection {
}
}

/// Prompt configuration. `system` holds only the user-editable base text.
/// The slash-command appendix is composed at load time into `resolved_system`
/// and is never written back to the file. `resolved_system` is computed, not
/// serialized.
///
/// Note: `#[derive(Default)]` is correct here because both fields genuinely
/// start empty: `system` empty means "use the built-in persona", and
/// `resolved_system` is populated by the loader before any consumer reads it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
/// Prompt configuration. `system` holds the user-editable persona prompt; on
/// first run it is seeded with the full built-in body so the file is the
/// single source of truth. The slash-command appendix is composed at load
/// time into `resolved_system` and is never written back to the file.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct PromptSection {
/// User-editable persona prompt. Empty means "use the built-in default".
/// User-editable persona prompt. Seeded with the built-in body and
/// freely editable thereafter. If the user clears it, no persona is
/// sent (only the slash-command appendix).
pub system: String,
/// Composed runtime value (base prompt plus slash-command appendix).
/// Not serialized; computed by the loader.
#[serde(skip)]
pub resolved_system: String,
}

impl Default for PromptSection {
fn default() -> Self {
Self {
system: DEFAULT_SYSTEM_PROMPT_BASE.to_string(),
resolved_system: String::new(),
}
}
}

/// Overlay UI configuration. Holds window geometry and input attachment
/// limits. The collapsed-bar height and the close-animation deadline are
/// baked into the frontend (see `App.tsx`) because their effective range is
Expand Down
33 changes: 24 additions & 9 deletions src-tauri/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fn defaults_const_values_match_schema_defaults() {
DEFAULT_KEEP_WARM_INACTIVITY_MINUTES
);
assert_eq!(c.inference.num_ctx, DEFAULT_NUM_CTX);
assert_eq!(c.prompt.system, "");
assert_eq!(c.prompt.system, DEFAULT_SYSTEM_PROMPT_BASE);
assert_eq!(c.prompt.resolved_system, "");
assert_eq!(c.window.overlay_width, DEFAULT_OVERLAY_WIDTH);
assert_eq!(c.window.max_chat_height, DEFAULT_MAX_CHAT_HEIGHT);
Expand Down Expand Up @@ -96,7 +96,7 @@ fn section_defaults_are_sensible() {
assert_eq!(m.ollama_url, DEFAULT_OLLAMA_URL);

let p = PromptSection::default();
assert!(p.system.is_empty());
assert_eq!(p.system, DEFAULT_SYSTEM_PROMPT_BASE);

let w = WindowSection::default();
assert_eq!(w.overlay_width, DEFAULT_OVERLAY_WIDTH);
Expand Down Expand Up @@ -161,6 +161,18 @@ fn compose_system_prompt_skips_appendix_when_totally_empty() {
assert_eq!(got, "hello");
}

#[test]
fn compose_system_prompt_returns_appendix_only_when_base_empty() {
let got = compose_system_prompt("", "world");
assert_eq!(got, "world");
}

#[test]
fn compose_system_prompt_returns_appendix_only_when_base_whitespace() {
let got = compose_system_prompt(" \n\t", "world");
assert_eq!(got, "world");
}

// ── loader: first run (file missing) ────────────────────────────────────────

#[test]
Expand Down Expand Up @@ -497,26 +509,29 @@ fn resolve_empty_ollama_url_falls_back() {
}

#[test]
fn resolve_empty_system_prompt_uses_built_in_base_plus_appendix() {
fn resolve_empty_system_prompt_keeps_only_appendix() {
// The user has explicitly cleared their persona; resolved_system contains
// the slash-command appendix only. Built-in persona is no longer auto
// re-injected, so the on-disk file remains the single source of truth.
let dir = fresh_temp_dir();
let path = config_path_in(&dir);
std::fs::write(
&path,
r#"
[prompt]
system = " "
system = ""
"#,
)
.unwrap();
let config = load_from_path(&path).unwrap();
assert!(config
assert_eq!(
config.prompt.resolved_system,
SLASH_COMMAND_PROMPT_APPENDIX.trim()
);
assert!(!config
.prompt
.resolved_system
.contains(DEFAULT_SYSTEM_PROMPT_BASE.trim()));
assert!(config
.prompt
.resolved_system
.contains(SLASH_COMMAND_PROMPT_APPENDIX.trim()));
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion src/settings/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,14 @@ export function Textarea({
placeholder,
maxLength,
ariaLabel,
rows = 4,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
maxLength?: number;
ariaLabel?: string;
rows?: number;
}) {
return (
<textarea
Expand All @@ -152,7 +154,7 @@ export function Textarea({
placeholder={placeholder}
maxLength={maxLength}
aria-label={ariaLabel}
rows={4}
rows={rows}
spellCheck={false}
/>
);
Expand Down
10 changes: 8 additions & 2 deletions src/settings/tabs/ModelTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ interface ModelTabProps {
onSaved: (next: RawAppConfig) => void;
}

const PROMPT_MAX_CHARS = 8000;
/// Built-in prompt body is ~17 KB; cap roomy so users can edit without truncation.
const PROMPT_MAX_CHARS = 32000;
/// Default textarea height for the system prompt: large enough to show a
/// meaningful slice of the seeded built-in body without forcing the user to
/// drag the resize grip on first open.
const PROMPT_TEXTAREA_ROWS = 16;
const EJECT_RESET_MS = 2500;
/// Approximate tokens per chat turn used for the "~N turns of context" hint.
/// 400 tokens ≈ a typical user question + assistant reply pair on this app.
Expand Down Expand Up @@ -441,9 +446,10 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {
<Textarea
value={value}
onChange={setValue}
placeholder="Use built-in secretary persona…"
placeholder="Persona prompt…"
maxLength={PROMPT_MAX_CHARS}
ariaLabel="System prompt"
rows={PROMPT_TEXTAREA_ROWS}
/>
<div className={styles.charCounter}>
{value.length} / {PROMPT_MAX_CHARS}
Expand Down
39 changes: 38 additions & 1 deletion src/settings/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,44 @@ describe('ModelTab', () => {

it('renders the live char counter for the prompt textarea', async () => {
await renderModelTab();
expect(screen.getByText(/5 \/ 8000/)).toBeInTheDocument();
expect(screen.getByText(/5 \/ 32000/)).toBeInTheDocument();
});

it('renders the prompt textarea with the configured persona text and a tall default size', async () => {
await renderModelTab();
const ta = screen.getByRole('textbox', {
name: 'System prompt',
}) as HTMLTextAreaElement;
expect(ta.value).toBe('hello');
// Default rows must be larger than the generic 4-row Textarea so the
// seeded built-in prompt body is visible without manual resizing.
expect(ta.rows).toBeGreaterThanOrEqual(8);
});

it('typing into the prompt textarea schedules a save with the typed text', async () => {
vi.useFakeTimers();
let savedValue: unknown = undefined;
invokeMock.mockImplementation((cmd: string, args?: unknown) => {
if (cmd === 'get_loaded_model') return Promise.resolve(null);
if (cmd === 'set_config_field') {
savedValue = (args as { value: unknown }).value;
return Promise.resolve(CONFIG);
}
return Promise.resolve(CONFIG);
});
render(<ModelTab config={CONFIG} resyncToken={0} onSaved={() => {}} />);
await act(async () => {
await Promise.resolve();
});
const ta = screen.getByRole('textbox', {
name: 'System prompt',
}) as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: 'new prompt body' } });
await act(async () => {
vi.advanceTimersByTime(300);
await Promise.resolve();
});
expect(savedValue).toBe('new prompt body');
});

it('renders the Keep Warm section with Release after input and Unload now button', async () => {
Expand Down