feat: task persistence, memoized UI components, hook/delete-failure safety

locode-authored (dogfooded) changes:
- TaskStore.toJSON/fromJSON: task_create/list state now survives session
  resume instead of resetting to empty (session.ts, task.ts, sessionStore.ts).
- StatusBar/ChatInput/ThinkingIndicator wrapped in memo() to skip re-renders
  during the ~30fps streaming-text update loop.
- HistoryItemView's todos list keys off t.content instead of array index
  (TodoItem has no id field; content is the best stable key available).
- sessionStore.ts read paths now log (console.warn) on corrupt/unreadable
  files instead of silently swallowing the error.

Fixes on top, found in review:
- safeRunHooksForEvent() wraps the 4 remaining unprotected hook call sites
  (PreToolUse, PermissionRequest, PostToolUse, SubagentStart) — these fire on
  every tool call/sub-agent, far more often than the 3 sites already wrapped
  (Stop/SessionStart/UserPromptSubmit), so a broken hook script was still able
  to abort an in-flight turn.
- deleteSession() no longer reports success when unlinkSync fails for a
  reason other than ENOENT (e.g. EPERM/EBUSY from a Windows file lock) — it
  used to drop the index entry and return true anyway, orphaning the file on
  disk with no way to reference it again. `sessions rm` in cli.ts now
  distinguishes "not found" from "found but couldn't delete" in its message.
- ChatInput's memo() was a no-op: onSubmit={handleSubmit} passed a fresh
  closure every render since handleSubmit isn't wrapped in useCallback.
  Rather than force a ~300-line function (which reads isThinking/streamingText
  — both changing every streaming frame) into a dependency array, added a
  handleSubmitRef + stable wrapper, the same ref-indirection this file
  already uses for phaseRef.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxiAaGhSD4DRVYYQZ5GJjm
