import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

if (!(globalThis as any).document) {
  const { Window } = await import("happy-dom");
  const window = new Window();
  (globalThis as any).window = window;
  (globalThis as any).document = window.document;
  Object.defineProperty(globalThis, "navigator", {
    configurable: true,
    value: window.navigator,
  });
  (globalThis as any).Node = window.Node;
  (globalThis as any).Element = window.Element;
  (globalThis as any).HTMLElement = window.HTMLElement;
  (globalThis as any).SVGElement = window.SVGElement;
  (globalThis as any).Event = window.Event;
  (globalThis as any).CustomEvent = window.CustomEvent;
  (globalThis as any).MouseEvent = window.MouseEvent;
  (globalThis as any).KeyboardEvent = window.KeyboardEvent;
  (globalThis as any).getComputedStyle = window.getComputedStyle.bind(window);
}

globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
  cb(0);
  return 1;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => {}) as typeof globalThis.cancelAnimationFrame;

const { createApp, defineComponent, h, nextTick, watchEffect } = await import("vue");
const { TerminalProvider, charCellWidth, createStdoutRenderer, createTerminalApp, useTerminal } =
  await import("@simon_he/vue-tui");
const { DimSdkShell, createDimSdkRouter } = await import("../src/dimsdk/index.ts");
const { createErrorGoatChainAgent: createErrorDimSdkAgent } =
  await import("../src/dimsdk/core/agents/error.ts");

afterEach(() => {
  document.body.innerHTML = "";
  while (tempDirs.length > 0) {
    rmSync(tempDirs.pop() as string, { force: true, recursive: true });
  }
});

const tempDirs: string[] = [];

async function flushApp(
  app: Readonly<{
    scheduler: { flush: () => void };
  }>,
  ticks = 6,
): Promise<void> {
  for (let index = 0; index < ticks; index += 1) {
    await nextTick();
    await Promise.resolve();
  }
  app.scheduler.flush();
}

async function waitFor(
  predicate: () => boolean,
  advance: () => Promise<void>,
  timeoutMs = 1500,
): Promise<void> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (predicate()) {
      return;
    }
    await advance();
  }
  throw new Error("waitFor timeout");
}

async function withEnv<T>(
  overrides: Readonly<Record<string, string | undefined>>,
  run: () => Promise<T>,
): Promise<T> {
  const previous = new Map<string, string | undefined>();
  for (const [key, value] of Object.entries(overrides)) {
    previous.set(key, process.env[key]);
    if (value == null) {
      delete process.env[key];
    } else {
      process.env[key] = value;
    }
  }
  try {
    return await run();
  } finally {
    for (const [key, value] of previous.entries()) {
      if (value == null) {
        delete process.env[key];
      } else {
        process.env[key] = value;
      }
    }
  }
}

async function sleep(ms: number): Promise<void> {
  await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}

function createWorkspace(files: Readonly<Record<string, string>>): string {
  const workspace = mkdtempSync(`${tmpdir()}/best-agent-dimsdk-terminal-screen-`);
  tempDirs.push(workspace);
  for (const [relativePath, content] of Object.entries(files)) {
    writeFileSync(resolve(workspace, relativePath), content, "utf8");
  }
  return workspace;
}

function snapshotLines(
  app: Readonly<{ terminal: { snapshot: () => { lines: string[] } } }>,
): string[] {
  return ((app.terminal.snapshot().lines ?? []) as string[]).map((line) => String(line));
}

function findTextCell(
  app: Readonly<{ terminal: { snapshot: () => { lines: string[] } } }>,
  text: string,
): { x: number; y: number } | null {
  const wanted = text.toLowerCase();
  const lines = app.terminal.snapshot().lines;
  for (let y = 0; y < lines.length; y += 1) {
    const x = lines[y]?.toLowerCase().indexOf(wanted) ?? -1;
    if (x >= 0) {
      return { x, y };
    }
  }
  return null;
}

