Show runtime mode icons in status bar

This commit is contained in:
murashit 2026-06-05 09:31:11 +09:00
parent 85d8f8c84d
commit d39403c8f5
7 changed files with 380 additions and 33 deletions

View file

@ -3,7 +3,7 @@ import { useLayoutEffect, useRef } from "preact/hooks";
import type { ComposerSuggestion } from "../composer/suggestions";
import type { ComposerMetaViewModel } from "../view-model";
import { IconButton } from "../../../shared/ui/components";
import { IconButton, ObsidianIcon } from "../../../shared/ui/components";
import { renderUiRoot } from "../../../shared/ui/ui-root";
import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow";
@ -27,9 +27,20 @@ type ButtonProps = ButtonHTMLAttributes & {
const DEFAULT_COMPOSER_META: ComposerMetaViewModel = {
fatal: null,
context: "ctx --%",
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
};
export function renderComposerShell(
@ -154,6 +165,31 @@ function ComposerMeta({
sendMode: ComposerSendMode;
onSendOrInterrupt: () => void;
}): UiNode {
const statusRef = useRef<HTMLSpanElement | null>(null);
useLayoutEffect(() => {
const status = statusRef.current;
if (!status) return;
const win = status.win;
let frame = 0;
const update = () => {
frame = 0;
updateComposerMetaStatusOverflow(status);
};
const scheduleUpdate = () => {
if (frame) win.cancelAnimationFrame(frame);
frame = win.requestAnimationFrame(update);
};
update();
const ResizeObserverCtor = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
const observer = ResizeObserverCtor ? new ResizeObserverCtor(scheduleUpdate) : null;
observer?.observe(status);
win.addEventListener("resize", scheduleUpdate);
return () => {
if (frame) win.cancelAnimationFrame(frame);
observer?.disconnect();
win.removeEventListener("resize", scheduleUpdate);
};
}, [meta]);
if (meta.fatal) {
return (
<div className="codex-panel__composer-meta codex-panel__composer-meta--fatal">
@ -164,15 +200,23 @@ function ComposerMeta({
}
return (
<div className="codex-panel__composer-meta">
<span className="codex-panel__composer-meta-status" aria-hidden="true">
<span className="codex-panel__composer-meta-context">{meta.context}</span>
<span ref={statusRef} className="codex-panel__composer-meta-status" aria-hidden="true">
<span className="codex-panel__composer-meta-modes">
<ComposerMetaIcon icon="list-todo" active={meta.planActive} />
<ComposerMetaIcon icon="shield" active={meta.autoReviewActive} />
<ComposerMetaIcon icon="zap" active={meta.fastActive} />
</span>
<span className="codex-panel__composer-meta-separator">|</span>
<span className="codex-panel__composer-meta-model">{meta.model}</span>
<ComposerContextMeter context={meta.context} />
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--model">
<span className="codex-panel__composer-meta-separator">|</span>
<span className="codex-panel__composer-meta-model">{meta.model}</span>
</span>
{meta.effort ? (
<>
<span className="codex-panel__composer-meta-field codex-panel__composer-meta-field--effort">
<span className="codex-panel__composer-meta-separator">|</span>
<span className="codex-panel__composer-meta-effort">{meta.effort}</span>
</>
</span>
) : null}
</span>
<ComposerSendButton sendMode={sendMode} onSendOrInterrupt={onSendOrInterrupt} />
@ -180,6 +224,47 @@ function ComposerMeta({
);
}
const COMPOSER_META_EFFORT_HIDDEN_CLASS = "is-effort-hidden";
const COMPOSER_META_MODEL_HIDDEN_CLASS = "is-model-hidden";
function updateComposerMetaStatusOverflow(status: HTMLElement): void {
status.classList.remove(COMPOSER_META_EFFORT_HIDDEN_CLASS, COMPOSER_META_MODEL_HIDDEN_CLASS);
if (!composerMetaStatusOverflowing(status)) return;
if (status.querySelector(".codex-panel__composer-meta-field--effort")) {
status.classList.add(COMPOSER_META_EFFORT_HIDDEN_CLASS);
}
if (!composerMetaStatusOverflowing(status)) return;
if (status.querySelector(".codex-panel__composer-meta-field--model")) {
status.classList.add(COMPOSER_META_MODEL_HIDDEN_CLASS);
}
}
function composerMetaStatusOverflowing(status: HTMLElement): boolean {
return status.scrollWidth > status.clientWidth;
}
function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["context"] }): UiNode {
return (
<span className="codex-panel__composer-meta-context">
<span className="codex-panel__composer-meta-context-dots">
{context.cells.map((cell, index) => (
<span
key={index}
className={["codex-panel__composer-meta-context-dot", cell.placeholder ? "is-placeholder" : ""].filter(Boolean).join(" ")}
>
{cell.text}
</span>
))}
</span>
<span className="codex-panel__composer-meta-context-percent">{context.percent}</span>
</span>
);
}
function ComposerMetaIcon({ icon, active }: { icon: string; active: boolean }): UiNode {
return <ObsidianIcon icon={icon} className={["codex-panel__composer-meta-icon", active ? "is-active" : ""].filter(Boolean).join(" ")} />;
}
interface ComposerSendMode {
icon: string;
label: string;

View file

@ -26,9 +26,22 @@ export interface RuntimeSnapshotInput {
export interface ComposerMetaViewModel {
fatal: string | null;
context: string;
context: ComposerContextMeterViewModel;
model: string;
effort: string | null;
planActive: boolean;
autoReviewActive: boolean;
fastActive: boolean;
}
export interface ComposerContextMeterCellViewModel {
text: string;
placeholder: boolean;
}
export interface ComposerContextMeterViewModel {
cells: ComposerContextMeterCellViewModel[];
percent: string;
}
export interface ToolbarViewModelInput {
@ -146,9 +159,12 @@ export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapsho
if (state.status === "Connection failed.") {
return {
fatal: "Codex app-server disconnected",
context: "",
context: contextComposerMeter(null),
model: "",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
};
}
@ -158,9 +174,12 @@ export function composerMetaViewModel(state: ChatState, snapshot: RuntimeSnapsho
const effort = currentReasoningEffort(snapshot, config);
return {
fatal: null,
context: contextComposerLabel(context?.percent ?? null),
context: contextComposerMeter(context?.percent ?? null),
model: model ?? "default",
effort: effort ? compactReasoningEffortLabel(effort) : null,
planActive: state.selectedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: fastModeActive(snapshot, config),
};
}
@ -205,27 +224,30 @@ const CONTEXT_DOT_WIDTH = 4;
const CONTEXT_CELL_PERCENT = 100 / CONTEXT_DOT_WIDTH;
const CONTEXT_PARTIAL_DOTS = ["", "⣀", "⣤", "⣶", "⣿"] as const;
const CONTEXT_FULL_DOT = "⣿";
const CONTEXT_EMPTY_DOT = "⣀";
function contextComposerLabel(percent: number | null): string {
const dots = contextBrailleDots(percent);
function contextComposerMeter(percent: number | null): ComposerContextMeterViewModel {
const percentLabel = percent === null ? "--%" : `${String(Math.round(Math.max(0, Math.min(100, percent)))).padStart(2, " ")}%`;
return `ctx ${dots} ${percentLabel}`;
return {
cells: contextBrailleCells(percent),
percent: percentLabel,
};
}
function contextBrailleDots(percent: number | null): string {
if (percent === null) return " ".repeat(CONTEXT_DOT_WIDTH);
function contextBrailleCells(percent: number | null): ComposerContextMeterCellViewModel[] {
if (percent === null) return Array.from({ length: CONTEXT_DOT_WIDTH }, () => ({ text: CONTEXT_EMPTY_DOT, placeholder: true }));
const clamped = Math.max(0, Math.min(100, percent));
const chars: string[] = [];
const cells: ComposerContextMeterCellViewModel[] = [];
for (let index = 0; index < CONTEXT_DOT_WIDTH; index += 1) {
const remaining = clamped - index * CONTEXT_CELL_PERCENT;
if (remaining <= 0) {
chars.push(index === 0 ? CONTEXT_PARTIAL_DOTS[1] : " ");
cells.push({ text: CONTEXT_EMPTY_DOT, placeholder: true });
continue;
}
const partialIndex = Math.min(CONTEXT_PARTIAL_DOTS.length - 1, Math.ceil((remaining / CONTEXT_CELL_PERCENT) * 4));
chars.push(CONTEXT_PARTIAL_DOTS[partialIndex] ?? CONTEXT_FULL_DOT);
cells.push({ text: CONTEXT_PARTIAL_DOTS[partialIndex] ?? CONTEXT_FULL_DOT, placeholder: false });
}
return chars.join("");
return cells;
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {

View file

@ -48,8 +48,29 @@
}
.codex-panel__composer-meta-context {
display: inline-flex;
flex: 0 0 auto;
gap: var(--codex-panel-control-gap);
align-items: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.codex-panel__composer-meta-context-dots {
display: inline-flex;
flex: 0 0 auto;
}
.codex-panel__composer-meta-context-dot {
color: currentcolor;
}
.codex-panel__composer-meta-context-dot.is-placeholder {
color: var(--text-faint);
}
.codex-panel__composer-meta-context-percent {
flex: 0 0 auto;
white-space: pre;
}
@ -58,11 +79,46 @@
color: var(--text-faint);
}
.codex-panel__composer-meta-field {
display: inline-flex;
flex: 0 0 auto;
gap: var(--size-4-2);
align-items: center;
}
.codex-panel__composer-meta-field--model {
flex: 0 0 auto;
}
.codex-panel__composer-meta-modes {
display: inline-flex;
flex: 0 0 auto;
gap: var(--codex-panel-control-gap);
align-items: center;
color: var(--text-faint);
}
.codex-panel__composer-meta-icon {
display: inline-flex;
width: var(--codex-panel-size-icon-xs);
height: var(--codex-panel-size-icon-xs);
align-items: center;
justify-content: center;
color: var(--text-faint);
}
.codex-panel__composer-meta-icon.is-active {
color: var(--icon-color-active);
}
.codex-panel__composer-meta-icon svg {
width: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2);
height: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2);
stroke-width: 2;
}
.codex-panel__composer-meta-model,
.codex-panel__composer-meta-effort {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@ -78,6 +134,14 @@
white-space: nowrap;
}
.codex-panel__composer-meta-status.is-effort-hidden .codex-panel__composer-meta-field--effort {
display: none;
}
.codex-panel__composer-meta-status.is-model-hidden .codex-panel__composer-meta-field--model {
display: none;
}
.codex-panel__composer-suggestions {
position: absolute;
right: var(--codex-panel-edge-padding-x);

View file

@ -24,7 +24,23 @@ describe("ChatComposerController", () => {
scrollThreadFromComposerEdges: () => false,
canInterrupt: () => false,
composerPlaceholder: () => "Ask Codex to work on this task...",
composerMeta: () => ({ fatal: null, context: "ctx --%", model: "default", effort: null }),
composerMeta: () => ({
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
}),
currentModelForSuggestions: () => null,
renderIfDetached: vi.fn(),
onDraftChange: vi.fn(),

View file

@ -3,6 +3,7 @@
import { describe, expect, it, vi } from "vitest";
import { renderComposerShell, scrollComposerSuggestionIntoView, syncComposerHeight } from "../../../../../src/features/chat/ui/composer";
import { waitForAsyncWork } from "../../../../support/async";
import { changeInputValue, composerSuggestionScrollFixture, installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
@ -47,30 +48,110 @@ describe("composer renderer decisions", () => {
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: "ctx ⣿⣶ 42%",
context: {
cells: [
{ text: "⣿", placeholder: false },
{ text: "⣶", placeholder: false },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "42%",
},
model: "gpt-5.5",
effort: "high",
planActive: true,
autoReviewActive: false,
fastActive: true,
});
const meta = parent.querySelector<HTMLElement>(".codex-panel__composer-meta");
const statusItems = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-status > span"));
const fields = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-field"));
const contextDots = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-context-dot"));
const modeIcons = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-icon"));
expect(meta?.getAttribute("aria-hidden")).toBeNull();
expect(meta?.textContent).toBe("ctx ⣿⣶ 42%|gpt-5.5|high");
expect(meta?.textContent).toBe("|⣿⣶⣀⣀42%|gpt-5.5|high");
expect(statusItems.map((item) => item.className)).toEqual([
"codex-panel__composer-meta-modes",
"codex-panel__composer-meta-separator",
"codex-panel__composer-meta-context",
"codex-panel__composer-meta-field codex-panel__composer-meta-field--model",
"codex-panel__composer-meta-field codex-panel__composer-meta-field--effort",
]);
expect(fields.map((field) => field.textContent)).toEqual(["|gpt-5.5", "|high"]);
expect(parent.querySelector(".codex-panel__composer-meta-status")?.getAttribute("aria-hidden")).toBe("true");
expect(parent.querySelector(".codex-panel__composer-meta-context")?.textContent).toBe("ctx ⣿⣶ 42%");
expect(parent.querySelector(".codex-panel__composer-meta-context")?.textContent).toBe("⣿⣶⣀⣀42%");
expect(contextDots.map((dot) => dot.textContent)).toEqual(["⣿", "⣶", "⣀", "⣀"]);
expect(contextDots.map((dot) => dot.classList.contains("is-placeholder"))).toEqual([false, false, true, true]);
expect(parent.querySelector(".codex-panel__composer-meta-model")?.textContent).toBe("gpt-5.5");
expect(parent.querySelector(".codex-panel__composer-meta-effort")?.textContent).toBe("high");
expect(modeIcons.map((icon) => icon.dataset["icon"])).toEqual(["list-todo", "shield", "zap"]);
expect(modeIcons.map((icon) => icon.classList.contains("is-active"))).toEqual([true, false, true]);
expect(parent.querySelector(".codex-panel__composer-action.codex-panel__send")).not.toBeNull();
expect(parent.querySelector(".codex-panel__new-chat")).toBeNull();
});
it("hides composer meta fields only after measured overflow", async () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: null,
context: {
cells: [
{ text: "⣿", placeholder: false },
{ text: "⣶", placeholder: false },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "42%",
},
model: "gpt-5.5",
effort: "high",
planActive: true,
autoReviewActive: false,
fastActive: true,
});
const status = parent.querySelector<HTMLElement>(".codex-panel__composer-meta-status");
if (!status) throw new Error("Expected composer meta status to render.");
Object.defineProperty(status, "clientWidth", { configurable: true, value: 100 });
Object.defineProperty(status, "scrollWidth", {
configurable: true,
get: () => (status.classList.contains("is-model-hidden") ? 80 : status.classList.contains("is-effort-hidden") ? 120 : 140),
});
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => {
resolve();
});
});
await waitForAsyncWork(() => {
expect(status.classList.contains("is-effort-hidden")).toBe(true);
expect(status.classList.contains("is-model-hidden")).toBe(true);
});
});
it("replaces composer meta with fatal status text", () => {
const parent = document.createElement("div");
renderComposerShell(parent, "view", "", false, false, "Ask Codex to work on this task...", [], 0, composerCallbacks(), {
fatal: "Codex app-server disconnected",
context: "",
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
model: "",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
});
expect(parent.querySelector(".codex-panel__composer-meta-fatal")?.textContent).toBe("Codex app-server disconnected");

View file

@ -80,7 +80,13 @@ describe("chat view model", () => {
it("builds composer meta from context and runtime state", () => {
const state = createChatState();
state.activeThreadId = "thread-1";
state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
state.selectedCollaborationMode = "plan";
state.effectiveConfig = effectiveConfigFixture({
model: "gpt-5.5",
model_reasoning_effort: "high",
approvals_reviewer: "auto_review",
service_tier: "fast",
});
state.tokenUsage = {
last: { inputTokens: 42, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 44 },
total: { inputTokens: 40, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 42 },
@ -89,9 +95,20 @@ describe("chat view model", () => {
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toEqual({
fatal: null,
context: "ctx ⣿⣶ 42%",
context: {
cells: [
{ text: "⣿", placeholder: false },
{ text: "⣶", placeholder: false },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "42%",
},
model: "gpt-5.5",
effort: "high",
planActive: true,
autoReviewActive: true,
fastActive: true,
});
});
@ -113,7 +130,15 @@ describe("chat view model", () => {
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toMatchObject({
fatal: null,
context: "ctx --%",
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
model: "gpt-5.5",
effort: null,
});
@ -129,7 +154,15 @@ describe("chat view model", () => {
};
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toMatchObject({
context: "ctx ⣀ 0%",
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: " 0%",
},
});
});
@ -139,9 +172,20 @@ describe("chat view model", () => {
expect(composerMetaViewModel(state, runtimeSnapshotForChatState({ state }))).toEqual({
fatal: "Codex app-server disconnected",
context: "",
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
model: "",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
});
});

View file

@ -110,9 +110,16 @@ describe("chat toolbar CSS", () => {
it("keeps the composer context status text fixed-width", () => {
const contextMeter = /\.codex-panel__composer-meta-context \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const contextDots = /\.codex-panel__composer-meta-context-dots \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const placeholderDot =
/\.codex-panel__composer-meta-context-dot\.is-placeholder \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const contextPercent = /\.codex-panel__composer-meta-context-percent \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(contextMeter).toContain("font-variant-numeric: tabular-nums");
expect(contextMeter).toContain("white-space: pre");
expect(contextMeter).toContain("white-space: nowrap");
expect(contextDots).toContain("display: inline-flex");
expect(placeholderDot).toContain("color: var(--text-faint)");
expect(contextPercent).toContain("white-space: pre");
expect(styles).not.toContain(".codex-panel__composer-meta-context::before");
expect(styles).not.toContain("conic-gradient");
});
@ -125,6 +132,34 @@ describe("chat toolbar CSS", () => {
expect(composerMetaFatal).toContain("padding-inline-start: calc(var(--size-4-1) / 2)");
});
it("keeps composer runtime mode icons compact and state-colored", () => {
const modes = /\.codex-panel__composer-meta-modes \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const field = /\.codex-panel__composer-meta-field \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const labels =
/\.codex-panel__composer-meta-model,\n\.codex-panel__composer-meta-effort \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const icon = /\.codex-panel__composer-meta-icon \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const activeIcon = /\.codex-panel__composer-meta-icon\.is-active \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const iconSvg = /\.codex-panel__composer-meta-icon svg \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
expect(modes).toContain("gap: var(--codex-panel-control-gap)");
expect(field).toContain("display: inline-flex");
expect(field).toContain("flex: 0 0 auto");
expect(field).toContain("gap: var(--size-4-2)");
expect(labels).toContain("white-space: nowrap");
expect(labels).not.toContain("text-overflow");
expect(icon).toContain("width: var(--codex-panel-size-icon-xs)");
expect(icon).toContain("height: var(--codex-panel-size-icon-xs)");
expect(activeIcon).toContain("color: var(--icon-color-active)");
expect(iconSvg).toContain("width: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
expect(iconSvg).toContain("height: calc(var(--codex-panel-size-icon-xs) - var(--codex-panel-rail-width) / 2)");
expect(styles).toContain(
".codex-panel__composer-meta-status.is-effort-hidden .codex-panel__composer-meta-field--effort {\n display: none;",
);
expect(styles).toContain(
".codex-panel__composer-meta-status.is-model-hidden .codex-panel__composer-meta-field--model {\n display: none;",
);
});
it("keeps nav inline input reset in the shared primitive", () => {
const navInlineInput =
/\.codex-panel-ui__nav-inline-input\.codex-panel-ui__nav-inline-input \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";