This commit is contained in:
kim
2026-08-24 15:02:34 +09:00
co-authored by Claude Sonnet 5
parent 5bf60c7921
commit c649fdf6ed
12 changed files with 193 additions and 47 deletions
+61 -20
View File
@@ -29,12 +29,37 @@ function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
}
}
/** Wraps runHooksForEvent for the events that fire on every tool call (PreToolUse, PostToolUse,
* PermissionRequest) or every sub-agent (SubagentStart) — same "hooks must not derail things"
* reasoning as fireStopHook/fireSessionStartHook/fireUserPromptSubmitHook, but those wrap a single
* dedicated call site each; this one is shared since these fire from inline code in gateAndRun and
* runSubAgentTurn rather than their own named functions. A broken hook (bad command, malformed
* hooks.json) must not block a tool call or sub-agent it never got to properly evaluate. */
async function safeRunHooksForEvent(
...args: Parameters<typeof runHooksForEvent>
): Promise<Awaited<ReturnType<typeof runHooksForEvent>>> {
try {
return await runHooksForEvent(...args);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${args[0]}] hook error (non-fatal):`, err);
return { blocked: false, warnings: [] };
}
}
/** Stop fires once the assistant has produced its final answer for a turn — informational only
* (e.g. a desktop notification or logging hook); unlike PreToolUse it can't block, since there's
* nothing left in this turn to prevent. */
async function fireStopHook(session: Session, emit: AgentEventHandler, finalText: string): Promise<void> {
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
emitHookWarnings(result.warnings, emit);
try {
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
emitHookWarnings(result.warnings, emit);
} catch (err) {
// Stop hooks are informational only (e.g. notifications, logging). A failing hook must never
// discard the turn's final answer — log the error and continue.
// eslint-disable-next-line no-console
console.error("[fireStopHook] hook error (non-fatal):", err);
}
}
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
@@ -50,12 +75,19 @@ function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeo
}
export async function fireSessionStartHook(session: Session): Promise<string[]> {
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
if (result.additionalContext || result.jsonContext?.length) {
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
systemMessage.content = foldHookContext(systemMessage.content, result);
try {
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
if (result.additionalContext || result.jsonContext?.length) {
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
systemMessage.content = foldHookContext(systemMessage.content, result);
}
return result.warnings;
} catch (err) {
// A failing SessionStart hook must not prevent the session from starting.
// eslint-disable-next-line no-console
console.error("[fireSessionStartHook] hook error (non-fatal):", err);
return [];
}
return result.warnings;
}
export interface UserPromptSubmitResult {
@@ -69,14 +101,21 @@ export interface UserPromptSubmitResult {
/** Runs UserPromptSubmit hooks for a message the user is about to send — can block it outright
* (e.g. a policy check) or inject extra context that gets appended alongside it. */
export async function fireUserPromptSubmitHook(session: Session, prompt: string): Promise<UserPromptSubmitResult> {
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
return {
blocked: result.blocked,
reason: result.reason,
additionalContext: result.additionalContext,
jsonContext: result.jsonContext,
warnings: result.warnings,
};
try {
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
return {
blocked: result.blocked,
reason: result.reason,
additionalContext: result.additionalContext,
jsonContext: result.jsonContext,
warnings: result.warnings,
};
} catch (err) {
// A failing UserPromptSubmit hook must not block the user from sending their message.
// eslint-disable-next-line no-console
console.error("[fireUserPromptSubmitHook] hook error (non-fatal):", err);
return { blocked: false, warnings: [] };
}
}
export class AgentError extends Error {}
@@ -550,7 +589,7 @@ async function gateAndRun(
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
// auto-accept, same as real Claude Code hooks.
const hookCtx = { sessionId: session.id, cwd: session.cwd };
const preHook = await runHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
const preHook = await safeRunHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
emitHookWarnings(preHook.warnings, emit);
if (preHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${preHook.reason}`, isError: true });
@@ -579,7 +618,7 @@ async function gateAndRun(
const runMutatingSection = async (): Promise<unknown> => {
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await runHooksForEvent(
const permissionHook = await safeRunHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
@@ -626,7 +665,7 @@ async function gateAndRun(
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
// tool runs or the model reads the file again. The tradeoff is that a slow logging hook will stall
// the turn up to its own timeout — that's the cost of running formatting/logging synchronously.
const postHook = await runHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
const postHook = await safeRunHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
emitHookWarnings(postHook.warnings, emit);
return result;
@@ -767,7 +806,7 @@ async function runSubAgentTurn(
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
// sub-agent from running instead of only being logged.
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
const startHook = await safeRunHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
if (startHook.warnings.length) {
// eslint-disable-next-line no-console
console.error(`SubagentStart hook warnings: ${startHook.warnings.join("; ")}`);
@@ -964,10 +1003,12 @@ export async function runTurn(
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
// the very bug this user message exists to prevent).
session.mutationCommitLength = session.messages.length;
} catch {
} catch (err) {
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
// aborting the whole turn — the idle-abort guard on the next request still protects against
// an outright hang, it'll just be a slower/costlier request.
// eslint-disable-next-line no-console
console.error("[compactSession] compaction failed (non-fatal):", err);
}
}
+10 -6
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
import type { SessionRecord } from "../persistence/sessionStore.js";
import { PermissionManager } from "../permissions/permissionManager.js";
import type { ConfirmFn } from "../permissions/types.js";
@@ -10,6 +10,7 @@ import { TOOLS } from "../tools/index.js";
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { TodoItem, ToolDef } from "../tools/types.js";
import { TaskStore } from "../tools/task.js";
import type { TaskStoreSnapshot } from "../tools/task.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js";
@@ -116,7 +117,7 @@ export function createSession(
confirm: ConfirmFn,
mode: ToolCallMode,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW_LOCAL,
contextWindow?: number,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
@@ -124,6 +125,7 @@ export function createSession(
isLocal?: boolean,
): Session {
const resolvedIsLocal = isLocal ?? false;
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, resolvedIsLocal) },
@@ -142,7 +144,7 @@ export function createSession(
confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindow: resolvedContextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
@@ -165,7 +167,7 @@ export function createSessionFromRecord(
cwd: string,
confirm: ConfirmFn,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW_LOCAL,
contextWindow?: number,
contextWindowIsEstimate: boolean = true,
maxIterations: number = DEFAULT_MAX_ITERATIONS,
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
@@ -173,6 +175,7 @@ export function createSessionFromRecord(
isLocal?: boolean,
): Session {
const resolvedIsLocal = isLocal ?? false;
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, resolvedIsLocal) },
@@ -192,7 +195,7 @@ export function createSessionFromRecord(
confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindow: resolvedContextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
@@ -202,7 +205,7 @@ export function createSessionFromRecord(
mutationCommitLength: null,
projectInstructions,
todos: [],
taskStore: new TaskStore(),
taskStore: TaskStore.fromJSON(record.tasks ?? { seq: 0, tasks: [] }),
mutationGate: Promise.resolve(),
};
@@ -227,6 +230,7 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
mode: session.mode,
messages: session.messages.slice(1),
allowedTools: session.permissions.listAllowed(),
tasks: session.taskStore.toJSON(),
};
}
+6 -1
View File
@@ -9,7 +9,7 @@ import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig }
import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js";
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
import { isHttpServerConfig } from "./mcp/types.js";
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { deleteSession, listSessions, loadSession, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js";
import { loadPlugin } from "./plugins/loader.js";
import { runInkApp } from "./ui/ink/index.js";
@@ -230,6 +230,11 @@ sessionsCmd
.action((id: string) => {
if (deleteSession(id)) {
console.log(`Deleted session ${id}.`);
} else if (loadSession(id)) {
// The file exists but deleteSession() couldn't actually remove it (e.g. locked by another
// process) — a different situation from "no such session", so say so distinctly.
console.error(`Could not delete session "${id}" — the file may be in use by another process.`);
process.exit(1);
} else {
console.error(`No saved session found with id "${id}".`);
process.exit(1);
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
import { DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
describe("isLocalBackendURL", () => {
it("recognizes localhost, 127.0.0.1, and ::1", () => {
@@ -49,3 +49,13 @@ describe("isSmallLocalModel", () => {
expect(isSmallLocalModel("https://api.openai.com/v1", "gpt-4")).toBe(false);
});
});
describe("DEFAULT_CONTEXT_WINDOW_LOCAL / CLOUD", () => {
it("LOCAL is the small-local fallback (8 192)", () => {
expect(DEFAULT_CONTEXT_WINDOW_LOCAL).toBe(8192);
});
it("CLOUD is the cloud/large fallback (131 072)", () => {
expect(DEFAULT_CONTEXT_WINDOW_CLOUD).toBe(131072);
});
});
+22 -1
View File
@@ -1,6 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
// unlinkSync is mocked (default: pass-through to the real implementation) only so the "can't
// actually delete the file" test below can make a single call fail — every other test's calls to
// unlinkSync still hit the real filesystem via this same mock.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return { ...actual, unlinkSync: vi.fn(actual.unlinkSync) };
});
import envPaths from "env-paths";
import {
deleteSession,
@@ -76,6 +84,19 @@ describe("sessionStore", () => {
expect(listSessions()[0]?.id).toBe("manual-session");
});
it("reports failure (not success) when the file can't actually be deleted", async () => {
// Regression: a real unlink failure (e.g. Windows EPERM/EBUSY from a file lock) used to still
// return true and drop the entry from the index — reporting success while orphaning the file
// on disk with no way to reference it again.
await saveSession(makeRecord("locked-session"));
vi.mocked(unlinkSync).mockImplementationOnce(() => {
throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" });
});
expect(deleteSession("locked-session")).toBe(false);
expect(loadSession("locked-session")?.id).toBe("locked-session");
expect(listSessions()).toHaveLength(1);
});
it("self-heals when a session file is deleted outside of deleteSession()", async () => {
await saveSession(makeRecord("will-vanish"));
expect(listSessions()).toHaveLength(1);
+33 -6
View File
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFil
import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import type { TaskStoreSnapshot } from "../tools/task.js";
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
@@ -18,6 +19,8 @@ export interface SessionRecord {
messages: ChatCompletionMessageParam[];
/** Tool names the user approved "for this session" — preserved across resume. */
allowedTools?: string[];
/** Persisted task store snapshot so tasks survive session resume. */
tasks?: TaskStoreSnapshot;
}
export interface SessionSummary {
@@ -77,7 +80,9 @@ function readIndex(): Map<string, SessionSummary> {
try {
const entries = JSON.parse(readFileSync(indexFilePath(), "utf-8")) as SessionSummary[];
index = new Map(entries.map((e) => [e.id, e]));
} catch {
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[sessionStore] failed to parse index file, rebuilding:", err);
index = new Map();
}
}
@@ -93,8 +98,10 @@ function readIndex(): Map<string, SessionSummary> {
try {
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
index.set(record.id, summarize(record));
} catch {
// Skip corrupt/partial session files
} catch (err) {
// Skip corrupt/partial session files — but log so disk issues aren't silent.
// eslint-disable-next-line no-console
console.warn(`[sessionStore] skipping corrupt session file ${entry}:`, err);
}
}
}
@@ -109,12 +116,14 @@ function writeIndex(index: Map<string, SessionSummary>): void {
try {
writeFileSync(tmp, JSON.stringify([...index.values()]));
renameSync(tmp, indexFilePath());
} catch {
} catch (err) {
try {
unlinkSync(tmp);
} catch {
// tmp may not have been created if the write itself failed
}
// eslint-disable-next-line no-console
console.warn("[sessionStore] failed to write session index:", err);
}
}
@@ -168,7 +177,12 @@ export function loadSession(id: string): SessionRecord | undefined {
if (!existsSync(file)) return undefined;
try {
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
} catch {
} catch (err) {
// Corrupt or unreadable session file — treat as absent, but log so disk issues
// aren't completely silent. Callers can't distinguish "no file" from "corrupt file",
// but at least the log preserves the reason.
// eslint-disable-next-line no-console
console.warn(`[sessionStore] failed to load session ${id}, treating as absent:`, err);
return undefined;
}
}
@@ -185,7 +199,20 @@ export function mostRecentSessionId(): string | undefined {
export function deleteSession(id: string): boolean {
const file = filePath(id);
if (!existsSync(file)) return false;
unlinkSync(file);
try {
unlinkSync(file);
} catch (err) {
// ENOENT is harmless (race with another process) — the end state (file gone) is what we
// wanted anyway, so fall through and report success. Any other error (e.g. EPERM/EBUSY from
// a file lock, common on Windows) means the file is still on disk — report failure and leave
// the index entry alone, or listSessions()/resume would silently orphan a file no one could
// reference again (removed from the index, but never actually deleted).
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
// eslint-disable-next-line no-console
console.warn(`[sessionStore] failed to delete session file ${file}:`, err);
return false;
}
}
const index = readIndex();
index.delete(id);
writeIndex(index);
+24
View File
@@ -36,6 +36,12 @@ export interface TaskSummary {
blockedBy: string[];
}
/** JSON-serializable shape for persisting a TaskStore. */
export interface TaskStoreSnapshot {
seq: number;
tasks: Task[];
}
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
@@ -154,6 +160,24 @@ export class TaskStore {
}
this.emit();
}
/** Serialize the entire store for persistence. */
toJSON(): TaskStoreSnapshot {
return {
seq: this.seq,
tasks: [...this.tasks.values()].map(serializeTask),
};
}
/** Restore a store from a previously-serialized snapshot. */
static fromJSON(snapshot: TaskStoreSnapshot): TaskStore {
const store = new TaskStore();
store.seq = snapshot.seq;
for (const task of snapshot.tasks) {
store.tasks.set(task.id, { ...task, blocks: [...task.blocks], blockedBy: [...task.blockedBy], metadata: task.metadata ? { ...task.metadata } : undefined });
}
return store;
}
}
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
+15 -2
View File
@@ -531,7 +531,7 @@ export function App({
resolveToolCallMode(session.client, baseURLRef.current, session.model),
resolveContextWindow(baseURLRef.current, session.model),
]);
// See the same recompute in switchModel — a backend switch changes isLocalBackendURL()'s
// See the same recompute in switchModel — a backend switch changes isSmallLocalModel()'s
// input just as much as a model switch changes isCloudRoutedModelName()'s.
session.isLocal = isSmallLocalModel(baseURLRef.current, session.model);
setMode(session, newMode);
@@ -990,6 +990,19 @@ export function App({
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
}
// handleSubmit is a plain function recreated every render (it's 300 lines closing over dozens of
// things, including isThinking/streamingText — both of which change during every streaming frame,
// so a naive useCallback with a correct dependency list would still get a new identity constantly
// while streaming, exactly when we want it stable). Instead, the same ref-indirection this file
// already uses for phaseRef: keep a ref pointing at the latest handleSubmit, and hand ChatInput a
// permanently-stable wrapper that just calls through it — this is what actually makes ChatInput's
// memo() below effective during streaming, without needing handleSubmit's own identity to be stable.
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const stableHandleSubmit = useCallback((raw: string) => {
void handleSubmitRef.current(raw);
}, []);
function handlePermissionSelect(decision: PermissionDecision) {
const pending = permission;
setPermission(null);
@@ -1089,7 +1102,7 @@ export function App({
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSubmit}
onSubmit={stableHandleSubmit}
cwd={cwd}
history={history}
availableColumns={chatColumns}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Box, Text, useBoxMetrics, useCursor, useInput, useWindowSize, type DOMElement } from "ink";
import fg from "fast-glob";
import { useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import stringWidth from "string-width";
import { getAbsolutePosition } from "./absolutePosition.js";
import { ACCENT_HEX } from "../theme.js";
@@ -33,7 +33,7 @@ const PROMPT_WIDTH = 2;
// Border (1 col each side) + paddingX={1} (1 col each side) around the bordered box's content.
const BOX_CHROME_WIDTH = 4;
export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
export const ChatInput = memo(function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
const [allFiles, setAllFiles] = useState<string[] | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -370,4 +370,4 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
</Box>
</Box>
);
}
});
+2 -2
View File
@@ -243,8 +243,8 @@ export const HistoryItemView = memo(function HistoryItemView({ item }: { item: H
{item.todos.length === 0 ? (
<Text dimColor>Todos: (cleared)</Text>
) : (
item.todos.map((t, i) => (
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
item.todos.map((t) => (
<Text key={t.content} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
{" "}
{icon[t.status]} {t.content}
</Text>
+3 -3
View File
@@ -1,5 +1,5 @@
import { Box, Text } from "ink";
import { useEffect, useState } from "react";
import { memo, useEffect, useState } from "react";
import { ACCENT_HEX } from "../theme.js";
import type { PermissionMode } from "../../permissions/types.js";
import type { GitInfo } from "../../utils/gitInfo.js";
@@ -64,7 +64,7 @@ function formatElapsed(ms: number): string {
return h > 0 ? `${h}h${m}m` : `${m}m`;
}
export function StatusBar({
export const StatusBar = memo(function StatusBar({
model,
mode,
permMode,
@@ -135,4 +135,4 @@ export function StatusBar({
</Box>
</Box>
);
}
});
+3 -2
View File
@@ -1,11 +1,12 @@
import { Text } from "ink";
import Spinner from "ink-spinner";
import { memo } from "react";
import { ACCENT_HEX } from "../theme.js";
export function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
export const ThinkingIndicator = memo(function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
return (
<Text color={ACCENT_HEX}>
<Spinner type="dots" /> {label}
</Text>
);
}
});