function applyAnsiToScreen(output: string, cols: number, rows: number): readonly string[] {
  const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
  let cursorX = 0;
  let cursorY = 0;
  let scrollTop = 0;
  let scrollBottom = rows;

  const scrollUp = (count: number) => {
    for (let index = 0; index < count; index += 1) {
      for (let y = scrollTop; y < scrollBottom - 1; y += 1) {
        grid[y] = grid[y + 1]!;
      }
      grid[scrollBottom - 1] = Array.from({ length: cols }, () => " ");
    }
  };

  const scrollDown = (count: number) => {
    for (let index = 0; index < count; index += 1) {
      for (let y = scrollBottom - 1; y > scrollTop; y -= 1) {
        grid[y] = grid[y - 1]!;
      }
      grid[scrollTop] = Array.from({ length: cols }, () => " ");
    }
  };

  let index = 0;
  while (index < output.length) {
    const ch = output[index]!;
    if (ch === "\u001B") {
      const next = output[index + 1];
      if (next === "[") {
        let end = index + 2;
        while (end < output.length && !/[A-Za-z]/.test(output[end]!)) {
          end += 1;
        }
        if (end >= output.length) {
          break;
        }
        const final = output[end]!;
        const raw = output.slice(index + 2, end);
        const params = raw.replace(/^\?/, "");
        const parts = params ? params.split(";").map((part) => Number(part || "0")) : [];
        if (final === "H" || final === "f") {
          cursorY = Math.max(0, Math.min(rows - 1, (parts[0] || 1) - 1));
          cursorX = Math.max(0, Math.min(cols, (parts[1] || 1) - 1));
        } else if (final === "K") {
          for (let x = cursorX; x < cols; x += 1) {
            grid[cursorY]![x] = " ";
          }
        } else if (final === "r") {
          if (parts.length >= 2) {
            scrollTop = Math.max(0, Math.min(rows - 1, (parts[0] || 1) - 1));
            scrollBottom = Math.max(scrollTop + 1, Math.min(rows, parts[1] || rows));
          } else {
            scrollTop = 0;
            scrollBottom = rows;
          }
        } else if (final === "S") {
          scrollUp(Math.max(1, parts[0] || 1));
        } else if (final === "T") {
          scrollDown(Math.max(1, parts[0] || 1));
        }
        index = end + 1;
        continue;
      }
      if (next === "]") {
        const end = output.indexOf("\u0007", index + 2);
        index = end >= 0 ? end + 1 : output.length;
        continue;
      }
    }

    if (ch === "\r") {
      cursorX = 0;
      index += 1;
      continue;
    }
    if (ch === "\n") {
      cursorX = 0;
      if (cursorY === scrollBottom - 1) {
        scrollUp(1);
      } else {
        cursorY = Math.min(rows - 1, cursorY + 1);
      }
      index += 1;
      continue;
    }

    const width = Math.max(1, charCellWidth(ch));
    if (cursorY >= 0 && cursorY < rows && cursorX >= 0 && cursorX < cols) {
      grid[cursorY]![cursorX] = ch;
    }
    if (width > 1) {
      for (let fillX = cursorX + 1; fillX < Math.min(cols, cursorX + width); fillX += 1) {
        grid[cursorY]![fillX] = " ";
      }
    }
    cursorX = Math.min(cols, cursorX + width);
    index += 1;
  }

  return grid.map((row) => row.join(""));
}

function assertLiveScreenMatchesAnsi(
  app: Readonly<{ terminal: { snapshot: () => { lines: string[] } } }>,
  ansiOutput: Readonly<{ text: string }>,
  cols: number,
  rows: number,
): void {
  expect(applyAnsiToScreen(ansiOutput.text, cols, rows)).toEqual(snapshotLines(app));
}

function selectedBgRows(
  app: Readonly<{ terminal: { getCell: (x: number, y: number) => { style: { bg?: string } } } }>,
  cols: number,
  rows: number,
): number[] {
  const rowsWithBlue: number[] = [];
  for (let y = 0; y < rows; y += 1) {
    let hasBlue = false;
    for (let x = 0; x < cols; x += 1) {
      if (app.terminal.getCell(x, y).style.bg === "blue") {
        hasBlue = true;
        break;
      }
    }
    if (hasBlue) {
      rowsWithBlue.push(y);
    }
  }
  return rowsWithBlue;
}

function selectedTranscriptRows(
  app: Readonly<{
    terminal: {
      getCell: (x: number, y: number) => { style: { bg?: string; inverse?: boolean } };
    };
  }>,
  cols: number,
  rows: number,
): number[] {
  const rowsWithSelection: number[] = [];
  for (let y = 0; y < rows; y += 1) {
    let hasSelection = false;
    for (let x = 0; x < cols; x += 1) {
      const style = app.terminal.getCell(x, y).style;
      if (style.bg === "magentaBright" || style.inverse === true) {
        hasSelection = true;
        break;
      }
    }
    if (hasSelection) {
      rowsWithSelection.push(y);
    }
  }
  return rowsWithSelection;
}

