From c333fcec32b1f90bf0da6bb14d2609c20e38a74f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 22:21:55 +0900 Subject: style: switch from tabs to 2-space indentation (incl. svelte) --- src/features/chat/index.ts | 28 +- src/features/chat/model-select.test.ts | 88 +- src/features/chat/model-select.ts | 44 +- src/features/chat/ports.ts | 24 +- src/features/chat/reasoning-effort.test.ts | 68 +- src/features/chat/reasoning-effort.ts | 32 +- src/features/chat/store.svelte.ts | 724 ++--- src/features/chat/store.test.ts | 3060 ++++++++++---------- src/features/chat/test-helpers.ts | 228 +- src/features/chat/ui.test.ts | 1526 +++++----- src/features/chat/ui/ChatView.svelte | 611 ++-- src/features/chat/ui/CompactionView.svelte | 278 +- src/features/chat/ui/Composer.svelte | 358 +-- src/features/chat/ui/ModelSelector.svelte | 82 +- .../chat/ui/ReasoningEffortSelector.svelte | 130 +- 15 files changed, 3667 insertions(+), 3614 deletions(-) (limited to 'src/features/chat') diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index c120916..ddb094d 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,23 +1,23 @@ export type { - ProviderRetryView, - RenderedChunk, - RenderGroup, - ToolBatchEntry, + ProviderRetryView, + RenderedChunk, + RenderGroup, + ToolBatchEntry, } from "../../core/chunks"; export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { - EffortOption, - ReasoningEffortSaveResult, - SaveReasoningEffort, + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, } from "./reasoning-effort"; export { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -30,6 +30,6 @@ export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "chat", - description: "Conversation turns, composer, model selector, and metrics", + name: "chat", + description: "Conversation turns, composer, model selector, and metrics", } as const; diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 109cae1..deb673d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select"; describe("splitModelName", () => { - it("splits on the first slash", () => { - expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); - }); - - it("keeps slashes in the model part (splits only the first)", () => { - expect(splitModelName("openrouter/anthropic/claude")).toEqual({ - key: "openrouter", - model: "anthropic/claude", - }); - }); - - it("treats a slashless name as all key", () => { - expect(splitModelName("local")).toEqual({ key: "local", model: "" }); - }); + it("splits on the first slash", () => { + expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); + }); + + it("keeps slashes in the model part (splits only the first)", () => { + expect(splitModelName("openrouter/anthropic/claude")).toEqual({ + key: "openrouter", + model: "anthropic/claude", + }); + }); + + it("treats a slashless name as all key", () => { + expect(splitModelName("local")).toEqual({ key: "local", model: "" }); + }); }); describe("joinModelName", () => { - it("recombines key + model", () => { - expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); - }); - - it("returns just the key when the model is empty", () => { - expect(joinModelName("local", "")).toBe("local"); - }); - - it("round-trips with splitModelName", () => { - const full = "openrouter/anthropic/claude"; - const { key, model } = splitModelName(full); - expect(joinModelName(key, model)).toBe(full); - }); + it("recombines key + model", () => { + expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); + }); + + it("returns just the key when the model is empty", () => { + expect(joinModelName("local", "")).toBe("local"); + }); + + it("round-trips with splitModelName", () => { + const full = "openrouter/anthropic/claude"; + const { key, model } = splitModelName(full); + expect(joinModelName(key, model)).toBe(full); + }); }); describe("modelKeys", () => { - it("returns distinct keys in first-seen order", () => { - expect( - modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), - ).toEqual(["openai", "anthropic", "google"]); - }); - - it("is empty for no models", () => { - expect(modelKeys([])).toEqual([]); - }); + it("returns distinct keys in first-seen order", () => { + expect( + modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), + ).toEqual(["openai", "anthropic", "google"]); + }); + + it("is empty for no models", () => { + expect(modelKeys([])).toEqual([]); + }); }); describe("modelsForKey", () => { - it("returns the model suffixes under a key, in order", () => { - const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; - expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); - }); - - it("returns empty for an unknown key", () => { - expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); - }); + it("returns the model suffixes under a key, in order", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; + expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); + }); + + it("returns empty for an unknown key", () => { + expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); + }); }); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index b1d70b9..db41772 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -8,42 +8,42 @@ */ export interface SplitModel { - readonly key: string; - readonly model: string; + readonly key: string; + readonly model: string; } /** Split `/` on the first slash. A slashless name is all-key. */ export function splitModelName(full: string): SplitModel { - const i = full.indexOf("/"); - if (i === -1) return { key: full, model: "" }; - return { key: full.slice(0, i), model: full.slice(i + 1) }; + const i = full.indexOf("/"); + if (i === -1) return { key: full, model: "" }; + return { key: full.slice(0, i), model: full.slice(i + 1) }; } /** Recombine a key + model into a `/` name (key-only if no model). */ export function joinModelName(key: string, model: string): string { - return model === "" ? key : `${key}/${model}`; + return model === "" ? key : `${key}/${model}`; } /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const full of models) { - const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } - } - return out; + const seen = new Set(); + const out: string[] = []; + for (const full of models) { + const { key } = splitModelName(full); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; } /** The model suffixes available under a given key, in order. */ export function modelsForKey(models: readonly string[], key: string): string[] { - const out: string[] = []; - for (const full of models) { - const split = splitModelName(full); - if (split.key === key) out.push(split.model); - } - return out; + const out: string[] = []; + for (const full of models) { + const split = splitModelName(full); + if (split.key === key) out.push(split.model); + } + return out; } diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index ffe2c94..2fe10dc 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,8 +1,8 @@ import type { - ChatQueueMessage, - ChatSendMessage, - ConversationHistoryResponse, - ConversationMetricsResponse, + ChatQueueMessage, + ChatSendMessage, + ConversationHistoryResponse, + ConversationMetricsResponse, } from "@dispatch/transport-contract"; /** @@ -11,7 +11,7 @@ import type { * auto-starts a turn if idle). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage): void; } /** @@ -19,10 +19,10 @@ export interface ChatTransport { * Both must be POSITIVE integers when present (the server 400s otherwise). */ export interface HistoryWindow { - /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ - readonly limit?: number; - /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ - readonly beforeSeq?: number; + /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ + readonly limit?: number; + /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ + readonly beforeSeq?: number; } /** @@ -34,9 +34,9 @@ export interface HistoryWindow { * satisfies this naturally). */ export type HistorySync = ( - conversationId: string, - sinceSeq: number, - window?: HistoryWindow, + conversationId: string, + sinceSeq: number, + window?: HistoryWindow, ) => Promise; /** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */ diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 8f76dea..6d409e9 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -1,45 +1,45 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { - it("ladder matches the wire contract, in ascending depth order", () => { - expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); - }); + it("ladder matches the wire contract, in ascending depth order", () => { + expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); - it("the server default is high", () => { - expect(DEFAULT_REASONING_EFFORT).toBe("high"); - }); + it("the server default is high", () => { + expect(DEFAULT_REASONING_EFFORT).toBe("high"); + }); - it("isReasoningEffort narrows ladder strings and rejects everything else", () => { - for (const level of REASONING_EFFORT_LEVELS) { - expect(isReasoningEffort(level)).toBe(true); - } - expect(isReasoningEffort("banana")).toBe(false); - expect(isReasoningEffort("")).toBe(false); - expect(isReasoningEffort("HIGH")).toBe(false); - }); + it("isReasoningEffort narrows ladder strings and rejects everything else", () => { + for (const level of REASONING_EFFORT_LEVELS) { + expect(isReasoningEffort(level)).toBe(true); + } + expect(isReasoningEffort("banana")).toBe(false); + expect(isReasoningEffort("")).toBe(false); + expect(isReasoningEffort("HIGH")).toBe(false); + }); - it("effectiveEffort maps null (never set) to the default, not 'off'", () => { - expect(effectiveEffort(null)).toBe("high"); - }); + it("effectiveEffort maps null (never set) to the default, not 'off'", () => { + expect(effectiveEffort(null)).toBe("high"); + }); - it("effectiveEffort passes a persisted value through", () => { - expect(effectiveEffort("xhigh")).toBe("xhigh"); - expect(effectiveEffort("low")).toBe("low"); - }); + it("effectiveEffort passes a persisted value through", () => { + expect(effectiveEffort("xhigh")).toBe("xhigh"); + expect(effectiveEffort("low")).toBe("low"); + }); - it("effortOptions lists every level once and marks only the default", () => { - const options = effortOptions(); - expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); - expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); - for (const option of options) { - if (option.value !== "high") expect(option.label).toBe(option.value); - } - }); + it("effortOptions lists every level once and marks only the default", () => { + const options = effortOptions(); + expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + for (const option of options) { + if (option.value !== "high") expect(option.label).toBe(option.value); + } + }); }); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 2a55089..1eb77b6 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; /** The canonical ladder, in ascending thinking-depth order (`wire@0.7.0`). */ export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; /** The server's fallback when nothing is set (the resolution chain's tail). */ @@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; /** Narrow an untrusted string (e.g. a ` -
- {rendered.streaming ? "Thinking" : "Thoughts"} - {#if rendered.streaming} - - {/if} -
-
-

{rendered.chunk.text}

-
- - - - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - -
-
- {#if rendered.chunk.type === "tool-call"} -
- -
- - {rendered.chunk.toolName} -
-
-
{JSON.stringify(rendered.chunk.input, null, 2)}
-
-
- {:else} -
- -
- - {rendered.chunk.toolName} - {#if rendered.chunk.isError} - error - {/if} -
-
-
{rendered.chunk.content}
-
-
- {/if} -
-
- {:else} - -
-
- {#if rendered.chunk.type === "text"} - - {:else if rendered.chunk.type === "error"} - - {:else if rendered.chunk.type === "system"} -
{rendered.chunk.text}
- {/if} -
-
- {/if} + {#if rendered.role === "user"} + +
+
+ {#if rendered.chunk.type === "text"} +

{rendered.chunk.text}

+ {/if} +
+
+ {:else if rendered.chunk.type === "thinking"} + +
+
+
+ +
+ {rendered.streaming ? "Thinking" : "Thoughts"} + {#if rendered.streaming} + + {/if} +
+
+

{rendered.chunk.text}

+
+
+
+
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + +
+
+ {#if rendered.chunk.type === "tool-call"} +
+ +
+ + {rendered.chunk.toolName} +
+
+
{JSON.stringify(
+                  rendered.chunk.input,
+                  null,
+                  2,
+                )}
+
+
+ {:else} +
+ +
+ + {rendered.chunk.toolName} + {#if rendered.chunk.isError} + error + {/if} +
+
+
{rendered.chunk.content}
+
+
+ {/if} +
+
+ {:else} + +
+
+ {#if rendered.chunk.type === "text"} + + {:else if rendered.chunk.type === "error"} + + {:else if rendered.chunk.type === "system"} +
{rendered.chunk.text}
+ {/if} +
+
+ {/if} {/snippet}
- {#if hasEarlier && onShowEarlier} - -
- -
- {/if} - {#each keyedRows as { row, key } (key)} - {#if row.kind === "step-metrics"} - {@const sv = viewStepMetrics(row.step, row.index)} -
-
-
- {sv.label} · {sv.tokensLabel} - {#if sv.tps} · {sv.tps}{/if} - {#if sv.genTotal} · {sv.genTotal}{/if} -
-
-
- {:else if row.kind === "turn-metrics"} - {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} - {@const lastCache = viewCacheRate(row.turn.usage)} - {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} -
-
-
-
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) - {#if turnView.tps} · {turnView.tps}{/if} - {#if turnView.duration} · {turnView.duration}{/if} -
-
- - Last turn: - {lastCache.pct}% - - - Chat Total: - {chatCache.pct}% - - {#if retention} - - Retention: - {retention.pct}% - - {/if} -
-
-
-
- {:else if row.group.kind === "single"} - {@render chunkRow(row.group.chunk)} - {:else} - -
-
-
- {#each row.group.entries as entry (entry.call.toolCallId)} -
- -
- - {entry.call.toolName} - {#if entry.result?.isError} - error - {:else if entry.result === null} - - {/if} -
-
-
{JSON.stringify(entry.call.input, null, 2)}
- {#if entry.result} -
{entry.result.content}
- {/if} -
-
- {/each} -
-
-
- {/if} - {/each} - {#if providerRetry} - {@const rv = viewProviderRetry(providerRetry)} - -
-
-
-
- - {rv.attemptLabel} — retrying in {rv.delayLabel}… - {#if rv.code} - {rv.code} - {/if} -
-
{rv.message}
-
-
-
- {/if} + {#if hasEarlier && onShowEarlier} + +
+ +
+ {/if} + {#each keyedRows as { row, key } (key)} + {#if row.kind === "step-metrics"} + {@const sv = viewStepMetrics(row.step, row.index)} +
+
+
+ {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.genTotal} + · {sv.genTotal}{/if} +
+
+
+ {:else if row.kind === "turn-metrics"} + {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} + {@const lastCache = viewCacheRate(row.turn.usage)} + {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} +
+
+
+
+ {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {#if turnView.tps} + · {turnView.tps}{/if} + {#if turnView.duration} + · {turnView.duration}{/if} +
+
+ + Last turn: + {lastCache.pct}% + + + Chat Total: + {chatCache.pct}% + + {#if retention} + + Retention: + {retention.pct}% + + {/if} +
+
+
+
+ {:else if row.group.kind === "single"} + {@render chunkRow(row.group.chunk)} + {:else} + +
+
+
+ {#each row.group.entries as entry (entry.call.toolCallId)} +
+ +
+ + {entry.call.toolName} + {#if entry.result?.isError} + error + {:else if entry.result === null} + + {/if} +
+
+
{JSON.stringify(
+                      entry.call.input,
+                      null,
+                      2,
+                    )}
+ {#if entry.result} +
{entry.result.content}
+ {/if} +
+
+ {/each} +
+
+
+ {/if} + {/each} + {#if providerRetry} + {@const rv = viewProviderRetry(providerRetry)} + +
+
+
+
+ + {rv.attemptLabel} — retrying in {rv.delayLabel}… + {#if rv.code} + {rv.code} + {/if} +
+
{rv.message}
+
+
+
+ {/if}
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte index 7bec984..5014e5c 100644 --- a/src/features/chat/ui/CompactionView.svelte +++ b/src/features/chat/ui/CompactionView.svelte @@ -1,154 +1,154 @@
- -
- Manual compaction - - {#if !canCompact} -

Open or start a conversation to compact its history.

- {:else if compactError} -

{compactError}

- {:else if compactResult} -

- Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. -

- {:else} -

- Summarizes old messages into a system summary + retains the most recent messages. -

- {/if} -
+ +
+ Manual compaction + + {#if !canCompact} +

Open or start a conversation to compact its history.

+ {:else if compactError} +

{compactError}

+ {:else if compactResult} +

+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. +

+ {:else} +

+ Summarizes old messages into a system summary + retains the most recent messages. +

+ {/if} +
- -
- Auto-compact percent -
- - % - {#if savingPercent} - - {/if} -
-

- Current: {percentLabel} -
- 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. -

- {#if percentError} -

{percentError}

- {:else if percentSaved} -

Saved.

- {/if} -
+ +
+ Auto-compact percent +
+ + % + {#if savingPercent} + + {/if} +
+

+ Current: {percentLabel} +
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. +

+ {#if percentError} +

{percentError}

+ {:else if percentSaved} +

Saved.

+ {/if} +
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index fe9ea94..96b4b3a 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,198 +1,198 @@
{ - e.preventDefault(); - handleSubmit(); - }} + class="flex flex-col" + onsubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} > - -
- - {#if buttonMode === "stop"} - - {:else} - - {/if} -
+ +
+ + {#if buttonMode === "stop"} + + {:else} + + {/if} +
- -
- - {#if status === "running"} - - {:else if status === "error"} - - - - - - {:else} - - - - {/if} - + +
+ + {#if status === "running"} + + {:else if status === "error"} + + + + + + {:else} + + + + {/if} + - {#if usage.percent !== null} - - {:else} - - {/if} + {#if usage.percent !== null} + + {:else} + + {/if} - - {#if hasUsage} - {formatCompactTokens(usage.current)}{#if usage.max !== null} - / {formatCompactTokens(usage.max)}{/if} - {#if usage.percent !== null} - · {usage.percent.toFixed(1)}% - {/if} - {:else} - — tokens - {/if} - -
+ + {#if hasUsage} + {formatCompactTokens(usage.current)}{#if usage.max !== null} + / {formatCompactTokens(usage.max)}{/if} + {#if usage.percent !== null} + · {usage.percent.toFixed(1)}% + {/if} + {:else} + — tokens + {/if} + +
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index a288cb8..03acb79 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,50 +1,50 @@
- - + +
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index 8c7b193..d982905 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,75 +1,75 @@
- Reasoning effort -
- - {#if saving} - - {/if} -
- {#if error} -

{error}

- {:else if justSaved} -

Saved — applies from the next turn.

- {:else} -

- How long the model thinks before answering. Changing it can re-prefill the prompt cache once. -

- {/if} + Reasoning effort +
+ + {#if saving} + + {/if} +
+ {#if error} +

{error}

+ {:else if justSaved} +

Saved — applies from the next turn.

+ {:else} +

+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once. +

+ {/if}
-- cgit v1.2.3