Refactor panel cohesion boundaries

This commit is contained in:
murashit 2026-05-16 19:06:04 +09:00
parent e1c76b260a
commit 53e7dd983a
54 changed files with 2121 additions and 1945 deletions

View file

@ -1,4 +1,4 @@
import type { SendShortcut } from "../settings";
import type { SendShortcut } from "../settings/model";
export interface ComposerSendKeyEvent {
key: string;

View file

@ -0,0 +1,22 @@
export const SLASH_COMMANDS = [
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
{ command: "/resume", detail: "Resume a recent Codex thread." },
{ command: "/fork", detail: "Fork the active Codex thread." },
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
{ command: "/compact", detail: "Compact the current conversation context." },
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
{ command: "/status", detail: "Show current session, context, and usage limits." },
{ command: "/doctor", detail: "Show Codex CLI and app-server connection diagnostics." },
{ command: "/model", detail: "Show or set the model for subsequent turns." },
{ command: "/effort", detail: "Show or set reasoning effort for subsequent turns." },
{ command: "/help", detail: "Show available Codex slash commands." },
] as const;
type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"];
export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : never;
export function slashCommandHelpLines(): string[] {
return SLASH_COMMANDS.map((item) => `${item.command} - ${item.detail}`);
}

View file

@ -1,7 +1,7 @@
import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import { SLASH_COMMANDS, type SlashCommandName } from "../panel/slash-commands";
import { getThreadTitle } from "../threads";
import { SLASH_COMMANDS, type SlashCommandName } from "./slash-commands";
import { getThreadTitle } from "../threads/model";
import { shortThreadId } from "../utils";
export interface ComposerSuggestion {

173
src/display/blocks.ts Normal file
View file

@ -0,0 +1,173 @@
import type { DisplayBlock, DisplayItem, DisplayKind } from "./types";
import { pathRelativeToRoot } from "./paths";
import { executionState } from "./state";
export function displayBlocksForItems(
items: DisplayItem[],
activeTurnId: string | null,
workspaceRoot?: string | null,
turnDiffs?: ReadonlyMap<string, string>,
): DisplayBlock[] {
const visibleItems = items.filter(shouldShowDisplayItem);
const orderedItems = activeTurnId ? moveActiveTaskProgressToEnd(visibleItems, activeTurnId) : visibleItems;
const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot);
const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems);
const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems);
const groupedTurnIds = new Set([...finalAssistantIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
const groupedActivities = new Map<string, DisplayItem[]>();
for (const item of orderedItems) {
if (!item.turnId || !groupedTurnIds.has(item.turnId) || !isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue;
const group = groupedActivities.get(item.turnId) ?? [];
group.push(item);
groupedActivities.set(item.turnId, group);
}
const emittedGroups = new Set<string>();
const blocks: DisplayBlock[] = [];
for (const item of orderedItems) {
const turnId = item.turnId;
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) {
if (!emittedGroups.has(turnId)) {
const groupItems = groupedActivities.get(turnId) ?? [];
blocks.push({
type: "activityGroup",
id: `turn-${turnId}-activity`,
turnId,
summary: turnActivitySummary(groupItems),
items: groupItems,
});
emittedGroups.add(turnId);
}
continue;
}
blocks.push({
type: "item",
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, finalAssistantIdByTurn, turnDiffs),
});
}
return blocks;
}
function moveActiveTaskProgressToEnd(items: DisplayItem[], activeTurnId: string): DisplayItem[] {
const activeTaskProgress = items.filter((item) => item.kind === "taskProgress" && item.turnId === activeTurnId);
if (activeTaskProgress.length === 0) return items;
return [...items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId), ...activeTaskProgress];
}
function shouldShowDisplayItem(item: DisplayItem): boolean {
return item.kind !== "reasoning" || executionState(item) !== "completed" || item.text.trim().length > 0;
}
function isCompletedTurnDetailItem(item: DisplayItem, finalAssistantIdByTurn: Map<string, string>): boolean {
if (!item.turnId || item.role === "user") return false;
return finalAssistantIdByTurn.get(item.turnId) !== item.id;
}
function finalAssistantItemsByTurn(items: DisplayItem[]): Map<string, string> {
const finalAssistantIdByTurn = new Map<string, string>();
for (const item of items) {
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
finalAssistantIdByTurn.set(item.turnId, item.id);
}
return finalAssistantIdByTurn;
}
function isFinalAssistantMessage(item: DisplayItem): boolean {
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
}
function itemWithTurnSummaries(
item: DisplayItem,
editedFilesByTurn: Map<string, string[]>,
autoReviewSummariesByTurn: Map<string, string[]>,
finalAssistantIdByTurn: Map<string, string>,
turnDiffs?: ReadonlyMap<string, string>,
): DisplayItem {
if (!item.turnId || finalAssistantIdByTurn.get(item.turnId) !== item.id) return item;
if (item.kind !== "message") return item;
const editedFiles = editedFilesByTurn.get(item.turnId);
const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId);
const diff = turnDiffs?.get(item.turnId);
const turnDiff = diff && diff.trim().length > 0 ? { diff } : undefined;
if ((!editedFiles || editedFiles.length === 0) && (!autoReviewSummaries || autoReviewSummaries.length === 0) && !turnDiff) return item;
return {
...item,
...(editedFiles && editedFiles.length > 0 ? { editedFiles } : {}),
...(turnDiff ? { turnDiff } : {}),
...(autoReviewSummaries && autoReviewSummaries.length > 0 ? { autoReviewSummaries } : {}),
};
}
function editedFilesForTurns(items: DisplayItem[], workspaceRoot?: string | null): Map<string, string[]> {
const byTurn = new Map<string, Set<string>>();
for (const item of items) {
if (!item.turnId || item.kind !== "fileChange") continue;
const files = editedFilesForItem(item, workspaceRoot);
if (files.length === 0) continue;
const set = byTurn.get(item.turnId) ?? new Set<string>();
files.forEach((file) => set.add(file));
byTurn.set(item.turnId, set);
}
return new Map([...byTurn].map(([turnId, files]) => [turnId, [...files].sort((a, b) => a.localeCompare(b))]));
}
function editedFilesForItem(item: DisplayItem, workspaceRoot?: string | null): string[] {
if (item.kind !== "fileChange") return [];
return item.changes.flatMap((change) =>
change.path && change.path !== "(unknown)" ? [pathRelativeToRoot(change.path, workspaceRoot)] : [],
);
}
function autoReviewSummariesForTurns(items: DisplayItem[]): Map<string, string[]> {
const byTurn = new Map<string, string[]>();
for (const item of items) {
if (!item.turnId || item.kind !== "reviewResult") continue;
const summary = item.text.trim();
if (!summary) continue;
const summaries = byTurn.get(item.turnId) ?? [];
if (!summaries.includes(summary)) summaries.push(summary);
byTurn.set(item.turnId, summaries);
}
return byTurn;
}
function turnActivitySummary(items: DisplayItem[]): string {
const parts = [
countMatchingLabel(items, (item) => item.kind === "message" && item.role === "assistant", "response", "responses"),
countLabel(items, "taskProgress", "task progress"),
countLabel(items, "agent", "agent"),
countLabel(items, "command", "command"),
countLabel(items, "fileChange", "file change"),
countLabel(items, "tool", "tool"),
countLabel(items, "hook", "hook"),
countLabel(items, "reasoning", "thought", "thought notes"),
countLabel(items, "approvalResult", "approval"),
countLabel(items, "userInputResult", "input"),
countLabel(items, "reviewResult", "review"),
].filter((part): part is string => Boolean(part));
if (parts.length === 0) return "Work details";
return `Work details: ${parts.join(", ")}`;
}
function countMatchingLabel(
items: DisplayItem[],
predicate: (item: DisplayItem) => boolean,
label: string,
pluralLabel = `${label}s`,
): string | null {
const count = items.filter(predicate).length;
if (count === 0) return null;
if (count === 1) return label;
return `${count} ${pluralLabel}`;
}
function countLabel(items: DisplayItem[], kind: DisplayKind, label: string, pluralLabel = `${label}s`): string | null {
const count = items.filter((item) => item.kind === kind).length;
if (count === 0) return null;
if (count === 1) return label;
return `${count} ${pluralLabel}`;
}