async function pressKey(
  app: Readonly<{
    events: { dispatch: (event: unknown) => boolean | void };
    scheduler: { flush: () => void };
  }>,
  key: string,
  opts: Readonly<{ code?: string; shift?: boolean }> = {},
): Promise<void> {
  app.events.dispatch({
    type: "keydown",
    key,
    code: opts.code ?? key,
    shiftKey: Boolean(opts.shift),
  } as const);
  await flushApp(app);
}

async function mountRoute(
  route: "home" | "chat",
  opts: Readonly<{ cols?: number; rows?: number; cwd?: string }> = {},
) {
  let storeRef: any;
  const app = createTerminalApp({
    cols: opts.cols ?? 100,
    rows: opts.rows ?? 36,
    component: DimSdkShell as never,
    props: {
      toolRunner: async () => ({
        id: "tool",
        status: "error" as const,
        output: "Tool execution is disabled in this test.",
      }),
      context: {
        system: "test",
        cwd: opts.cwd ?? process.cwd(),
        env: {
          ...process.env,
          DIMCODE_VERSION: process.env.DIMCODE_VERSION ?? "1.0.0",
        },
      },
      agent: createErrorDimSdkAgent(new Error("Agent is disabled in this test.")),
      onStoreCreated: (store: any) => {
        storeRef = store;
      },
    },
    defaultStyle: { bg: "black", fg: "whiteBright" },
  });

  app.app.use(createDimSdkRouter({ initialRoute: route, mode: "cli" }) as never);
  app.mount();
  await flushApp(app);
  await waitFor(
    () => Boolean(storeRef),
    () => flushApp(app),
  );

  const ansiOutput = { text: "" };
  const stdoutRenderer = createStdoutRenderer(app.terminal as any, {
    output: {
      isTTY: true,
      write(chunk: string) {
        ansiOutput.text += chunk;
      },
    },
    clear: false,
    hideCursor: false,
    altScreen: false,
  });
  await flushApp(app);

  return {
    app,
    store: storeRef,
    ansiOutput,
    dispose() {
      stdoutRenderer.dispose();
      app.dispose();
    },
  };
}

async function mountHomeAnimationSurface(
  opts: Readonly<{ cols?: number; rows?: number; cwd?: string }> = {},
): Promise<{
  terminal: any;
  ansiOutput: { text: string };
  unmount: () => void;
}> {
  const cols = opts.cols ?? 100;
  const rows = opts.rows ?? 36;
  const root = document.createElement("div");
  document.body.appendChild(root);

  const exposed = {
    terminal: null as any,
  };
  const shouldSuppressVueWarn = (message: string) =>
    message.includes("Missing ref owner context. ref cannot be used on hoisted vnodes.") ||
    message.includes('Slot "default" invoked outside of the render function');
  const originalConsoleWarn = console.warn;
  console.warn = (...args: unknown[]) => {
    const message = args.map((value) => String(value ?? "")).join(" ");
    if (shouldSuppressVueWarn(message)) {
      return;
    }
    originalConsoleWarn(...args);
  };

  const Expose = defineComponent({
    name: "ExposeDimSdkHomeAnimationSurface",
    setup() {
      const ctx = useTerminal();
      exposed.terminal = ctx.terminal;
      watchEffect(() => {
        exposed.terminal = ctx.terminal;
      });
      return () => null;
    },
  });

  const App = defineComponent({
    name: "DimSdkHomeAnimationSurfaceApp",
    setup() {
      return () =>
        h(
          TerminalProvider,
          {
            cols,
            rows,
            defaultStyle: { fg: "whiteBright", bg: "black" },
          },
          () => [
            h(Expose),
            h(DimSdkShell as any, {
              toolRunner: async () => ({
                id: "tool",
                status: "error",
                output: "Tool execution is disabled in this test.",
              }),
              context: {
                system: "test",
                cwd: opts.cwd ?? process.cwd(),
                env: {
                  ...process.env,
                  DIMCODE_VERSION: process.env.DIMCODE_VERSION ?? "1.0.0",
                },
              },
              agent: createErrorDimSdkAgent(new Error("Agent is disabled in this test.")),
            }),
          ],
        );
    },
  });

  const app = createApp(App);
  app.config.warnHandler = (message, instance, trace) => {
    if (
      message.includes("Missing ref owner context. ref cannot be used on hoisted vnodes.") ||
      message.includes('Slot "default" invoked outside of the render function')
    ) {
      return;
    }
    console.warn(`[Vue warn]: ${message}${trace ?? ""}`, instance);
  };
  app.use(createDimSdkRouter({ initialRoute: "home", mode: "cli" }) as never);
  try {
    app.mount(root);
    await waitFor(
      () => Boolean(exposed.terminal),
      async () => {
        await nextTick();
        await Promise.resolve();
        await sleep(0);
      },
    );

    const ansiOutput = { text: "" };
    const stdoutRenderer = createStdoutRenderer(exposed.terminal, {
      output: {
        isTTY: true,
        write(chunk: string) {
          ansiOutput.text += chunk;
        },
      },
      clear: false,
      hideCursor: false,
      altScreen: false,
    });
    await sleep(20);

    return {
      terminal: exposed.terminal,
      ansiOutput,
      unmount: () => {
        stdoutRenderer.dispose();
        app.unmount();
        root.remove();
        console.warn = originalConsoleWarn;
      },
    };
  } catch (error) {
    console.warn = originalConsoleWarn;
    app.unmount();
    root.remove();
    throw error;
  }
}

