generated from nhcarrigan/template
feat: add streamer mode for privacy during streaming
- 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:
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, "****/");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user