View file

@ -1,4 +1,4 @@
import type { DisplayBlock, DisplayDetailSection, DisplayFileChange, DisplayItem, DisplayKind } from "./types";
import type { DisplayDetailSection, DisplayFileChange, DisplayItem } from "./types";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep";
@ -6,7 +6,8 @@ import { inputToText, truncate } from "../utils";
import { taskStatusMarker } from "./labels";
import { agentDisplayItem } from "./agent";
import { pathRelativeToRoot } from "./paths";
import { classifyExecutionState, executionState } from "./state";
import { normalizeProposedPlanMarkdown } from "./plan";
import { classifyExecutionState } from "./state";
import {
bodyDetail,
compactToolSummary,
@ -17,8 +18,19 @@ import {
statusQualifier,
} from "./tool-format";
export { activeAgentRunSummary, agentDisplayItem } from "./agent";
export { displayBlocksForItems } from "./blocks";
export { normalizeProposedPlanMarkdown } from "./plan";
export { classifyExecutionState, executionState, executionStateLabel } from "./state";
export { createAutoReviewResultItem, createReviewResultItem } from "./review";
export {
appendAssistantDelta,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
completeReasoningItems,
upsertDisplayItem,
} from "./stream-updates";
export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] {
const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
@ -457,384 +469,10 @@ export function shouldSuppressLifecycleItem(item: ThreadItem): boolean {
return item.type === "agentMessage" || item.type === "userMessage";
}
export function upsertDisplayItem(items: DisplayItem[], next: DisplayItem): DisplayItem[] {
const index = items.findIndex((item) => item.id === next.id);
if (index === -1) return [...items, next];
const copy = [...items];
const previous = copy[index];
copy[index] = {
...previous,
...next,
output: mergeOutput(previous, next),
changes: mergeChanges(previous, next),
} as DisplayItem;
return copy;
}
function mergeOutput(previous: DisplayItem, next: DisplayItem): string | undefined {
const previousOutput = "output" in previous ? previous.output : undefined;
const nextOutput = "output" in next ? next.output : undefined;
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
}
function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChange[] | undefined {
const previousChanges = previous.kind === "fileChange" ? previous.changes : undefined;
const nextChanges = next.kind === "fileChange" ? next.changes : undefined;
return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges;
}
export function appendAssistantDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message"
? {
...item,
text: `${item.text}${delta}`,
copyText: `${item.text}${delta}`,
turnId: item.turnId ?? turnId,
markdown: false,
}
: item,
);
}
return [
...items,
{
id: itemId,
kind: "message",
role: "assistant",
text: delta,
copyText: delta,
turnId,
itemId,
markdown: false,
},
];
}
export function completeReasoningItems(items: DisplayItem[], turnId: string): DisplayItem[] {
return items.map((item) =>
item.kind === "reasoning" && item.turnId === turnId
? {
...item,
status: "completed",
state: "completed",
}
: item,
);
}
export function appendPlanDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message" ? appendPlanDeltaToMessage(item, turnId, delta) : item,
);
}
const text = normalizeProposedPlanMarkdown(delta);
return [
...items,
{
id: itemId,
kind: "message",
role: "assistant",
text,
copyText: text,
turnId,
itemId,
markdown: false,
},
];
}
function appendPlanDeltaToMessage(item: Extract<DisplayItem, { kind: "message" }>, turnId: string, delta: string): DisplayItem {
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
return {
...item,
text,
copyText: text,
turnId: item.turnId ?? turnId,
markdown: false,
};
}
export function normalizeProposedPlanMarkdown(text: string): string {
return text
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
.trim();
}
export function appendItemText(
items: DisplayItem[],
itemId: string,
turnId: string,
label: string,
delta: string,
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item));
}
return [
...items,
{
id: itemId,
kind,
role: "tool",
text: `${label}: ${delta}`,
turnId,
itemId,
},
];
}
export function appendToolOutput(
items: DisplayItem[],
itemId: string,
turnId: string,
delta: string,
fallbackLabel: string,
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning")
? { ...item, output: `${item.output ?? ""}${delta}` }
: item,
);
}
return [
...items,
{
id: itemId,
kind: "tool",
role: "tool",
text: "details",
toolLabel: fallbackLabel,
turnId,
itemId,
output: delta,
},
];
}
export function appendItemOutput(
items: DisplayItem[],
itemId: string,
turnId: string,
delta: string,
kind: "command" | "fileChange",
fallbackText: string,
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && (item.kind === "command" || item.kind === "fileChange")
? { ...item, output: `${item.output ?? ""}${delta}` }
: item,
);
}
return [
...items,
{
id: itemId,
kind,
role: "tool",
text: fallbackText,
turnId,
itemId,
output: delta,
...(kind === "fileChange"
? {
status: "inProgress",
changes: [],
}
: {
command: fallbackText,
cwd: "(unknown)",
status: "running",
}),
},
] as DisplayItem[];
}
export function displayBlocksForItems(
items: DisplayItem[],
activeTurnId: string | null,
workspaceRoot?: string | null,
turnDiffs?: ReadonlyMap<string, string>,
): DisplayBlock[] {
const visibleItems = items.filter(shouldShowDisplayItem);
const orderedItems = activeTurnId ? moveActiveTaskProgressToEnd(visibleItems, activeTurnId) : visibleItems;
const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot);
const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems);
const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems);
const groupedTurnIds = new Set([...finalAssistantIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
const groupedActivities = new Map<string, DisplayItem[]>();
for (const item of orderedItems) {
if (!item.turnId || !groupedTurnIds.has(item.turnId) || !isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue;
const group = groupedActivities.get(item.turnId) ?? [];
group.push(item);
groupedActivities.set(item.turnId, group);
}
const emittedGroups = new Set<string>();
const blocks: DisplayBlock[] = [];
for (const item of orderedItems) {
const turnId = item.turnId;
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) {
if (!emittedGroups.has(turnId)) {
const groupItems = groupedActivities.get(turnId) ?? [];
blocks.push({
type: "activityGroup",
id: `turn-${turnId}-activity`,
turnId,
summary: turnActivitySummary(groupItems),
items: groupItems,
});
emittedGroups.add(turnId);
}
continue;
}
blocks.push({
type: "item",
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, finalAssistantIdByTurn, turnDiffs),
});
}
return blocks;
}
function moveActiveTaskProgressToEnd(items: DisplayItem[], activeTurnId: string): DisplayItem[] {
const activeTaskProgress = items.filter((item) => item.kind === "taskProgress" && item.turnId === activeTurnId);
if (activeTaskProgress.length === 0) return items;
return [...items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId), ...activeTaskProgress];
}
function shouldShowDisplayItem(item: DisplayItem): boolean {
return item.kind !== "reasoning" || executionState(item) !== "completed" || item.text.trim().length > 0;
}
function isCompletedTurnDetailItem(item: DisplayItem, finalAssistantIdByTurn: Map<string, string>): boolean {
if (!item.turnId || item.role === "user") return false;
return finalAssistantIdByTurn.get(item.turnId) !== item.id;
}
function finalAssistantItemsByTurn(items: DisplayItem[]): Map<string, string> {
const finalAssistantIdByTurn = new Map<string, string>();
for (const item of items) {
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
finalAssistantIdByTurn.set(item.turnId, item.id);
}
return finalAssistantIdByTurn;
}
function isFinalAssistantMessage(item: DisplayItem): boolean {
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
}
function itemWithTurnSummaries(
item: DisplayItem,
editedFilesByTurn: Map<string, string[]>,
autoReviewSummariesByTurn: Map<string, string[]>,
finalAssistantIdByTurn: Map<string, string>,
turnDiffs?: ReadonlyMap<string, string>,
): DisplayItem {
if (!item.turnId || finalAssistantIdByTurn.get(item.turnId) !== item.id) return item;
if (item.kind !== "message") return item;
const editedFiles = editedFilesByTurn.get(item.turnId);
const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId);
const diff = turnDiffs?.get(item.turnId);
const turnDiff = diff && diff.trim().length > 0 ? { diff } : undefined;
if ((!editedFiles || editedFiles.length === 0) && (!autoReviewSummaries || autoReviewSummaries.length === 0) && !turnDiff) return item;
return {
...item,
...(editedFiles && editedFiles.length > 0 ? { editedFiles } : {}),
...(turnDiff ? { turnDiff } : {}),
...(autoReviewSummaries && autoReviewSummaries.length > 0 ? { autoReviewSummaries } : {}),
};
}
function editedFilesForTurns(items: DisplayItem[], workspaceRoot?: string | null): Map<string, string[]> {
const byTurn = new Map<string, Set<string>>();
for (const item of items) {
if (!item.turnId || item.kind !== "fileChange") continue;
const files = editedFilesForItem(item, workspaceRoot);
if (files.length === 0) continue;
const set = byTurn.get(item.turnId) ?? new Set<string>();
files.forEach((file) => set.add(file));
byTurn.set(item.turnId, set);
}
return new Map([...byTurn].map(([turnId, files]) => [turnId, [...files].sort((a, b) => a.localeCompare(b))]));
}
function editedFilesForItem(item: DisplayItem, workspaceRoot?: string | null): string[] {
if (item.kind !== "fileChange") return [];
return item.changes.flatMap((change) =>
change.path && change.path !== "(unknown)" ? [pathRelativeToWorkspace(change.path, workspaceRoot)] : [],
);
}
function autoReviewSummariesForTurns(items: DisplayItem[]): Map<string, string[]> {
const byTurn = new Map<string, string[]>();
for (const item of items) {
if (!item.turnId || item.kind !== "reviewResult") continue;
const summary = item.text.trim();
if (!summary) continue;
const summaries = byTurn.get(item.turnId) ?? [];
if (!summaries.includes(summary)) summaries.push(summary);
byTurn.set(item.turnId, summaries);
}
return byTurn;
}
export function pathRelativeToWorkspace(path: string, workspaceRoot?: string | null): string {
return pathRelativeToRoot(path, workspaceRoot);
}
function turnActivitySummary(items: DisplayItem[]): string {
const parts = [
countMatchingLabel(items, (item) => item.kind === "message" && item.role === "assistant", "response", "responses"),
countLabel(items, "taskProgress", "task progress"),
countLabel(items, "agent", "agent"),
countLabel(items, "command", "command"),
countLabel(items, "fileChange", "file change"),
countLabel(items, "tool", "tool"),
countLabel(items, "hook", "hook"),
countLabel(items, "reasoning", "thought", "thought notes"),
countLabel(items, "approvalResult", "approval"),
countLabel(items, "userInputResult", "input"),
countLabel(items, "reviewResult", "review"),
].filter((part): part is string => Boolean(part));
if (parts.length === 0) return "Work details";
return `Work details: ${parts.join(", ")}`;
}
function countMatchingLabel(
items: DisplayItem[],
predicate: (item: DisplayItem) => boolean,
label: string,
pluralLabel = `${label}s`,
): string | null {
const count = items.filter(predicate).length;
if (count === 0) return null;
if (count === 1) return label;
return `${count} ${pluralLabel}`;
}
function countLabel(items: DisplayItem[], kind: DisplayKind, label: string, pluralLabel = `${label}s`): string | null {
const count = items.filter((item) => item.kind === kind).length;
if (count === 0) return null;
if (count === 1) return label;
return `${count} ${pluralLabel}`;
}
export function createSystemItem(text: string): DisplayItem {
return {
id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`,

6
src/display/plan.ts Normal file
View file

@ -0,0 +1,6 @@
export function normalizeProposedPlanMarkdown(text: string): string {
return text
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
.trim();
}

View file

@ -0,0 +1,199 @@
import type { DisplayFileChange, DisplayItem, DisplayKind } from "./types";
import { normalizeProposedPlanMarkdown } from "./plan";
export function upsertDisplayItem(items: DisplayItem[], next: DisplayItem): DisplayItem[] {
const index = items.findIndex((item) => item.id === next.id);
if (index === -1) return [...items, next];
const copy = [...items];
const previous = copy[index];
copy[index] = {
...previous,
...next,
output: mergeOutput(previous, next),
changes: mergeChanges(previous, next),
} as DisplayItem;
return copy;
}
function mergeOutput(previous: DisplayItem, next: DisplayItem): string | undefined {
const previousOutput = "output" in previous ? previous.output : undefined;
const nextOutput = "output" in next ? next.output : undefined;
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
}
function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChange[] | undefined {
const previousChanges = previous.kind === "fileChange" ? previous.changes : undefined;
const nextChanges = next.kind === "fileChange" ? next.changes : undefined;
return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges;
}
export function appendAssistantDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message"
? {
...item,
text: `${item.text}${delta}`,
copyText: `${item.text}${delta}`,
turnId: item.turnId ?? turnId,
markdown: false,
}
: item,
);
}
return [
...items,
{
id: itemId,
kind: "message",
role: "assistant",
text: delta,
copyText: delta,
turnId,
itemId,
markdown: false,
},
];
}
export function completeReasoningItems(items: DisplayItem[], turnId: string): DisplayItem[] {
return items.map((item) =>
item.kind === "reasoning" && item.turnId === turnId
? {
...item,
status: "completed",
state: "completed",
}
: item,
);
}
export function appendPlanDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && item.kind === "message" ? appendPlanDeltaToMessage(item, turnId, delta) : item,
);
}
const text = normalizeProposedPlanMarkdown(delta);
return [
...items,
{
id: itemId,
kind: "message",
role: "assistant",
text,
copyText: text,
turnId,
itemId,
markdown: false,
},
];
}
function appendPlanDeltaToMessage(item: Extract<DisplayItem, { kind: "message" }>, turnId: string, delta: string): DisplayItem {
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
return {
...item,
text,
copyText: text,
turnId: item.turnId ?? turnId,
markdown: false,
};
}
export function appendItemText(
items: DisplayItem[],
itemId: string,
turnId: string,
label: string,
delta: string,
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item));
}
return [
...items,
{
id: itemId,
kind,
role: "tool",
text: `${label}: ${delta}`,
turnId,
itemId,
},
];
}
export function appendToolOutput(
items: DisplayItem[],
itemId: string,
turnId: string,
delta: string,
fallbackLabel: string,
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning")
? { ...item, output: `${item.output ?? ""}${delta}` }
: item,
);
}
return [
...items,
{
id: itemId,
kind: "tool",
role: "tool",
text: "details",
toolLabel: fallbackLabel,
turnId,
itemId,
output: delta,
},
];
}
export function appendItemOutput(
items: DisplayItem[],
itemId: string,
turnId: string,
delta: string,
kind: "command" | "fileChange",
fallbackText: string,
): DisplayItem[] {
const index = items.findIndex((item) => item.itemId === itemId);
if (index !== -1) {
return items.map((item, itemIndex) =>
itemIndex === index && (item.kind === "command" || item.kind === "fileChange")
? { ...item, output: `${item.output ?? ""}${delta}` }
: item,
);
}
return [
...items,
{
id: itemId,
kind,
role: "tool",
text: fallbackText,
turnId,
itemId,
output: delta,
...(kind === "fileChange"
? {
status: "inProgress",
changes: [],
}
: {
command: fallbackText,
cwd: "(unknown)",
status: "running",
}),
},
] as DisplayItem[];
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,228 @@
import type { App, EventRef } from "obsidian";
import { isComposerSendKey } from "../composer/keys";
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "../composer/obsidian-context";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
composerSuggestionNavigationDirection,
composerSuggestionSignature,
nextComposerSuggestionIndex,
type ComposerSuggestion,
type NoteCandidate,
} from "../composer/suggestions";
import { userInputWithWikiLinkMentions } from "../composer/wikilink-context";
import type { UserInput } from "../generated/app-server/v2/UserInput";
import type { SendShortcut } from "../settings/model";
import { renderComposerShell, renderComposerSuggestions, syncComposerControls, syncComposerHeight } from "../ui/composer";
import type { PanelState } from "./state";
export interface PanelComposerControllerOptions {
app: App;
state: PanelState;
viewId: string;
sendShortcut: () => SendShortcut;
canInterrupt: () => boolean;
renderIfDetached: () => void;
onSubmit: () => void;
onNewThread: () => void;
}
export class PanelComposerController {
private composer: HTMLTextAreaElement | null = null;
private suggestionsEl: HTMLElement | null = null;
private noteCandidatesCache: NoteCandidate[] | null = null;
private noteEventsRegistered = false;
constructor(private readonly options: PanelComposerControllerOptions) {}
get trimmedDraft(): string {
return this.composer?.value.trim() ?? this.options.state.composerDraft.trim();
}
registerNoteIndexInvalidation(registerEvent: (eventRef: EventRef) => void): void {
if (this.noteEventsRegistered) return;
this.noteEventsRegistered = true;
const invalidate = () => {
this.noteCandidatesCache = null;
};
registerEvent(this.options.app.vault.on("create", invalidate));
registerEvent(this.options.app.vault.on("delete", invalidate));
registerEvent(this.options.app.vault.on("rename", invalidate));
registerEvent(this.options.app.vault.on("modify", invalidate));
}
render(parent: HTMLElement): void {
if (this.composer && parent.contains(this.composer)) {
return;
}
const elements = renderComposerShell(parent, this.options.viewId, this.options.state.composerDraft, this.options.state.busy, {
onInput: () => {
this.options.state.composerDraft = this.composer?.value ?? "";
this.options.state.composerSuggestionsDismissedSignature = null;
this.updateSuggestions();
this.syncControls(parent);
},
onUpdateSuggestions: () => this.updateSuggestions(),
onKeydown: (event) => {
if (this.handleSuggestionKeydown(event)) {
return;
}
if (isComposerSendKey(event, this.options.sendShortcut())) {
event.preventDefault();
this.options.onSubmit();
}
},
onNewThread: () => this.options.onNewThread(),
onSendOrInterrupt: () => this.options.onSubmit(),
onSuggestionHover: (index) => this.selectSuggestion(index),
onSuggestionInsert: (suggestion) => this.insertSuggestion(suggestion),
});
this.composer = elements.composer;
this.suggestionsEl = elements.suggestions;
this.updateSuggestions();
}
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void {
this.options.state.composerDraft = text;
if (options.clearSuggestions) this.clearSuggestions();
if (!this.composer) {
if (options.renderIfDetached) this.options.renderIfDetached();
return;
}
this.composer.value = text;
syncComposerHeight(this.composer);
if (options.focus) this.composer.focus();
}
syncControls(parent: HTMLElement | null): void {
syncComposerControls(parent, this.composer, this.options.state.busy, this.options.canInterrupt());
}
codexInput(text: string): UserInput[] {
return userInputWithWikiLinkMentions(text, (target) => resolveAppWikiLinkMention(this.options.app, target));
}
private handleSuggestionKeydown(event: KeyboardEvent): boolean {
if (event.isComposing) return false;
if (this.options.state.composerSuggestions.length === 0) return false;
const direction = composerSuggestionNavigationDirection(event);
if (direction) {
event.preventDefault();
this.options.state.composerSuggestSelected = nextComposerSuggestionIndex(
this.options.state.composerSuggestSelected,
this.options.state.composerSuggestions.length,
direction,
);
this.renderSuggestions();
return true;
}
if (event.metaKey || event.ctrlKey) return false;
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
this.insertSuggestion(this.options.state.composerSuggestions[this.options.state.composerSuggestSelected]);
return true;
}
if (event.key === "Escape") {
event.preventDefault();
this.dismissSuggestions();
return true;
}
return false;
}
private updateSuggestions(): void {
if (!this.composer) {
this.clearSuggestions();
return;
}
const cursor = this.composer.selectionStart;
const signature = this.suggestionSignature();
if (this.options.state.composerSuggestionsDismissedSignature === signature) {
this.options.state.composerSuggestions = [];
this.renderSuggestions();
return;
}
const beforeCursor = this.composer.value.slice(0, cursor);
const suggestions = activeComposerSuggestions(
beforeCursor,
this.noteCandidates(),
this.options.state.availableSkills,
this.options.state.listedThreads,
);
this.options.state.composerSuggestions = suggestions;
if (this.options.state.composerSuggestSelected >= this.options.state.composerSuggestions.length) {
this.options.state.composerSuggestSelected = 0;
}
this.renderSuggestions();
}
private renderSuggestions(): void {
renderComposerSuggestions(
this.suggestionsEl,
this.composer,
this.options.viewId,
this.options.state.composerSuggestions,
this.options.state.composerSuggestSelected,
{
onSuggestionHover: (index) => this.selectSuggestion(index),
onSuggestionInsert: (suggestion) => this.insertSuggestion(suggestion),
},
);
}
private selectSuggestion(index: number): void {
if (this.options.state.composerSuggestSelected === index) return;
this.options.state.composerSuggestSelected = index;
this.renderSuggestions();
}
private insertSuggestion(suggestion: ComposerSuggestion | undefined): void {
if (!this.composer || !suggestion) return;
const cursor = this.composer.selectionStart;
const value = this.composer.value;
const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion);
this.options.state.composerDraft = insertion.value;
this.composer.value = insertion.value;
syncComposerHeight(this.composer);
this.composer.focus();
this.composer.setSelectionRange(insertion.cursor, insertion.cursor);
this.clearSuggestions();
}
private clearSuggestions(): void {
this.options.state.composerSuggestSelected = 0;
this.options.state.composerSuggestions = [];
this.composer?.setAttr("aria-expanded", "false");
this.composer?.removeAttribute("aria-activedescendant");
this.suggestionsEl?.empty();
this.suggestionsEl?.hide();
}
private dismissSuggestions(): void {
this.options.state.composerSuggestionsDismissedSignature = this.suggestionSignature();
this.clearSuggestions();
}
private suggestionSignature(): string | null {
if (!this.composer) return null;
return composerSuggestionSignature(this.composer.value, this.composer.selectionStart);
}
private noteCandidates(): NoteCandidate[] {
if (!this.noteCandidatesCache) {
this.noteCandidatesCache = appNoteCandidates(this.options.app);
}
return this.noteCandidatesCache;
}
}

View file

@ -1,13 +1,15 @@
import { approvalResponse, toPendingApproval, type ApprovalAction, type PendingApproval } from "../approvals/model";
import { createAutoReviewResultItem, createReviewResultItem } from "../display/model";
import {
appendAssistantDelta,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
createAutoReviewResultItem,
completeReasoningItems,
createReviewResultItem,
upsertDisplayItem,
} from "../display/stream-updates";
import {
createSystemItem,
displayItemFromThreadItem,
displayItemsFromTurns,
@ -15,7 +17,6 @@ import {
planProgressDisplayItem,
shouldSuppressLifecycleItem,
shouldSuppressThreadItem,
upsertDisplayItem,
} from "../display/model";
import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../display/types";
import type { RequestId } from "../generated/app-server/RequestId";
@ -23,7 +24,7 @@ import type { ServerNotification } from "../generated/app-server/ServerNotificat
import type { ServerRequest } from "../generated/app-server/ServerRequest";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { clearActiveThreadState, type PanelState } from "../state/panel-state";
import { clearActiveThreadState, type PanelState } from "./state";
import { toPendingUserInput, userInputResponse, type PendingUserInput } from "../user-input/model";
import { jsonPreview } from "../utils";
import { classifyAppServerLog } from "./app-server-logs";

View file

@ -0,0 +1,122 @@
import { MarkdownRenderer, type App, type Component } from "obsidian";
import type { DisplayItem } from "../display/types";
import { copyTextWithNotice } from "../ui/clipboard";
import { renderTextWithWikiLinks as renderInlineWikiLinks } from "../ui/dom";
import { messageRenderBlocks, syncMessageRenderBlocks } from "../ui/message-stream";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../ui/scroll";
import type { TurnDiffViewState } from "../ui/turn-diff";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import type { PanelState } from "./state";
export interface PanelMessageRendererOptions {
app: App;
owner: Component;
state: PanelState;
vaultPath: string;
blockSignatures: Map<string, string>;
consumeForceScrollToBottom: () => boolean;
loadOlderTurns: () => void;
rollbackThread: (threadId: string) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
pendingRequestsSignature: () => string;
renderPendingRequests: () => HTMLElement | null;
}
export class PanelMessageRenderer {
constructor(private readonly options: PanelMessageRendererOptions) {}
render(parent: HTMLElement): void {
const { state } = this.options;
const messagesEl = parent.querySelector<HTMLElement>(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" });
messagesEl.onscroll = () => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
};
const wasNearBottom = isNearScrollBottom(messagesEl);
const shouldScrollToBottom = this.options.consumeForceScrollToBottom() || wasNearBottom;
const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl);
state.messagesPinnedToBottom = shouldScrollToBottom;
const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems);
const blocks = messageRenderBlocks({
activeThreadId: state.activeThreadId,
activeTurnId: state.activeTurnId,
historyCursor: state.historyCursor,
loadingHistory: state.loadingHistory,
busy: state.busy,
displayItems: state.displayItems,
turnDiffs: state.turnDiffs,
workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath,
openDetails: state.openDetails,
onDetailsToggle: () => {
window.requestAnimationFrame(() => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
});
},
loadOlderTurns: () => this.options.loadOlderTurns(),
renderMarkdown: (element, text) => this.renderMarkdownMessage(element, text),
renderTextWithWikiLinks: (element, text) => this.renderTextWithWikiLinks(element, text),
copyText: (text) => void this.copyMessageText(text),
canRollbackItem: (item: DisplayItem) => isRollbackCandidateItem(item, rollbackCandidate),
onRollbackItem: () => {
if (state.activeThreadId) this.options.rollbackThread(state.activeThreadId);
},
openTurnDiff: (turnDiffState) => this.options.openTurnDiff(turnDiffState),
pendingRequestsSignature: this.options.pendingRequestsSignature(),
renderPendingRequests: () => this.options.renderPendingRequests(),
});
syncMessageRenderBlocks(messagesEl, blocks, this.options.blockSignatures);
window.requestAnimationFrame(() => {
if (shouldScrollToBottom) {
messagesEl.scrollTop = bottomScrollTop(messagesEl);
} else {
restoreScrollAnchor(messagesEl, scrollAnchor);
}
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
});
}
private async copyMessageText(text: string): Promise<void> {
await copyTextWithNotice(text, "Copied message.", "Could not copy message.");
}
private renderMarkdownMessage(parent: HTMLElement, text: string): void {
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
void MarkdownRenderer.render(this.options.app, text, parent, sourcePath, this.options.owner).then(() => {
this.bindRenderedWikiLinks(parent, sourcePath);
this.scrollMarkdownMessageIntoPinnedBottom(parent);
});
}
private scrollMarkdownMessageIntoPinnedBottom(parent: HTMLElement): void {
if (!this.options.state.messagesPinnedToBottom) return;
const messagesEl = parent.closest<HTMLElement>(".codex-panel__messages");
if (!messagesEl) return;
window.requestAnimationFrame(() => {
if (!this.options.state.messagesPinnedToBottom) return;
messagesEl.scrollTop = bottomScrollTop(messagesEl);
this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
});
}
private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void {
parent.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((link) => {
link.addClass("codex-panel__wikilink");
link.onclick = (event) => {
event.preventDefault();
const target = link.getAttribute("data-href") ?? link.getAttribute("href") ?? link.textContent ?? "";
if (target.trim().length > 0) {
void this.options.app.workspace.openLinkText(target, sourcePath, false);
}
};
});
}
private renderTextWithWikiLinks(parent: HTMLElement, text: string): void {
renderInlineWikiLinks(parent, text, (target) => {
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
void this.options.app.workspace.openLinkText(target, sourcePath, false);
});
}
}

View file

@ -1,6 +1,6 @@
import type { AppServerClient } from "../app-server/client";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "./runtime-state";
import type { PanelState } from "../state/panel-state";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../runtime/state";
import type { PanelState } from "./state";
export interface PanelSessionControllerHost {
state: PanelState;

View file

@ -1,30 +1,14 @@
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { Thread } from "../generated/app-server/v2/Thread";
import { getThreadTitle } from "../threads";
import { modelOverrideMessage, parseModelOverride, parseReasoningEffortOverride, reasoningEffortOverrideMessage } from "./runtime-settings";
export const SLASH_COMMANDS = [
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
{ command: "/resume", detail: "Resume a recent Codex thread." },
{ command: "/fork", detail: "Fork the active Codex thread." },
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
{ command: "/compact", detail: "Compact the current conversation context." },
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
{ command: "/status", detail: "Show current session, context, and usage limits." },
{ command: "/doctor", detail: "Show Codex CLI and app-server connection diagnostics." },
{ command: "/model", detail: "Show or set the model for subsequent turns." },
{ command: "/effort", detail: "Show or set reasoning effort for subsequent turns." },
{ command: "/help", detail: "Show available Codex slash commands." },
] as const;
type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"];
export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : never;
export function slashCommandHelpLines(): string[] {
return SLASH_COMMANDS.map((item) => `${item.command} - ${item.detail}`);
}
import { getThreadTitle } from "../threads/model";
import { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
import {
modelOverrideMessage,
parseModelOverride,
parseReasoningEffortOverride,
reasoningEffortOverrideMessage,
} from "../runtime/settings";
export { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
export interface SlashCommandExecutionContext {
activeThreadId: string | null;

View file

@ -14,7 +14,7 @@ import type { ComposerSuggestion } from "../composer/suggestions";
import type { DisplayItem } from "../display/types";
import type { PendingUserInput } from "../user-input/model";
import type { ServiceTier } from "../app-server/service-tier";
import { defaultRuntimeOverride, type RuntimeOverride } from "../panel/runtime-state";
import { defaultRuntimeOverride, type RuntimeOverride } from "../runtime/state";
export interface PanelState {
status: string;

View file

@ -1,4 +1,4 @@
import type { RateLimitSummary } from "./runtime-view";
import type { RateLimitSummary } from "../runtime/view";
export function statusValue(value: unknown, fallback: string): string {
if (typeof value === "string") return value;

View file

@ -1,6 +1,6 @@
import type { AppServerClient } from "../app-server/client";
import { displayItemsFromTurns } from "../display/model";
import type { PanelState } from "../state/panel-state";
import type { PanelState } from "./state";
export interface ThreadHistoryLoaderHost {
state: PanelState;

View file

@ -8,7 +8,7 @@ import type { SortDirection } from "../generated/app-server/v2/SortDirection";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { inputToText, truncate } from "../utils";
import { findModelByIdOrName, supportedEffortsForModel } from "./model-runtime";
import { findModelByIdOrName, supportedEffortsForModel } from "../runtime/model";
const NAMING_SERVICE_NAME = "codex-panel-naming";
const NAMING_TIMEOUT_MS = 60_000;

View file

@ -1,9 +1,9 @@
import type { AppServerClient } from "../app-server/client";
import type { Thread } from "../generated/app-server/v2/Thread";
import type { Turn } from "../generated/app-server/v2/Turn";
import type { CodexPanelSettings } from "../settings";
import type { PanelState } from "../state/panel-state";
import { getThreadTitle } from "../threads";
import type { CodexPanelSettings } from "../settings/model";
import type { PanelState } from "./state";
import { getThreadTitle } from "../threads/model";
import {
findThreadNamingContext,
generateThreadTitleWithCodex,

View file

@ -0,0 +1,72 @@
import { ItemView, type ViewStateResult } from "obsidian";
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../constants";
import { copyTextWithNotice } from "../ui/clipboard";
import {
isPersistedTurnDiffViewState,
persistedTurnDiffViewState,
renderTurnDiffView,
type PersistedTurnDiffViewState,
type TurnDiffViewState,
} from "../ui/turn-diff";
export class CodexTurnDiffView extends ItemView {
private metadata: PersistedTurnDiffViewState | null = null;
private payload: TurnDiffViewState | null = null;
getViewType(): string {
return VIEW_TYPE_CODEX_TURN_DIFF;
}
getDisplayText(): string {
return "Codex turn diff";
}
getIcon(): string {
return "file-diff";
}
getState(): Record<string, unknown> {
return this.metadata ? { ...this.metadata } : {};
}
async setState(state: unknown, result: ViewStateResult): Promise<void> {
await super.setState(state, result);
this.metadata = isPersistedTurnDiffViewState(state)
? {
threadId: state.threadId,
turnId: state.turnId,
cwd: state.cwd,
files: [...state.files],
}
: null;
this.payload = null;
this.render();
}
async onOpen(): Promise<void> {
this.render();
}
setDiffPayload(payload: TurnDiffViewState): void {
this.metadata = persistedTurnDiffViewState(payload);
this.payload = payload;
this.render();
}
private render(): void {
const root = this.contentEl;
renderTurnDiffView(
root,
this.payload,
{
copyDiff: this.payload ? () => void this.copyDiff(this.payload?.diff ?? "") : undefined,
},
this.metadata,
);
}
private async copyDiff(diff: string): Promise<void> {
await copyTextWithNotice(diff, "Copied diff.", "Could not copy diff.");
}
}

1026
src/panel/view.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import { isReasoningEffort } from "./model-runtime";
import { isReasoningEffort } from "./model";
const DEFAULT_ALIASES = new Set(["default", "reset", "clear", "off"]);

View file

@ -8,9 +8,9 @@ import type { ThreadTokenUsage } from "../generated/app-server/v2/ThreadTokenUsa
import type { DisplayItem } from "../display/types";
import { parseServiceTier, serviceTierRequestValue, type ServiceTier, type ServiceTierRequest } from "../app-server/service-tier";
import { defaultCollaborationMode, planCollaborationMode } from "./collaboration-mode";
import { findModelByIdOrName, isReasoningEffort, supportedEffortsForModel } from "./model-runtime";
import { compactModelLabel, compactReasoningEffortLabel } from "./runtime-settings";
export { sortedAvailableModels } from "./model-runtime";
import { findModelByIdOrName, isReasoningEffort, supportedEffortsForModel } from "./model";
import { compactModelLabel, compactReasoningEffortLabel } from "./settings";
export { sortedAvailableModels } from "./model";
export type RuntimeOverride<T> = { kind: "default" } | { kind: "set"; value: T } | { kind: "resetPending" };

View file

@ -9,7 +9,7 @@ import {
runtimeOverrideLabel,
serviceTierLabel,
type RuntimeSnapshot,
} from "./runtime-state";
} from "./state";
export interface ContextSummary {
label: string;

View file

@ -1,8 +1,8 @@
import type { AppServerClient } from "./app-server/client";
import type { HookMetadata } from "./generated/app-server/v2/HookMetadata";
import type { Model } from "./generated/app-server/v2/Model";
import type { Thread } from "./generated/app-server/v2/Thread";
import { errorMessage } from "./utils";
import type { AppServerClient } from "../app-server/client";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { Model } from "../generated/app-server/v2/Model";
import type { Thread } from "../generated/app-server/v2/Thread";
import { errorMessage } from "../utils";
export interface LoadedHooks {
hooks: HookMetadata[];

View file

@ -0,0 +1,154 @@
import { Setting } from "obsidian";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import { archivedThreadDisplayTitle, fullThreadTitle } from "../threads/model";
import { shortThreadId } from "../utils";
export interface ArchivedThreadSectionState {
threads: Thread[];
loaded: boolean;
loading: boolean;
status: string;
onRestore(threadId: string): void;
}
export interface HookSectionState {
hooks: HookMetadata[];
warnings: string[];
errors: string[];
loaded: boolean;
loading: boolean;
status: string;
onTrust(hook: HookMetadata): void;
onToggleEnabled(hook: HookMetadata, enabled: boolean): void;
}
export function renderHookSection(containerEl: HTMLElement, state: HookSectionState): void {
const section = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
new Setting(section)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Hook status")
.setDesc("Review hooks discovered by Codex app-server for the current vault root, including trust and enabled state.");
if (state.loading) {
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: "Loading hooks..." });
} else if (state.loaded) {
renderHooks(section, state);
} else if (state.status) {
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: state.status });
}
}
export function renderArchivedThreadSection(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
const section = containerEl.createDiv({
cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
});
new Setting(section)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Archived thread list")
.setDesc("Restore archived Codex threads to chat history when they are needed again.");
if (state.loading) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "Loading archived threads...",
});
} else if (state.loaded && state.threads.length === 0) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "No archived threads.",
});
} else if (state.loaded) {
renderArchivedThreadList(section, state);
} else if (state.status) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: state.status,
});
}
}
function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${state.threads.length} archived thread${state.threads.length === 1 ? "" : "s"} from Codex app-server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
for (const thread of state.threads) {
const title = archivedThreadDisplayTitle(thread);
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(title)
.setDesc(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
.addExtraButton((button) => {
button.setIcon("rotate-ccw").onClick(() => state.onRestore(thread.id));
button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
});
setting.settingEl.addClass("codex-panel-settings__archived-row");
setting.settingEl.setAttr("title", fullThreadTitle(thread));
}
}
function renderHooks(containerEl: HTMLElement, state: HookSectionState): void {
if (state.hooks.length === 0) {
containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks discovered for the current vault root." });
} else {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${state.hooks.length} hook${state.hooks.length === 1 ? "" : "s"} from Codex app-server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
for (const hook of state.hooks) {
renderHookRow(list, hook, state);
}
}
for (const warning of state.warnings) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
}
for (const error of state.errors) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
}
}
function renderHookRow(list: HTMLElement, hook: HookMetadata, state: HookSectionState): void {
const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(hook.statusMessage || hook.command || hook.matcher || hook.eventName)
.setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
.addButton((button) => {
button
.setButtonText("Trust")
.setDisabled(state.loading || !canTrust)
.onClick(() => state.onTrust(hook));
})
.addButton((button) => {
button
.setButtonText(hook.enabled ? "Disable" : "Enable")
.setDisabled(state.loading || hook.isManaged)
.onClick(() => state.onToggleEnabled(hook, !hook.enabled));
});
setting.settingEl.addClass("codex-panel-settings__hook-row");
setting.settingEl.setAttr("title", hook.command ?? hook.key);
setting.descEl.createDiv({
cls: "codex-panel-settings__hook-hash",
text: hook.currentHash,
attr: { title: hook.key },
});
}
function formatThreadDate(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
return new Date(timestamp * 1000).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

View file

@ -1,8 +1,8 @@
import { FileSystemAdapter, type App } from "obsidian";
import { DEFAULT_CODEX_PATH } from "./constants";
import type { ReasoningEffort } from "./generated/app-server/ReasoningEffort";
import { normalizeReasoningEffort } from "./panel/model-runtime";
import { DEFAULT_CODEX_PATH } from "../constants";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import { normalizeReasoningEffort } from "../runtime/model";
export interface CodexPanelSettings {
codexPath: string;

View file

@ -1,17 +1,18 @@
import { type App, Notice, PluginSettingTab, Setting } from "obsidian";
import type { AppServerClient } from "./app-server/client";
import { withAppServerSession } from "./app-server/session-client";
import { DEFAULT_CODEX_PATH } from "./constants";
import type { ReasoningEffort } from "./generated/app-server/ReasoningEffort";
import type { HookMetadata } from "./generated/app-server/v2/HookMetadata";
import type { Model } from "./generated/app-server/v2/Model";
import type { Thread } from "./generated/app-server/v2/Thread";
import type CodexPanelPlugin from "./main";
import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supportedEffortsForModel } from "./panel/model-runtime";
import { loadHookData, loadSettingsData } from "./settings-data";
import { archivedThreadDisplayTitle, fullThreadTitle } from "./threads";
import { errorMessage, shortThreadId } from "./utils";
import type { AppServerClient } from "../app-server/client";
import { withAppServerSession } from "../app-server/session-client";
import { DEFAULT_CODEX_PATH } from "../constants";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { Model } from "../generated/app-server/v2/Model";
import type { Thread } from "../generated/app-server/v2/Thread";
import type CodexPanelPlugin from "../main";
import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supportedEffortsForModel } from "../runtime/model";
import { archivedThreadDisplayTitle } from "../threads/model";
import { errorMessage } from "../utils";
import { loadHookData, loadSettingsData } from "./data";
import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections";
const CODEX_DEFAULT_VALUE = "__codex-default__";
const SEND_SHORTCUT_LABELS = {
@ -139,48 +140,24 @@ export class CodexPanelSettingTab extends PluginSettingTab {
});
}
const hookSection = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
new Setting(hookSection)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Hook status")
.setDesc("Review hooks discovered by Codex app-server for the current vault root, including trust and enabled state.");
if (this.hooksLoading) {
hookSection.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: "Loading hooks..." });
} else if (this.hooksLoaded) {
this.renderHooks(hookSection);
} else if (this.hooksStatus) {
hookSection.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: this.hooksStatus });
}
const archivedSection = containerEl.createDiv({
cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
renderHookSection(containerEl, {
hooks: this.hooks,
warnings: this.hookWarnings,
errors: this.hookErrors,
loaded: this.hooksLoaded,
loading: this.hooksLoading,
status: this.hooksStatus,
onTrust: (hook) => void this.trustHook(hook),
onToggleEnabled: (hook, enabled) => void this.setHookEnabled(hook, enabled),
});
new Setting(archivedSection)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Archived thread list")
.setDesc("Restore archived Codex threads to chat history when they are needed again.");
if (this.archivedThreadsLoading) {
archivedSection.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "Loading archived threads...",
});
} else if (this.archivedThreadsLoaded && this.archivedThreads.length === 0) {
archivedSection.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "No archived threads.",
});
} else if (this.archivedThreadsLoaded) {
this.renderArchivedThreadList(archivedSection);
} else if (this.archivedThreadsStatus) {
archivedSection.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: this.archivedThreadsStatus,
});
}
renderArchivedThreadSection(containerEl, {
threads: this.archivedThreads,
loaded: this.archivedThreadsLoaded,
loading: this.archivedThreadsLoading,
status: this.archivedThreadsStatus,
onRestore: (threadId) => void this.restoreArchivedThread(threadId),
});
this.maybeAutoLoadSettingsData();
}
@ -341,86 +318,4 @@ export class CodexPanelSettingTab extends PluginSettingTab {
private selectedNamingModel(): Model | null {
return findModelByIdOrName(this.namingModels, this.plugin.settings.threadNamingModel);
}
private renderArchivedThreadList(containerEl: HTMLElement): void {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${this.archivedThreads.length} archived thread${this.archivedThreads.length === 1 ? "" : "s"} from Codex app-server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
for (const thread of this.archivedThreads) {
const title = archivedThreadDisplayTitle(thread);
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(title)
.setDesc(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
.addExtraButton((button) => {
button.setIcon("rotate-ccw").onClick(() => void this.restoreArchivedThread(thread.id));
button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
});
setting.settingEl.addClass("codex-panel-settings__archived-row");
setting.settingEl.setAttr("title", fullThreadTitle(thread));
}
}
private renderHooks(containerEl: HTMLElement): void {
if (this.hooks.length === 0) {
containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks discovered for the current vault root." });
} else {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${this.hooks.length} hook${this.hooks.length === 1 ? "" : "s"} from Codex app-server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
for (const hook of this.hooks) {
this.renderHookRow(list, hook);
}
}
for (const warning of this.hookWarnings) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
}
for (const error of this.hookErrors) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
}
}
private renderHookRow(list: HTMLElement, hook: HookMetadata): void {
const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(hook.statusMessage || hook.command || hook.matcher || hook.eventName)
.setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
.addButton((button) => {
button
.setButtonText("Trust")
.setDisabled(this.hooksLoading || !canTrust)
.onClick(() => void this.trustHook(hook));
})
.addButton((button) => {
button
.setButtonText(hook.enabled ? "Disable" : "Enable")
.setDisabled(this.hooksLoading || hook.isManaged)
.onClick(() => void this.setHookEnabled(hook, !hook.enabled));
});
setting.settingEl.addClass("codex-panel-settings__hook-row");
setting.settingEl.setAttr("title", hook.command ?? hook.key);
setting.descEl.createDiv({
cls: "codex-panel-settings__hook-hash",
text: hook.currentHash,
attr: { title: hook.key },
});
}
}
function formatThreadDate(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
return new Date(timestamp * 1000).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

View file

@ -1,5 +1,5 @@
import type { Thread } from "./generated/app-server/v2/Thread";
import { shortThreadId } from "./utils";
import type { Thread } from "../generated/app-server/v2/Thread";
import { shortThreadId } from "../utils";
const MAX_THREAD_DISPLAY_TITLE_LENGTH = 96;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View file

@ -1,4 +1,4 @@
import type { EffectiveConfigSection } from "../panel/runtime-view";
import type { EffectiveConfigSection } from "../runtime/view";
import { createDefinitionRow } from "./components";
export function renderEffectiveConfig(parent: HTMLElement, sections: EffectiveConfigSection[]): void {

View file

@ -1,4 +1,5 @@
import { displayBlocksForItems, executionState } from "../display/model";
import { displayBlocksForItems } from "../display/blocks";
import { executionState } from "../display/model";
import { displayItemSignature } from "../display/signature";
import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types";
import { createIconButton, createMetaPair, createRememberedDetails } from "./components";

View file

@ -1,6 +1,6 @@
import { setIcon } from "obsidian";
import type { EffectiveConfigSection, RateLimitSummary } from "../panel/runtime-view";
import type { EffectiveConfigSection, RateLimitSummary } from "../runtime/view";
import { createToolbarButton } from "./components";
import { renderEffectiveConfig } from "./config";

View file

@ -6,7 +6,7 @@ import {
defaultCollaborationMode,
nextCollaborationMode,
planCollaborationMode,
} from "../src/panel/collaboration-mode";
} from "../src/runtime/collaboration-mode";
describe("collaboration mode", () => {
it("toggles between Default and Plan mode", () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { PanelController } from "../src/panel/controller";
import { createPanelState } from "../src/state/panel-state";
import { createPanelState } from "../src/panel/state";
import type { ServerNotification } from "../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../src/generated/app-server/ServerRequest";
import type { Thread } from "../src/generated/app-server/v2/Thread";

View file

@ -8,7 +8,7 @@ import {
parseModelOverride,
parseReasoningEffortOverride,
reasoningEffortOverrideMessage,
} from "../src/panel/runtime-settings";
} from "../src/runtime/settings";
import {
currentModel,
currentReasoningEffort,
@ -19,8 +19,8 @@ import {
setRuntimeOverride,
serviceTierLabel,
type RuntimeSnapshot,
} from "../src/panel/runtime-state";
import { contextSummary, rateLimitSummary } from "../src/panel/runtime-view";
} from "../src/runtime/state";
import { contextSummary, rateLimitSummary } from "../src/runtime/view";
describe("runtime settings", () => {
it("parses model overrides", () => {

View file

@ -5,9 +5,9 @@ import type { Thread } from "../src/generated/app-server/v2/Thread";
import type { HookMetadata } from "../src/generated/app-server/v2/HookMetadata";
import type { Model } from "../src/generated/app-server/v2/Model";
import type { ReasoningEffort } from "../src/generated/app-server/ReasoningEffort";
import { findModelByIdOrName, sortedAvailableModels, supportedEffortsForModel } from "../src/panel/model-runtime";
import { CodexPanelSettingTab } from "../src/settings-tab";
import { archivedThreadDisplayTitle } from "../src/threads";
import { findModelByIdOrName, sortedAvailableModels, supportedEffortsForModel } from "../src/runtime/model";
import { CodexPanelSettingTab } from "../src/settings/tab";
import { archivedThreadDisplayTitle } from "../src/threads/model";
import { notices } from "./mocks/obsidian";
const require = createRequire(import.meta.url);

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { FileSystemAdapter, type App } from "obsidian";
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData } from "../src/settings";
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData } from "../src/settings/model";
describe("settings", () => {
it("normalizes empty data", () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { statusValue, usageLimitStatusLines } from "../src/panel/status-lines";
import type { RateLimitSummary } from "../src/panel/runtime-view";
import type { RateLimitSummary } from "../src/runtime/view";
describe("status line helpers", () => {
it("formats primitive and structured diagnostic values", () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../src/generated/app-server/v2/Thread";
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../src/threads";
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../src/threads/model";
describe("thread helpers", () => {
it("formats Codex panel display titles from the active thread", () => {

View file

@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderTextWithWikiLinks, shortSignature } from "../src/view/dom";
import { renderTextWithWikiLinks, shortSignature } from "../src/ui/dom";
declare global {
interface HTMLElement {

View file

@ -10,12 +10,12 @@ import {
scrollComposerSuggestionIntoView,
syncComposerControls,
syncComposerHeight,
} from "../src/view/composer";
import { renderPendingRequestMessage } from "../src/view/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../src/view/toolbar";
} from "../src/ui/composer";
import { renderPendingRequestMessage } from "../src/ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../src/ui/toolbar";
import { displayItemSignature } from "../src/display/signature";
import { messageRenderBlocks, syncMessageRenderBlocks } from "../src/view/message-stream";
import { displayDiffLines, persistedTurnDiffViewState, renderTurnDiffView } from "../src/view/turn-diff";
import { messageRenderBlocks, syncMessageRenderBlocks } from "../src/ui/message-stream";
import { displayDiffLines, persistedTurnDiffViewState, renderTurnDiffView } from "../src/ui/turn-diff";
declare global {
function createDiv(options?: { cls?: string; text?: string; attr?: Record<string, string> }): HTMLDivElement;
@ -220,7 +220,7 @@ describe("view renderers", () => {
rows: [
{ key: "status", value: "approved" },
{ key: "action", value: "apply patch" },
{ key: "files", value: "src/display/tool-view.ts\nsrc/view/message-stream.ts" },
{ key: "files", value: "src/display/tool-view.ts\nsrc/ui/message-stream.ts" },
],
},
],
@ -242,7 +242,7 @@ describe("view renderers", () => {
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch");
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain(
"filessrc/display/tool-view.ts\nsrc/view/message-stream.ts",
"filessrc/display/tool-view.ts\nsrc/ui/message-stream.ts",
);
expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]);
});

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../src/view/scroll";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../src/ui/scroll";
describe("message scroll helpers", () => {
it("detects whether the transcript is pinned near the bottom", () => {