feat: add streamer mode for privacy during streaming
CI / Lint & Test (pull_request) Failing after 5m40s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 49s

- Add quick toggle button in InputBar for easy access
- Mask API keys in settings when streamer mode active
- Optional path masking to hide usernames in file paths
- Visual LIVE indicator in both InputBar and StatusBar
- Keyboard shortcut Ctrl+Shift+S for quick toggle
- Privacy section in settings for additional options

Closes #35
This commit is contained in:
2026-01-25 18:15:47 -08:00
committed by Naomi Carrigan
parent 0ac52c8c8d
commit ff50b28641
7 changed files with 235 additions and 42 deletions
+10
View File
@@ -73,6 +73,12 @@ pub struct HikariConfig {
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)]
pub streamer_mode: bool,
#[serde(default)]
pub streamer_hide_paths: bool,
}
impl Default for HikariConfig {
@@ -93,6 +99,8 @@ impl Default for HikariConfig {
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
}
}
}
@@ -147,6 +155,8 @@ mod tests {
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths);
}
#[test]
+93 -39
View File
@@ -27,6 +27,8 @@
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
});
let isOpen = $state(false);
@@ -187,47 +189,60 @@
<div class="mb-4">
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
{#if config.streamer_mode}
<span class="text-yellow-500 text-xs ml-2">🔒 Hidden (Streamer Mode)</span>
{/if}
</label>
<div class="relative">
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
{#if config.streamer_mode}
<input
id="api-key"
type="password"
value="••••••••••••••••••••••••"
disabled
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-tertiary)] focus:outline-none cursor-not-allowed"
/>
{:else}
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
{/if}
</div>
</div>
@@ -519,6 +534,45 @@
</div>
</section>
<!-- Privacy / Streamer Mode Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Privacy / Streamer Mode
</h3>
<!-- Streamer Mode Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_mode}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Enable streamer mode</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle)
</p>
</div>
<!-- Hide Paths Toggle -->
{#if config.streamer_mode}
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_hide_paths}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Also hide file paths</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Mask directory paths (e.g., /home/user → /home/****)
</p>
</div>
{/if}
</section>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
+74
View File
@@ -25,6 +25,7 @@
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
@@ -46,6 +47,11 @@
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
// Input history state
let inputHistory = $state<string[]>([]);
@@ -765,6 +771,34 @@ User: ${formattedMessage}`;
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
<button
type="button"
onclick={() => configStore.toggleStreamerMode()}
class="control-button streamer-toggle"
class:streamer-active={streamerModeActive}
title="Toggle Streamer Mode (Ctrl+Shift+S)"
>
{#if streamerModeActive}
<div class="live-indicator"></div>
<span>LIVE</span>
{:else}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
<span>Stream</span>
{/if}
</button>
<button
type="button"
onclick={() => (showQuickActions = true)}
@@ -992,6 +1026,46 @@ User: ${formattedMessage}`;
transform: scale(0.95);
}
.streamer-toggle.streamer-active {
background: rgba(239, 68, 68, 0.2);
border-color: rgb(239, 68, 68);
color: rgb(248, 113, 113);
animation: pulse-red 2s ease-in-out infinite;
}
.streamer-toggle.streamer-active:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgb(248, 113, 113);
}
.live-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-red {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.input-row {
display: flex;
gap: 12px;
+11 -1
View File
@@ -11,7 +11,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
@@ -54,6 +54,13 @@
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
});
let streamerModeActive = $state(false);
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
onMount(async () => {
@@ -207,6 +214,9 @@
</div>
<div class="flex items-center gap-3">
{#if streamerModeActive}
<div class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse" title="Streamer mode active (Ctrl+Shift+S to toggle)"></div>
{/if}
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 icon-trans-hover relative"
+8 -2
View File
@@ -6,6 +6,7 @@
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
@@ -18,6 +19,11 @@
currentSearchQuery = value;
});
let hidePaths = false;
shouldHidePaths.subscribe((value) => {
hidePaths = value;
});
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
@@ -188,9 +194,9 @@
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
<Markdown content={maskPaths(line.content, hidePaths)} searchQuery={currentSearchQuery} />
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
<HighlightedText content={maskPaths(line.content, hidePaths)} searchQuery={currentSearchQuery} />
{/if}
</div>
{/each}
+32
View File
@@ -19,6 +19,8 @@ export interface HikariConfig {
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
streamer_mode: boolean;
streamer_hide_paths: boolean;
}
const defaultConfig: HikariConfig = {
@@ -37,6 +39,8 @@ const defaultConfig: HikariConfig = {
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
};
function createConfigStore() {
@@ -145,6 +149,12 @@ function createConfigStore() {
config.subscribe((c) => (currentConfig = c))();
return currentConfig;
},
toggleStreamerMode: async () => {
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
},
};
}
@@ -174,3 +184,25 @@ export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode);
export const shouldHidePaths = derived(
configStore.config,
($config) => $config.streamer_mode && $config.streamer_hide_paths
);
/**
* Masks file paths in text when streamer mode with hide paths is enabled.
* Replaces username portion of paths with asterisks.
*/
export function maskPaths(text: string, hidePaths: boolean): string {
if (!hidePaths) return text;
// Match Unix paths like /home/username/... or /Users/username/...
// and Windows paths like C:\Users\username\...
return text
.replace(/\/home\/([^\/\s]+)/g, "/home/****")
.replace(/\/Users\/([^\/\s]+)/g, "/Users/****")
.replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****")
.replace(/~\//g, "****/");
}
+7
View File
@@ -149,6 +149,13 @@
configStore.resetFontSize();
return;
}
// Ctrl+Shift+S - Toggle streamer mode
if (event.ctrlKey && event.shiftKey && event.key === "S") {
event.preventDefault();
configStore.toggleStreamerMode();
return;
}
}
async function handleInterrupt() {