describe("dimsdk terminal screen e2e", () => {
  it("keeps home mention suggestions screen-consistent across navigation, wrap, and dismiss", async () => {
    const cols = 100;
    const rows = 36;
    const workspace = createWorkspace({
      "a-note.md": "first\n",
      "z-note.md": "last\n",
    });
    const mounted = await mountRoute("home", { cols, rows, cwd: workspace });

    try {
      await pressKey(mounted.app, "@", { code: "Digit2", shift: true });
      await pressKey(mounted.app, "n", { code: "KeyN" });
      await waitFor(
        () =>
          snapshotLines(mounted.app).join("\n").includes("a-note.md") &&
          snapshotLines(mounted.app).join("\n").includes("z-note.md"),
        async () => {
          await sleep(120);
          await flushApp(mounted.app);
        },
      );

      const firstSelected = selectedBgRows(mounted.app, cols, rows);
      expect(firstSelected.length).toBe(1);
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);

      let movedAway = false;
      let wrapped = false;
      for (let index = 0; index < 24; index += 1) {
        await pressKey(mounted.app, "ArrowDown", { code: "ArrowDown" });
        const selected = selectedBgRows(mounted.app, cols, rows);
        expect(selected.length).toBe(1);
        assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
        if (selected[0] !== firstSelected[0]) {
          movedAway = true;
        }
        if (movedAway && selected[0] === firstSelected[0]) {
          wrapped = true;
          break;
        }
      }
      expect(wrapped).toBe(true);

      await pressKey(mounted.app, "Escape", { code: "Escape" });
      await waitFor(
        () => !snapshotLines(mounted.app).join("\n").includes("a-note.md"),
        () => flushApp(mounted.app),
      );
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
    } finally {
      mounted.dispose();
    }
  });

  it("animates the home logo when logo animation is enabled", async () => {
    await withEnv(
      {
        DIMCODE_TEST: "0",
        DIMCODE_LOGO_ANIM: "1",
        DIMCODE_LOGO_DRAW_FRAMES: "12",
      },
      async () => {
        const cols = 100;
        const rows = 36;
        const mounted = await mountHomeAnimationSurface({ cols, rows, cwd: process.cwd() });

        try {
          const initialTop = ((mounted.terminal.snapshot().lines ?? []) as string[])
            .slice(0, 18)
            .join("\n");
          await waitFor(
            () =>
              ((mounted.terminal.snapshot().lines ?? []) as string[]).slice(0, 18).join("\n") !==
              initialTop,
            async () => {
              await sleep(90);
            },
            1200,
          );
          const animatedTop = ((mounted.terminal.snapshot().lines ?? []) as string[])
            .slice(0, 18)
            .join("\n");
          expect(animatedTop).toContain("Welcome! Quick start:");
          expect(animatedTop).not.toBe(initialTop);
          expect(applyAnsiToScreen(mounted.ansiOutput.text, cols, rows)).toEqual(
            ((mounted.terminal.snapshot().lines ?? []) as string[]).map((line) => String(line)),
          );
        } finally {
          mounted.unmount();
        }
      },
    );
  });

  it("animates the home logo on headless cli surfaces", async () => {
    await withEnv(
      {
        DIMCODE_TEST: "0",
        DIMCODE_LOGO_ANIM: "1",
        DIMCODE_LOGO_DRAW_FRAMES: "12",
      },
      async () => {
        const cols = 100;
        const rows = 36;
        const mounted = await mountRoute("home", { cols, rows, cwd: process.cwd() });

        try {
          const initialTop = snapshotLines(mounted.app).slice(0, 18).join("\n");
          await waitFor(
            () => snapshotLines(mounted.app).slice(0, 18).join("\n") !== initialTop,
            async () => {
              await sleep(90);
              await flushApp(mounted.app);
            },
            1200,
          );
          const animatedTop = snapshotLines(mounted.app).slice(0, 18).join("\n");
          expect(animatedTop).toContain("Welcome! Quick start:");
          expect(animatedTop).not.toBe(initialTop);
          assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
        } finally {
          mounted.dispose();
        }
      },
    );
  });

  it("keeps slash cleanup stable while the home logo animation is active", async () => {
    await withEnv(
      {
        DIMCODE_TEST: "0",
        DIMCODE_LOGO_ANIM: "1",
      },
      async () => {
        const cols = 100;
        const rows = 36;
        const mounted = await mountRoute("home", { cols, rows, cwd: process.cwd() });

        try {
          await pressKey(mounted.app, "/", { code: "Slash" });
          await waitFor(
            () => snapshotLines(mounted.app).join("\n").includes("/sessions"),
            () => flushApp(mounted.app),
          );

          await pressKey(mounted.app, "Backspace", { code: "Backspace" });
          await flushApp(mounted.app, 2);

          const afterDelete = snapshotLines(mounted.app).join("\n");
          expect(afterDelete).not.toContain("/sessions");
          expect(afterDelete).not.toContain("/skills");
          assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
        } finally {
          mounted.dispose();
        }
      },
    );
  });

  it("moves the chat mention popup selection background cleanly when wrapping from last item to top", async () => {
    const cols = 100;
    const rows = 26;
    const workspace = createWorkspace({
      "a-note.md": "first\n",
      "z-note.md": "last\n",
    });
    const mounted = await mountRoute("chat", { cols, rows, cwd: workspace });

    try {
      await pressKey(mounted.app, "@", { code: "Digit2", shift: true });
      await pressKey(mounted.app, "n", { code: "KeyN" });
      await waitFor(
        () =>
          snapshotLines(mounted.app).join("\n").includes("a-note.md") &&
          snapshotLines(mounted.app).join("\n").includes("z-note.md"),
        async () => {
          await sleep(120);
          await flushApp(mounted.app);
        },
      );

      const firstSelected = selectedBgRows(mounted.app, cols, rows);
      expect(firstSelected.length).toBe(1);
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);

      let movedAway = false;
      let wrapped = false;
      for (let index = 0; index < 24; index += 1) {
        await pressKey(mounted.app, "ArrowDown", { code: "ArrowDown" });
        const selected = selectedBgRows(mounted.app, cols, rows);
        expect(selected.length).toBe(1);
        assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
        if (selected[0] !== firstSelected[0]) {
          movedAway = true;
        }
        if (movedAway && selected[0] === firstSelected[0]) {
          wrapped = true;
          break;
        }
      }
      expect(wrapped).toBe(true);
    } finally {
      mounted.dispose();
    }
  });

  it("shows transcript selection background and copy toast when dragging chat text", async () => {
    const cols = 100;
    const rows = 36;
    const mounted = await mountRoute("chat", { cols, rows, cwd: process.cwd() });
    const previousClipboard = (globalThis.navigator as any).clipboard;
    const clipboardWrites: string[] = [];

    Object.defineProperty(globalThis.navigator, "clipboard", {
      configurable: true,
      value: {
        async writeText(text: string) {
          clipboardWrites.push(text);
        },
      },
    });

    try {
      mounted.store.state.sessionId = "thread_selection_copy";
      mounted.store.state.messages = [
        {
          id: "assistant_selection_copy",
          role: "assistant",
          parts: [
            {
              type: "markdown",
              markdown: "Select this transcript line for copy testing.",
            },
          ],
        },
      ];
      mounted.store.state.layoutDirtySeq = Number(mounted.store.state.layoutDirtySeq ?? 0) + 1;
      await flushApp(mounted.app);
      await waitFor(
        () =>
          snapshotLines(mounted.app)
            .join("\n")
            .includes("Select this transcript line for copy testing."),
        () => flushApp(mounted.app),
      );

      const start = findTextCell(mounted.app, "Select this");
      const end = findTextCell(mounted.app, "copy testing");
      expect(start).toBeTruthy();
      expect(end).toBeTruthy();

      mounted.app.events.dispatch({
        type: "pointerdown",
        button: 0,
        cellX: start!.x + 1,
        cellY: start!.y,
      } as const);
      await flushApp(mounted.app);

      mounted.app.events.dispatch({
        type: "pointermove",
        button: 0,
        cellX: end!.x + "copy testing".length + 1,
        cellY: end!.y,
      } as const);
      await flushApp(mounted.app);

      const selectionRows = selectedTranscriptRows(mounted.app, cols, rows);

      mounted.app.events.dispatch({
        type: "pointerup",
        button: 0,
        cellX: end!.x + "copy testing".length + 1,
        cellY: end!.y,
      } as const);

      await waitFor(
        () => {
          const snapshot = snapshotLines(mounted.app).join("\n");
          return snapshot.includes("Copied to clipboard") || snapshot.includes("Copy failed");
        },
        async () => {
          await sleep(20);
          await flushApp(mounted.app);
        },
      );

      expect(selectionRows.length).toBeGreaterThan(0);
      if (clipboardWrites.length > 0) {
        expect(clipboardWrites.at(-1)).toContain("transcript line for copy testing.");
      }
    } finally {
      if (previousClipboard == null) {
        delete (globalThis.navigator as any).clipboard;
      } else {
        Object.defineProperty(globalThis.navigator, "clipboard", {
          configurable: true,
          value: previousClipboard,
        });
      }
      mounted.dispose();
    }
  });

  it("toggles a tool call state without creating transcript selection", async () => {
    const cols = 100;
    const rows = 36;
    const mounted = await mountRoute("chat", { cols, rows, cwd: process.cwd() });

    try {
      const callId = "call_workspace_search";
      mounted.store.state.sessionId = "thread_tool_click";
      mounted.store.state.ui.expandToolCallsAndThinking = true;
      mounted.store.state.messages = [
        {
          id: "assistant_tool_click",
          role: "assistant",
          parts: [
            {
              type: "tool_call",
              call: {
                toolCallId: callId,
                name: "workspace-search",
                arguments: {
                  query: "best-agent",
                },
              },
            },
            {
              type: "tool_result",
              result: {
                toolCallId: callId,
                status: "success",
                output: "Found matches in README.md",
              },
            },
          ],
        },
      ];
      mounted.store.state.layoutDirtySeq = Number(mounted.store.state.layoutDirtySeq ?? 0) + 1;
      await flushApp(mounted.app);
      await waitFor(
        () => snapshotLines(mounted.app).join("\n").includes("workspace-search"),
        () => flushApp(mounted.app),
      );

      mounted.store.toggleToolCallCollapsed(callId);
      await flushApp(mounted.app);

      await waitFor(
        () => mounted.store.state.collapsedToolCallIds.has(callId),
        () => flushApp(mounted.app),
      );
      expect(selectedTranscriptRows(mounted.app, cols, rows)).toHaveLength(0);
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);

      mounted.store.toggleToolCallCollapsed(callId);
      await flushApp(mounted.app);

      await waitFor(
        () => !mounted.store.state.collapsedToolCallIds.has(callId),
        () => flushApp(mounted.app),
      );
      expect(selectedTranscriptRows(mounted.app, cols, rows)).toHaveLength(0);
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
    } finally {
      mounted.dispose();
    }
  });

  it("suppresses empty workspace wrapper tool calls when a concrete workspace tool follows", async () => {
    const cols = 100;
    const rows = 36;
    const mounted = await mountRoute("chat", { cols, rows, cwd: process.cwd() });

    try {
      mounted.store.state.sessionId = "thread_workspace_wrapper";
      mounted.store.state.ui.expandToolCallsAndThinking = true;
      mounted.store.state.messages = [
        {
          id: "assistant_workspace_wrapper",
          role: "assistant",
          parts: [
            {
              type: "tool_call",
              call: {
                id: "call_workspace_wrapper",
                name: "workspace",
                arguments: {},
              },
            },
            {
              type: "tool_call",
              call: {
                id: "call_read_file",
                name: "read-file",
                arguments: {
                  path: "README.md",
                },
              },
            },
            {
              type: "tool_result",
              result: {
                id: "call_read_file",
                status: "success",
                output: "# Best-Agent",
              },
            },
          ],
        },
      ];
      mounted.store.state.layoutDirtySeq = Number(mounted.store.state.layoutDirtySeq ?? 0) + 1;
      await flushApp(mounted.app);
      await waitFor(
        () => snapshotLines(mounted.app).join("\n").includes("read-file"),
        () => flushApp(mounted.app),
      );

      const screen = snapshotLines(mounted.app).join("\n");
      expect(screen).toContain("read-file");
      expect(screen).toContain("# Best-Agent");
      expect(screen).not.toContain("workspace");
    } finally {
      mounted.dispose();
    }
  });

  it("repaints a shorter context percentage without leaving stale digits in the model row", async () => {
    const cols = 100;
    const rows = 36;
    const mounted = await mountRoute("chat", { cols, rows, cwd: process.cwd() });

    try {
      mounted.store.state.sessionId = "thread_ctx_pct";
      mounted.store.state.provider.activeId = "openai";
      mounted.store.state.config.model = "glm-5";
      mounted.store.state.config.context_window = 4000;

      mounted.store.applyLastRequestBudget({
        autoBudgetOutcome: "not-needed",
        estimatedInputTokens: 1600,
        inputBudgetTokens: 4000,
        thresholdTokens: 4000,
        maxInputTokens: 4000,
        triggerRatio: 1,
        updatedAtMs: Date.now(),
        usedAutoCompaction: false,
      });
      await flushApp(mounted.app);

      await waitFor(
        () => snapshotLines(mounted.app).slice(-6).join("\n").includes("40%"),
        () => flushApp(mounted.app),
      );

      mounted.store.applyLastRequestBudget({
        autoBudgetOutcome: "not-needed",
        estimatedInputTokens: 200,
        inputBudgetTokens: 4000,
        thresholdTokens: 4000,
        maxInputTokens: 4000,
        triggerRatio: 1,
        updatedAtMs: Date.now() + 1,
        usedAutoCompaction: false,
      });
      await flushApp(mounted.app);

      await waitFor(
        () => snapshotLines(mounted.app).slice(-6).join("\n").includes("5%"),
        () => flushApp(mounted.app),
      );

      const bottom = snapshotLines(mounted.app).slice(-6).join("\n");
      expect(bottom).toContain("5%");
      expect(bottom).not.toContain("405%");
      expect(bottom).not.toContain("40%");
      assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
    } finally {
      mounted.dispose();
    }
  });

  it("animates the chat thinking banner while waiting for a delayed reply", async () => {
    await withEnv(
      {
        DIMCODE_LOADING_ANIM_MS: "40",
      },
      async () => {
        const cols = 100;
        const rows = 36;
        const mounted = await mountRoute("chat", { cols, rows, cwd: process.cwd() });

        try {
          mounted.store.state.sessionId = "thread_thinking_anim";
          mounted.store.state.runningAgentRound = "turn_thinking_anim";
          mounted.store.state.messages = [
            {
              id: "assistant_thinking_anim",
              role: "assistant",
              parts: [{ type: "status", text: "thinking" }],
            },
          ];
          await flushApp(mounted.app, 4);

          const initialBottom = snapshotLines(mounted.app).slice(-8).join("\n");
          expect(initialBottom).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u);

          await waitFor(
            () => snapshotLines(mounted.app).slice(-8).join("\n") !== initialBottom,
            async () => {
              await sleep(80);
              await flushApp(mounted.app, 2);
            },
            1200,
          );

          const animatedBottom = snapshotLines(mounted.app).slice(-8).join("\n");
          expect(animatedBottom).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u);
          expect(animatedBottom).not.toBe(initialBottom);
          assertLiveScreenMatchesAnsi(mounted.app, mounted.ansiOutput, cols, rows);
        } finally {
          mounted.dispose();
        }
      },
    );
  });
});
