generated from nhcarrigan/template
a4e6788573
## 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>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { get } from "svelte/store";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { claudeStore } from "$lib/stores/claude";
|
|
import { characterState } from "$lib/stores/character";
|
|
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
|
import { searchState } from "$lib/stores/search";
|
|
import { conversationsStore } from "$lib/stores/conversations";
|
|
import { configStore } from "$lib/stores/config";
|
|
|
|
export interface SlashCommand {
|
|
name: string;
|
|
description: string;
|
|
usage: string;
|
|
execute: (args: string) => Promise<void> | void;
|
|
}
|
|
|
|
async function changeDirectory(path: string): Promise<void> {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
claudeStore.addLine("error", "No active conversation");
|
|
return;
|
|
}
|
|
|
|
if (!path.trim()) {
|
|
const currentDir = get(claudeStore.currentWorkingDirectory);
|
|
claudeStore.addLine("system", `Current directory: ${currentDir}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
characterState.setState("thinking");
|
|
claudeStore.addLine("system", `Changing directory to: ${path}`);
|
|
|
|
const currentDir = get(claudeStore.currentWorkingDirectory);
|
|
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
|
|
|
|
// Capture conversation history before disconnecting
|
|
const conversationHistory = claudeStore.getConversationHistory();
|
|
|
|
// Get currently granted tools and config auto-granted tools
|
|
const activeConversation = get(conversationsStore.activeConversation);
|
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
|
const config = configStore.getConfig();
|
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
|
|
|
await invoke("stop_claude", { conversationId });
|
|
|
|
// Wait for clean shutdown
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
claudeStore.setWorkingDirectory(validatedPath);
|
|
|
|
setSkipNextGreeting(true);
|
|
|
|
await invoke("start_claude", {
|
|
conversationId,
|
|
options: {
|
|
working_dir: validatedPath,
|
|
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: allAllowedTools,
|
|
use_worktree: config.use_worktree ?? false,
|
|
disable_1m_context: config.disable_1m_context ?? false,
|
|
},
|
|
});
|
|
|
|
// Update Discord RPC when reconnecting after directory change
|
|
if (activeConversation) {
|
|
await updateDiscordRpc(
|
|
activeConversation.name,
|
|
config.model || "claude",
|
|
activeConversation.startedAt
|
|
);
|
|
}
|
|
|
|
// Wait for connection to establish
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// Restore context if there was conversation history
|
|
if (conversationHistory) {
|
|
const contextMessage = `[CONTEXT RESTORATION]
|
|
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
|
|
|
|
${conversationHistory}
|
|
|
|
Please continue where we left off. You are now operating in the new directory.`;
|
|
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message: contextMessage,
|
|
});
|
|
}
|
|
|
|
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
|
|
characterState.setState("idle");
|
|
} catch (error) {
|
|
claudeStore.addLine("error", `Failed to change directory: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
}
|
|
}
|
|
|
|
async function startNewConversation(): Promise<void> {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
claudeStore.addLine("error", "No active conversation");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const workingDir = await invoke<string>("get_working_directory", {
|
|
conversationId,
|
|
});
|
|
|
|
// Get granted tools before interrupting
|
|
const activeConversation = get(conversationsStore.activeConversation);
|
|
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
|
const config = configStore.getConfig();
|
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
|
|
|
claudeStore.addLine("system", "Starting new conversation...");
|
|
characterState.setState("thinking");
|
|
|
|
await invoke("interrupt_claude", { conversationId });
|
|
|
|
claudeStore.clearTerminal();
|
|
|
|
setSkipNextGreeting(true);
|
|
|
|
await invoke("start_claude", {
|
|
conversationId,
|
|
options: {
|
|
working_dir: workingDir,
|
|
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: allAllowedTools,
|
|
use_worktree: config.use_worktree ?? false,
|
|
disable_1m_context: config.disable_1m_context ?? false,
|
|
},
|
|
});
|
|
|
|
// Update Discord RPC when starting new conversation
|
|
if (activeConversation) {
|
|
await updateDiscordRpc(
|
|
activeConversation.name,
|
|
config.model || "claude",
|
|
activeConversation.startedAt
|
|
);
|
|
}
|
|
|
|
claudeStore.addLine("system", "New conversation started!");
|
|
characterState.setState("idle");
|
|
} catch (error) {
|
|
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
}
|
|
}
|
|
|
|
export const slashCommands: SlashCommand[] = [
|
|
{
|
|
name: "cd",
|
|
description: "Change the working directory",
|
|
usage: "/cd <path>",
|
|
execute: changeDirectory,
|
|
},
|
|
{
|
|
name: "clear",
|
|
description: "Clear the terminal display (keeps conversation context)",
|
|
usage: "/clear",
|
|
execute: () => {
|
|
claudeStore.clearTerminal();
|
|
claudeStore.addLine("system", "Terminal cleared");
|
|
},
|
|
},
|
|
{
|
|
name: "new",
|
|
description: "Start a fresh conversation (resets context)",
|
|
usage: "/new",
|
|
execute: startNewConversation,
|
|
},
|
|
{
|
|
name: "help",
|
|
description: "Show available slash commands",
|
|
usage: "/help",
|
|
execute: () => {
|
|
const helpText = slashCommands
|
|
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
|
|
.join("\n");
|
|
claudeStore.addLine("system", `Available commands:\n${helpText}`);
|
|
},
|
|
},
|
|
{
|
|
name: "search",
|
|
description: "Search within the conversation (use /search to clear)",
|
|
usage: "/search [query]",
|
|
execute: (args: string) => {
|
|
if (!args.trim()) {
|
|
searchState.clear();
|
|
claudeStore.addLine("system", "Search cleared");
|
|
return;
|
|
}
|
|
searchState.setQuery(args.trim());
|
|
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
|
|
},
|
|
},
|
|
{
|
|
name: "summarise",
|
|
description: "Get a summary of the entire conversation",
|
|
usage: "/summarise",
|
|
execute: async () => {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
claudeStore.addLine("error", "No active conversation");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
claudeStore.addLine("system", "Requesting conversation summary...");
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message:
|
|
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
|
|
});
|
|
} catch (error) {
|
|
claudeStore.addLine("error", `Failed to request summary: ${error}`);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skill",
|
|
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
|
usage: "/skill [name] [data]",
|
|
execute: async (args: string) => {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
claudeStore.addLine("error", "No active conversation");
|
|
return;
|
|
}
|
|
|
|
const parts = args.trim().split(/\s+/);
|
|
const skillName = parts[0];
|
|
const skillData = parts.slice(1).join(" ");
|
|
|
|
// If no skill name provided, list available skills
|
|
if (!skillName) {
|
|
try {
|
|
const skills = await invoke<string[]>("list_skills");
|
|
if (skills.length === 0) {
|
|
claudeStore.addLine(
|
|
"system",
|
|
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
|
|
);
|
|
} else {
|
|
const skillList = skills.map((s) => ` • ${s}`).join("\n");
|
|
claudeStore.addLine(
|
|
"system",
|
|
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
claudeStore.addLine("error", `Failed to list skills: ${error}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
|
|
characterState.setState("thinking");
|
|
|
|
const message = skillData
|
|
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
|
|
: `Please run the /${skillName} skill.`;
|
|
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message,
|
|
});
|
|
} catch (error) {
|
|
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
export function parseSlashCommand(input: string): {
|
|
command: SlashCommand | null;
|
|
args: string;
|
|
} {
|
|
const trimmed = input.trim();
|
|
|
|
if (!trimmed.startsWith("/")) {
|
|
return { command: null, args: "" };
|
|
}
|
|
|
|
const parts = trimmed.slice(1).split(/\s+/);
|
|
const commandName = parts[0]?.toLowerCase();
|
|
const args = parts.slice(1).join(" ");
|
|
|
|
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
|
|
|
|
return { command: command || null, args };
|
|
}
|
|
|
|
export function getMatchingCommands(input: string): SlashCommand[] {
|
|
const trimmed = input.trim();
|
|
|
|
if (!trimmed.startsWith("/")) {
|
|
return [];
|
|
}
|
|
|
|
const partial = trimmed.slice(1).toLowerCase();
|
|
|
|
if (partial === "") {
|
|
return slashCommands;
|
|
}
|
|
|
|
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
|
|
}
|
|
|
|
export function isSlashCommand(input: string): boolean {
|
|
return input.trim().startsWith("/");
|
|
}
|