[codex] Add local tool trace toggle (#806)

* test: harden tool approval browser contract

* test: cover tool trace display edge cases

* test: cover resumed tool trace edge cases

* feat: hide tool traces by default

* Add local tool trace toggle

---------

Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
This commit is contained in:
ekko
2026-05-17 09:01:59 +08:00
committed by GitHub
parent 569ddc28da
commit 0c2bafc619
19 changed files with 975 additions and 29 deletions
@@ -8,10 +8,12 @@ import { setModelContext } from '@/api/hermes/model-context'
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
const chatStore = useChatStore()
const { t } = useI18n()
const message = useMessage()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const commandDropdownRef = ref<HTMLDivElement>()
@@ -430,6 +432,24 @@ function isImage(type: string): boolean {
/>
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton
quaternary
size="tiny"
class="tool-trace-toggle"
:class="{ active: toolTraceVisible }"
:aria-label="toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls')"
@click="toggleToolTraceVisible"
>
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
{{ formatTokens(totalTokens) }} /
<NTooltip trigger="hover">
@@ -614,20 +634,65 @@ function isImage(type: string): boolean {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
padding: 0 0 0 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
color: $text-muted;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
font-size: 12px;
svg {
opacity: 0.7;
opacity: 1;
}
}
:deep(.n-switch),
:deep(.n-switch__rail) {
margin-right: 0;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
opacity: 1;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
flex: 0 0 16px;
width: 16px;
height: 16px;
}
&.active {
color: #999999;
opacity: 1;
}
&:hover {
color: #999999;
opacity: 1;
}
}
.context-info {
@@ -3,6 +3,7 @@ import { ref, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
import type { Session } from "@/stores/hermes/chat";
const props = defineProps<{
@@ -10,6 +11,7 @@ const props = defineProps<{
}>();
const chatStore = useChatStore();
const { toolTraceVisible } = useToolTraceVisibility();
const { t } = useI18n();
const listRef = ref<HTMLElement>();
@@ -18,10 +20,10 @@ const activeSession = computed(() => props.session || chatStore.activeSession);
const displayMessages = computed(() =>
(activeSession.value?.messages || []).filter((m) => {
// Filter out tool messages without name (internal use only)
if (m.role === 'tool' && !m.toolName) return false
// Filter out messages with empty content (except tool messages)
if (m.role !== 'tool' && !m.content?.trim()) return false
// Tool messages without a name are internal use only and remain hidden.
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
// Filter out messages with empty content.
if (!m.content?.trim()) return false
return true
}),
);
@@ -19,7 +19,13 @@ import { useGlobalSpeech } from "@/composables/useSpeech";
import { useVoiceSettings } from "@/composables/useVoiceSettings";
import { speedToEdgeRate, hzToEdgePitch } from "@/utils/ttsHelpers";
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000;
const JSON_STRING_DISPLAY_LIMIT = 200;
const JSON_MAX_DEPTH = 6;
const JSON_MAX_NODES = 1000;
const JSON_MAX_KEYS_PER_OBJECT = 50;
const JSON_MAX_ITEMS_PER_ARRAY = 50;
const JSON_TRUNCATED_KEY = "__truncated__";
const props = defineProps<{ message: Message; highlight?: boolean }>();
const { t } = useI18n();
@@ -353,19 +359,96 @@ type ToolPayload = {
language?: string;
};
function truncateLongString(value: string, marker: string): string {
return value.length > JSON_STRING_DISPLAY_LIMIT
? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + "\n" + marker
: value;
}
function truncateJsonValue(value: unknown, marker: string): unknown {
let nodeCount = 0;
const seen = new WeakSet<object>();
function stringifyLength(candidate: unknown): number {
return JSON.stringify(candidate, null, 2).length;
}
function visit(current: unknown, depth: number): unknown {
nodeCount += 1;
if (nodeCount > JSON_MAX_NODES) {
return marker;
}
if (typeof current === "string") return truncateLongString(current, marker);
if (current === null || typeof current !== "object") return current;
if (seen.has(current)) return `[Circular ${marker}]`;
if (depth >= JSON_MAX_DEPTH) {
return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`;
}
seen.add(current);
if (Array.isArray(current)) {
const result: unknown[] = [];
const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY);
for (let i = 0; i < maxItems; i += 1) {
const remaining = current.length - i;
result.push(visit(current[i], depth + 1));
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
result.pop();
result.push(`${marker}: ${remaining} more items`);
seen.delete(current);
return result;
}
}
if (current.length > maxItems) {
result.push(`${marker}: ${current.length - maxItems} more items`);
}
seen.delete(current);
return result;
}
const entries = Object.entries(current as Record<string, unknown>);
const result: Record<string, unknown> = {};
const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT);
for (let i = 0; i < maxKeys; i += 1) {
const [key, val] = entries[i];
const remaining = entries.length - i;
result[key] = visit(val, depth + 1);
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
delete result[key];
result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`;
seen.delete(current);
return result;
}
}
if (entries.length > maxKeys) {
result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`;
}
seen.delete(current);
return result;
}
const truncated = visit(value, 0);
if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated;
return { [JSON_TRUNCATED_KEY]: marker };
}
function formatToolPayload(raw?: string): ToolPayload {
if (!raw) {
return { full: "", display: "" };
}
try {
const full = JSON.stringify(JSON.parse(raw), null, 2);
const parsed = JSON.parse(raw);
const full = JSON.stringify(parsed, null, 2);
const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
? JSON.stringify(truncateJsonValue(parsed, t("chat.truncated")), null, 2)
: full;
return {
full,
display:
full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated")
: full,
display,
language: "json",
};
} catch {
@@ -6,10 +6,12 @@ import { useChatStore } from "@/stores/hermes/chat";
import thinkingVideoLight from "@/assets/thinking-light.mp4";
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
import { useTheme } from "@/composables/useTheme";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
const chatStore = useChatStore();
const { t } = useI18n();
const { isDark } = useTheme();
const { toolTraceVisible } = useToolTraceVisibility();
const listRef = ref<HTMLElement>();
function formatTokens(n: number): string {
@@ -41,9 +43,16 @@ const currentToolCalls = computed(() => {
return [...tools].reverse();
});
const displayMessages = computed(() =>
chatStore.messages.filter((m) => {
if (m.role === "tool") return false;
const visibleToolCalls = computed(() =>
toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [],
);
const displayMessages = computed(() => {
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
return chatStore.messages.filter((m) => {
if (m.role === "tool") {
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
}
if (
m.role === "assistant" &&
m.isStreaming &&
@@ -54,8 +63,8 @@ const displayMessages = computed(() =>
return false;
}
return true;
}),
);
});
});
const queuedMessages = computed(() => {
const sid = chatStore.activeSessionId;
@@ -171,7 +180,7 @@ watch(currentToolCalls, () => {
playsinline
class="thinking-video"
/>
<div v-if="currentToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<!-- Abort indicator -->
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
<svg
@@ -254,7 +263,7 @@ watch(currentToolCalls, () => {
</div>
<!-- Tool calls -->
<div
v-for="tc in currentToolCalls"
v-for="tc in visibleToolCalls"
:key="tc.id"
class="tool-call-item"
>