generated from nhcarrigan/template
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>
This commit was merged in pull request #159.
This commit is contained in:
@@ -318,6 +318,16 @@
|
||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Last assistant message snippet -->
|
||||
{#if agent.lastAssistantMessage}
|
||||
<p
|
||||
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
||||
title={agent.lastAssistantMessage}
|
||||
>
|
||||
{agent.lastAssistantMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -2,15 +2,43 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let version = $state("Loading...");
|
||||
const SUPPORTED_CLI_VERSION = "2.1.50";
|
||||
|
||||
let installedVersion = $state("Loading...");
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aVal = aParts[i] ?? 0;
|
||||
const bVal = bParts[i] ?? 0;
|
||||
if (aVal > bVal) return 1;
|
||||
if (aVal < bVal) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let displayVersion = $derived(installedVersion.split(" (")[0]);
|
||||
|
||||
let supportedBadgeState = $derived.by(() => {
|
||||
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return "neutral";
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return "neutral";
|
||||
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
|
||||
if (cmp > 0) return "ahead";
|
||||
if (cmp < 0) return "behind";
|
||||
return "current";
|
||||
});
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("get_claude_version");
|
||||
version = result;
|
||||
installedVersion = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to get Claude CLI version:", error);
|
||||
version = "Unknown";
|
||||
installedVersion = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,25 +47,60 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cli-version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {version}</span>
|
||||
<div class="cli-versions">
|
||||
<div class="cli-version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {displayVersion}</span>
|
||||
</div>
|
||||
|
||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
|
||||
</div>
|
||||
|
||||
{#if supportedBadgeState === "ahead"}
|
||||
<span class="version-warning ahead"
|
||||
>Your version is newer, some features may not be supported</span
|
||||
>
|
||||
{:else if supportedBadgeState === "behind"}
|
||||
<span class="version-warning behind"
|
||||
>Your version is out of date, please update to ensure compatibility</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cli-versions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cli-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -57,6 +120,21 @@
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.cli-version.supported.current {
|
||||
border-color: var(--success-color, #4caf50);
|
||||
color: var(--success-color, #4caf50);
|
||||
}
|
||||
|
||||
.cli-version.supported.ahead {
|
||||
border-color: var(--warning-color, #ff9800);
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.cli-version.supported.behind {
|
||||
border-color: var(--error-color, #f44336);
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
@@ -65,4 +143,18 @@
|
||||
.version-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-warning {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-warning.ahead {
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.version-warning.behind {
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
} from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import CostSummary from "./CostSummary.svelte";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
@@ -52,10 +53,26 @@
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
|
||||
interface AuthStatus {
|
||||
is_logged_in: boolean;
|
||||
email: string | null;
|
||||
org_name: string | null;
|
||||
api_key_source: string | null;
|
||||
api_provider: string | null;
|
||||
subscription_type: string | null;
|
||||
}
|
||||
|
||||
let authStatus: AuthStatus | null = $state(null);
|
||||
let authLoading = $state(false);
|
||||
let authActionLoading = $state(false);
|
||||
let authError: string | null = $state(null);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let saveError: string | null = $state(null);
|
||||
@@ -69,6 +86,9 @@
|
||||
|
||||
configStore.isSidebarOpen.subscribe((open) => {
|
||||
isOpen = open;
|
||||
if (open && authStatus === null) {
|
||||
void refreshAuthStatus();
|
||||
}
|
||||
});
|
||||
|
||||
configStore.saveError.subscribe((error) => {
|
||||
@@ -111,6 +131,44 @@
|
||||
"Task",
|
||||
];
|
||||
|
||||
async function refreshAuthStatus() {
|
||||
authLoading = true;
|
||||
authError = null;
|
||||
try {
|
||||
authStatus = await invoke<AuthStatus>("get_auth_status");
|
||||
} catch (e) {
|
||||
authError = String(e);
|
||||
} finally {
|
||||
authLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthLogin() {
|
||||
authActionLoading = true;
|
||||
authError = null;
|
||||
try {
|
||||
await invoke<string>("auth_login");
|
||||
await refreshAuthStatus();
|
||||
} catch (e) {
|
||||
authError = String(e);
|
||||
} finally {
|
||||
authActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthLogout() {
|
||||
authActionLoading = true;
|
||||
authError = null;
|
||||
try {
|
||||
await invoke<string>("auth_logout");
|
||||
await refreshAuthStatus();
|
||||
} catch (e) {
|
||||
authError = String(e);
|
||||
} finally {
|
||||
authActionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
@@ -228,6 +286,101 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Account Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
Account
|
||||
</h3>
|
||||
|
||||
{#if authLoading}
|
||||
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
|
||||
{:else if authStatus}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'}"
|
||||
></span>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">
|
||||
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
|
||||
</span>
|
||||
</div>
|
||||
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
|
||||
<dl class="text-xs space-y-1 mb-3">
|
||||
{#if authStatus.email}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
|
||||
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.org_name}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
|
||||
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.api_key_source}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.subscription_type}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
|
||||
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
|
||||
<dd class="text-[var(--text-primary)]">
|
||||
{#if config.api_key}
|
||||
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
|
||||
{:else}
|
||||
None
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
|
||||
{/if}
|
||||
|
||||
{#if authError}
|
||||
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
|
||||
{authError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={refreshAuthStatus}
|
||||
disabled={authLoading || authActionLoading}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{#if authStatus && !authStatus.is_logged_in}
|
||||
<button
|
||||
onclick={handleAuthLogin}
|
||||
disabled={authActionLoading}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{authActionLoading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
{:else if authStatus && authStatus.is_logged_in}
|
||||
<button
|
||||
onclick={handleAuthLogout}
|
||||
disabled={authActionLoading}
|
||||
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{authActionLoading ? "Logging out..." : "Logout"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Agent Settings Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
@@ -322,6 +475,37 @@
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Worktree Isolation -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.use_worktree}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Disable 1M Context Window -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.disable_1m_context}
|
||||
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
|
||||
</label>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
|
||||
context window
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Greeting Section -->
|
||||
|
||||
@@ -362,6 +362,8 @@ User: ${formattedMessage}`;
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: newGrantedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -178,6 +180,8 @@
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -289,6 +293,8 @@
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
@@ -92,6 +94,14 @@
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
case "rate-limit":
|
||||
return "terminal-rate-limit";
|
||||
case "compact-prompt":
|
||||
return "terminal-compact-prompt";
|
||||
case "worktree":
|
||||
return "terminal-worktree";
|
||||
case "config-change":
|
||||
return "terminal-config-change";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
@@ -109,6 +119,12 @@
|
||||
return "[tool]";
|
||||
case "error":
|
||||
return "[error]";
|
||||
case "rate-limit":
|
||||
return "[rate-limit]";
|
||||
case "worktree":
|
||||
return "[worktree]";
|
||||
case "config-change":
|
||||
return "[config]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -187,6 +203,27 @@
|
||||
copiedMessageId = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function handleCompact() {
|
||||
if (!currentConversationId) return;
|
||||
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||
}
|
||||
|
||||
// Collapsible tool lines
|
||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||
let expandedToolLines: Record<string, boolean> = {};
|
||||
|
||||
function isToolContentLong(content: string): boolean {
|
||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||
}
|
||||
|
||||
function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
function toggleToolLine(id: string) {
|
||||
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -262,7 +299,11 @@
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
{#if line.type === "compact-prompt"}
|
||||
<button class="compact-action-btn" onclick={handleCompact}>
|
||||
⚡ Compact Conversation
|
||||
</button>
|
||||
{:else if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
@@ -289,6 +330,22 @@
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
|
||||
<span class="tool-collapsible">
|
||||
<HighlightedText
|
||||
content={expandedToolLines[line.id]
|
||||
? maskPaths(line.content, hidePaths)
|
||||
: truncateToolContent(maskPaths(line.content, hidePaths))}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="tool-toggle-btn"
|
||||
onclick={() => toggleToolLine(line.id)}
|
||||
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
|
||||
>
|
||||
{expandedToolLines[line.id] ? "▲" : "▼"}
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
@@ -329,6 +386,42 @@
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-rate-limit {
|
||||
color: var(--terminal-rate-limit, #fb923c);
|
||||
}
|
||||
|
||||
.terminal-compact-prompt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-worktree {
|
||||
color: var(--terminal-worktree, #34d399);
|
||||
}
|
||||
|
||||
.terminal-config-change {
|
||||
color: var(--terminal-config-change, #a78bfa);
|
||||
}
|
||||
|
||||
.compact-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--terminal-error, #f87171);
|
||||
color: var(--terminal-error, #f87171);
|
||||
padding: 0.3em 0.8em;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.compact-action-btn:hover {
|
||||
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-default {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -408,4 +501,28 @@
|
||||
.terminal-line {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-collapsible {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.tool-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
cursor: pointer;
|
||||
font-size: 0.7em;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tool-toggle-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Terminal Component Tests
|
||||
*
|
||||
* Tests the pure helper functions extracted from the Terminal component:
|
||||
* - getLineClass: maps line types to CSS class names
|
||||
* - getLinePrefix: maps line types to display prefixes
|
||||
* - formatTime: formats a Date as "HH:MM AM/PM"
|
||||
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
||||
* - truncateToolContent: truncates long tool content with ellipsis
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] rate-limit lines appear in amber
|
||||
* - [ ] error lines appear in red
|
||||
* - [ ] tool lines appear in purple
|
||||
* - [ ] system lines appear in grey italic
|
||||
* - [ ] user lines appear in cyan
|
||||
* - [ ] assistant lines appear in primary text colour
|
||||
* - [ ] long tool content is collapsed by default with a toggle button
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror functions from Terminal.svelte for isolated testing
|
||||
|
||||
function getLineClass(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "terminal-user";
|
||||
case "assistant":
|
||||
return "terminal-assistant";
|
||||
case "system":
|
||||
return "terminal-system italic";
|
||||
case "tool":
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
case "rate-limit":
|
||||
return "terminal-rate-limit";
|
||||
case "compact-prompt":
|
||||
return "terminal-compact-prompt";
|
||||
case "worktree":
|
||||
return "terminal-worktree";
|
||||
case "config-change":
|
||||
return "terminal-config-change";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
}
|
||||
|
||||
function getLinePrefix(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return ">";
|
||||
case "assistant":
|
||||
return "";
|
||||
case "system":
|
||||
return "[system]";
|
||||
case "tool":
|
||||
return "[tool]";
|
||||
case "error":
|
||||
return "[error]";
|
||||
case "rate-limit":
|
||||
return "[rate-limit]";
|
||||
case "worktree":
|
||||
return "[worktree]";
|
||||
case "config-change":
|
||||
return "[config]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||
|
||||
function isToolContentLong(content: string): boolean {
|
||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||
}
|
||||
|
||||
function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getLineClass", () => {
|
||||
it("returns terminal-user for user lines", () => {
|
||||
expect(getLineClass("user")).toBe("terminal-user");
|
||||
});
|
||||
|
||||
it("returns terminal-assistant for assistant lines", () => {
|
||||
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
||||
});
|
||||
|
||||
it("returns terminal-system italic for system lines", () => {
|
||||
expect(getLineClass("system")).toBe("terminal-system italic");
|
||||
});
|
||||
|
||||
it("returns terminal-tool for tool lines", () => {
|
||||
expect(getLineClass("tool")).toBe("terminal-tool");
|
||||
});
|
||||
|
||||
it("returns terminal-error for error lines", () => {
|
||||
expect(getLineClass("error")).toBe("terminal-error");
|
||||
});
|
||||
|
||||
it("returns terminal-thinking for thinking lines", () => {
|
||||
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
||||
});
|
||||
|
||||
it("returns terminal-rate-limit for rate-limit lines", () => {
|
||||
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
||||
});
|
||||
|
||||
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
||||
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
||||
});
|
||||
|
||||
it("returns terminal-worktree for worktree lines", () => {
|
||||
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
||||
});
|
||||
|
||||
it("returns terminal-config-change for config-change lines", () => {
|
||||
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
||||
});
|
||||
|
||||
it("returns terminal-default for unknown line types", () => {
|
||||
expect(getLineClass("unknown")).toBe("terminal-default");
|
||||
expect(getLineClass("")).toBe("terminal-default");
|
||||
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLinePrefix", () => {
|
||||
it("returns > for user lines", () => {
|
||||
expect(getLinePrefix("user")).toBe(">");
|
||||
});
|
||||
|
||||
it("returns empty string for assistant lines", () => {
|
||||
expect(getLinePrefix("assistant")).toBe("");
|
||||
});
|
||||
|
||||
it("returns [system] for system lines", () => {
|
||||
expect(getLinePrefix("system")).toBe("[system]");
|
||||
});
|
||||
|
||||
it("returns [tool] for tool lines", () => {
|
||||
expect(getLinePrefix("tool")).toBe("[tool]");
|
||||
});
|
||||
|
||||
it("returns [error] for error lines", () => {
|
||||
expect(getLinePrefix("error")).toBe("[error]");
|
||||
});
|
||||
|
||||
it("returns [rate-limit] for rate-limit lines", () => {
|
||||
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
||||
});
|
||||
|
||||
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
||||
expect(getLinePrefix("compact-prompt")).toBe("");
|
||||
});
|
||||
|
||||
it("returns [worktree] for worktree lines", () => {
|
||||
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
||||
});
|
||||
|
||||
it("returns [config] for config-change lines", () => {
|
||||
expect(getLinePrefix("config-change")).toBe("[config]");
|
||||
});
|
||||
|
||||
it("returns empty string for thinking lines (no prefix)", () => {
|
||||
expect(getLinePrefix("thinking")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for unknown line types", () => {
|
||||
expect(getLinePrefix("unknown")).toBe("");
|
||||
expect(getLinePrefix("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats time in 12-hour format with AM/PM", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
||||
});
|
||||
|
||||
it("formats afternoon times correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("02:35");
|
||||
expect(formatted.toUpperCase()).toContain("PM");
|
||||
});
|
||||
|
||||
it("formats morning times correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 9, 5);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("09:05");
|
||||
expect(formatted.toUpperCase()).toContain("AM");
|
||||
});
|
||||
|
||||
it("formats midnight correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 0, 0);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("12:00");
|
||||
expect(formatted.toUpperCase()).toContain("AM");
|
||||
});
|
||||
|
||||
it("formats noon correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 12, 0);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("12:00");
|
||||
expect(formatted.toUpperCase()).toContain("PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolContentLong", () => {
|
||||
it("returns false for content at or below the threshold", () => {
|
||||
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
||||
expect(isToolContentLong(exactThreshold)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for content exceeding the threshold", () => {
|
||||
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
||||
expect(isToolContentLong(overThreshold)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for short content", () => {
|
||||
expect(isToolContentLong("short")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty content", () => {
|
||||
expect(isToolContentLong("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateToolContent", () => {
|
||||
it("truncates content to the threshold length with an ellipsis", () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = truncateToolContent(long);
|
||||
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
||||
});
|
||||
|
||||
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
||||
const short = "hello";
|
||||
const result = truncateToolContent(short);
|
||||
expect(result).toBe("hello…");
|
||||
});
|
||||
|
||||
it("uses the unicode ellipsis character (not three dots)", () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = truncateToolContent(long);
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
expect(result.endsWith("...")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,8 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user