Files
hikari-desktop/src/lib/components/UserQuestionModal.svelte
T
hikari a4e6788573
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
feat: stuffy feature bundle (#159)
## Summary

This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit.

- **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157
- **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153
- **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50)
- **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155
- **"Prompt is too long" handling** — Detects this error in assistant messages and shows a  Compact Conversation button to send `/compact` directly. Closes #158
- **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156
- **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150
- **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151
- **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154

## Test plan

- [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green
- [ ] Auth status: open settings and verify the Account section shows correct login info
- [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches
- [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly
- [ ] Compact button: unit tests cover detection; button renders correctly in terminal
- [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet
- [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args
- [ ] ConfigChange: hook events display as `[config]` lines rather than errors
- [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ`

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #159
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-24 20:48:49 -08:00

288 lines
9.6 KiB
Svelte

<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { SvelteSet } from "svelte/reactivity";
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { UserQuestionEvent } from "$lib/types/messages";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let isVisible = $state(false);
let question: UserQuestionEvent | null = $state(null);
let selectedOptions: SvelteSet<string> = new SvelteSet();
let customAnswer = $state("");
let showCustomInput = $state(false);
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
hasQuestionPending.subscribe((pending) => {
isVisible = pending;
if (!pending) {
selectedOptions.clear();
customAnswer = "";
showCustomInput = false;
}
});
claudeStore.pendingQuestion.subscribe((q) => {
question = q;
if (q) {
characterState.setState("permission");
}
});
claudeStore.grantedTools.subscribe((tools) => {
grantedToolsList = Array.from(tools);
});
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDirectory = dir;
});
function toggleOption(label: string) {
if (!question) return;
if (question.multi_select) {
if (selectedOptions.has(label)) {
selectedOptions.delete(label);
} else {
selectedOptions.add(label);
}
} else {
selectedOptions.clear();
selectedOptions.add(label);
}
showCustomInput = false;
}
function selectCustom() {
showCustomInput = true;
selectedOptions.clear();
}
async function handleSubmitAndReconnect() {
if (!question) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
let answerText: string;
if (showCustomInput && customAnswer.trim()) {
answerText = customAnswer.trim();
} else if (selectedOptions.size > 0) {
if (question.multi_select) {
answerText = Array.from(selectedOptions).join(", ");
} else {
answerText = Array.from(selectedOptions)[0];
}
} else {
return;
}
const questionText = question.question;
const conversationHistory = claudeStore.getConversationHistory();
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
claudeStore.clearQuestion();
try {
// Prevent stats reset on reconnection
setSkipNextGreeting(true);
await invoke("stop_claude", { conversationId });
await new Promise((resolve) => setTimeout(resolve, 500));
const config = configStore.getConfig();
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
model: config.model || null,
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
// Update Discord RPC when reconnecting after answering question
const activeConversation = get(conversationsStore.activeConversation);
if (activeConversation) {
await updateDiscordRpc(
activeConversation.name,
config.model || "claude",
activeConversation.startedAt
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just answered your question. Here's our conversation so far:
${conversationHistory}
You asked me: "${questionText}"
My answer: "${answerText}"
Please continue where we left off, taking my answer into account.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
characterState.setTemporaryState("success", 2000);
} catch (error) {
console.error("Failed to reconnect:", error);
claudeStore.addLine("error", `Reconnect failed: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
function handleDismiss() {
claudeStore.clearQuestion();
claudeStore.addLine("system", "Question dismissed");
characterState.setTemporaryState("idle", 1000);
}
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !question) return;
if (event.key === "Enter" && !showCustomInput) {
event.preventDefault();
if (selectedOptions.size > 0) {
handleSubmitAndReconnect();
}
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
function canSubmit(): boolean {
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && question}
<div
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
>
<div
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<span class="text-xl">?</span>
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
{question.header || "Question"}
</h2>
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
</div>
</div>
<div class="mb-4">
<p class="text-[var(--text-primary)]">{question.question}</p>
</div>
<div class="mb-4 space-y-2">
{#each question.options as option (option.label)}
<button
onclick={() => toggleOption(option.label)}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
option.label
)
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
? 'sm'
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
option.label
)
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if selectedOptions.has(option.label)}
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">{option.label}</div>
{#if option.description}
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
{/if}
</div>
</div>
</button>
{/each}
<button
onclick={selectCustom}
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
: 'border-[var(--text-secondary)]'}"
>
{#if showCustomInput}
<span class="text-white text-xs">x</span>
{/if}
</div>
<div class="flex-1">
<div class="font-medium">Other</div>
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
</div>
</div>
</button>
</div>
{#if showCustomInput}
<div class="mb-4">
<textarea
bind:value={customAnswer}
placeholder="Type your answer here..."
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
rows="3"
></textarea>
</div>
{/if}
<div class="flex gap-3">
<button
onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
>
Dismiss
</button>
<button
onclick={handleSubmitAndReconnect}
disabled={!canSubmit()}
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Answer & Reconnect
</button>
</div>
</div>
</div>
{/if}