diff --git a/.gitignore b/.gitignore
index ecd5b29ef..cca784ccc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,3 +69,5 @@ package-lock.json
/apps/desktop/release-alpha
/apps/desktop/release-beta
apps/desktop/resources/runtime/ade-*
+
+.claude/scheduled_tasks.lock
diff --git a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx
index add08bf69..82936e368 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/HeaderFooter.test.tsx
@@ -84,6 +84,7 @@ describe("FooterControls", () => {
provider="codex"
modelDisplay="GPT-5.5"
permissionLabel="full-auto"
+ permissionDetail="never · danger-full-access"
fastMode
/>,
);
@@ -92,6 +93,7 @@ describe("FooterControls", () => {
expect(frame).toContain("GPT-5.5");
expect(frame).toContain("fast");
expect(frame).toContain("full-auto");
+ expect(frame).toContain("full-auto ·");
});
it("renders the resting hint strip with lanes/pane/chat-info/cmds/help", () => {
diff --git a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx
index 42c0a5de4..9c178d89d 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/Palettes.test.tsx
@@ -119,7 +119,9 @@ describe("SlashPalette", () => {
).lastFrame() ?? "");
const lines = frame.split("\n").filter(Boolean);
- expect(lines).toHaveLength(8);
+ // 6 command rows (MIN_VISIBLE_ROWS, no maxRows budget passed) + header +
+ // selected-summary + footer.
+ expect(lines).toHaveLength(9);
expect(lines.every((line) => /^[┌│└]/.test(line) && /[┐│┘]$/.test(line))).toBe(true);
expect(frame).toContain("/feedback");
expect(frame).toContain("Submit ADE feedback to GitHub issues");
diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
index f8c946823..c20c88527 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
@@ -1,9 +1,25 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { render } from "ink-testing-library";
-import { LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, RightPane } from "../components/RightPane";
+import { LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, feedbackStateFromContent, laneDetailsInteractionLayout, rightPaneScrollableRowCount, RightPane } from "../components/RightPane";
+import { feedbackFormToFormValues } from "../feedbackForm";
+import { buildFeedbackDraftInput } from "../feedback";
import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes";
+describe("rightPaneScrollableRowCount", () => {
+ it("counts details body lines and list rows; flows full diff bodies for scrolling", () => {
+ expect(rightPaneScrollableRowCount({ kind: "details", title: "t", body: "a\nb\nc" })).toBe(3);
+ expect(rightPaneScrollableRowCount({ kind: "list", title: "t", rows: ["a", "b"] })).toBe(2);
+ expect(rightPaneScrollableRowCount({ kind: "empty" })).toBe(0);
+ const bigBody = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n");
+ // 1 header row + all 50 body rows (diffs scroll in full, capped per file at 600).
+ expect(rightPaneScrollableRowCount({ kind: "diff", title: "d", files: [{ path: "a.ts", body: bigBody }] })).toBe(51);
+ // Per-file cap: 600 body rows shown + 1 header + 1 "more lines" row.
+ const huge = Array.from({ length: 650 }, (_, i) => `+line ${i}`).join("\n");
+ expect(rightPaneScrollableRowCount({ kind: "diff", title: "d", files: [{ path: "a.ts", body: huge }] })).toBe(602);
+ });
+});
+
function stripAnsi(text: string): string {
return text.replace(/\u001b(?:\[[0-9;]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, "");
}
@@ -511,19 +527,33 @@ describe("RightPane setup panes", () => {
expect(frame).toContain("enter deletes this lane");
});
- it("renders model setup rows and selected row detail", () => {
+ it("renders the unified model picker with model list and settings footer", () => {
const result = render(
,
@@ -531,12 +561,11 @@ describe("RightPane setup panes", () => {
const frame = stripAnsi(result.lastFrame() ?? "");
expect(frame).toContain("MODEL");
- expect(frame).toContain("Provider: Claude");
- expect(frame).toContain("Model: Sonnet");
- expect(frame).toContain("Reasoning: high");
- expect(frame).toContain("low, medium, high");
- expect(frame).toContain("Permissions: auto");
- expect(frame).toContain("↑↓ rows · ←→ change · ↵ apply · esc close");
+ expect(frame).toContain("Claude Sonnet 4.6");
+ // Reasoning is shown in the settings footer, not as an inline per-model
+ // "think high" chip (that detail was removed from model rows).
+ expect(frame).toContain("reasoning");
+ expect(frame).not.toContain("think high");
});
});
@@ -557,6 +586,121 @@ describe("RightPane details", () => {
expect(frame).toContain("SKILLS");
expect(frame).toContain("line 1");
- expect(frame).toContain("… 14 more lines");
+ expect(frame).toContain("↓ 14 more lines");
+ });
+
+ it("renders context usage as a visual pane", () => {
+ const result = render(
+ ,
+ );
+ const frame = stripAnsi(result.lastFrame() ?? "");
+
+ expect(frame).toContain("CONTEXT");
+ expect(frame).toContain("gpt-5.5");
+ expect(frame).toContain("12.0k / 20.0k (60%)");
+ expect(frame).toContain("messages");
+ });
+});
+
+describe("RightPane feedback form", () => {
+ // The feedback pane render is exercised end-to-end through the deterministic
+ // helper contract below (state rebuild -> serialize -> daemon draft). Full-frame
+ // string assertions are intentionally avoided: ink-testing-library leaks tall
+ // frames between renders in this suite, making pixel-frame matches flaky.
+
+ it("feedbackStateFromContent rebuilds the framework-free form state", () => {
+ const content = {
+ kind: "form" as const,
+ title: "Feedback",
+ command: "feedback" as const,
+ fields: [],
+ feedback: {
+ type: "idea" as const,
+ body: "add dark mode\nplease",
+ showContext: false,
+ provider: "anthropic",
+ model: "opus",
+ lane: "L",
+ lastError: "boom",
+ },
+ };
+ const state = feedbackStateFromContent(content);
+ expect(state.type).toBe("idea");
+ expect(state.text).toBe("add dark mode\nplease");
+ expect(state.showContext).toBe(false);
+ expect(state.context).toEqual({ provider: "anthropic", model: "opus", lane: "L", lastError: "boom" });
+ });
+
+ it("submit path: feedbackStateFromContent -> feedbackFormToFormValues -> buildFeedbackDraftInput keeps the multiline body", () => {
+ const content = {
+ kind: "form" as const,
+ title: "Feedback",
+ command: "feedback" as const,
+ fields: [],
+ feedback: {
+ type: "bug" as const,
+ body: "crash on launch\nstep one\nstep two",
+ showContext: true,
+ provider: "anthropic",
+ model: "opus",
+ lane: "my-lane",
+ lastError: "boom",
+ },
+ };
+ const values = feedbackFormToFormValues(feedbackStateFromContent(content));
+ expect(values.category).toBe("bug");
+ expect(values.summary).toBe("crash on launch");
+ const draft = buildFeedbackDraftInput(values);
+ expect(draft.category).toBe("bug");
+ expect(draft.summary).toBe("crash on launch");
+ if (draft.category === "bug") {
+ expect(draft.stepsToReproduce).toBe("crash on launch\nstep one\nstep two");
+ }
+ expect(draft.additionalContext).toContain("--- Context ---");
+ expect(draft.additionalContext).toContain("Provider/Model: anthropic / opus");
+ expect(draft.additionalContext).toContain("Lane: my-lane");
+ expect(draft.additionalContext).toContain("Last error/notice: boom");
+ });
+
+ it("omits the context footer from the serialized draft when showContext is false", () => {
+ const content = {
+ kind: "form" as const,
+ title: "Feedback",
+ command: "feedback" as const,
+ fields: [],
+ feedback: { type: "idea" as const, body: "an idea", showContext: false, lane: "L" },
+ };
+ const values = feedbackFormToFormValues(feedbackStateFromContent(content));
+ expect(values.additionalContext).toBeUndefined();
+ });
+
+ it("idea type maps to a feature draft", () => {
+ const content = {
+ kind: "form" as const,
+ title: "Feedback",
+ command: "feedback" as const,
+ fields: [],
+ feedback: { type: "idea" as const, body: "add dark mode\nplease", showContext: false },
+ };
+ const draft = buildFeedbackDraftInput(feedbackFormToFormValues(feedbackStateFromContent(content)));
+ expect(draft.category).toBe("feature");
+ expect(draft.summary).toBe("add dark mode");
});
});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx
new file mode 100644
index 000000000..3d56dae71
--- /dev/null
+++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { render } from "ink-testing-library";
+import { RightPane } from "../components/RightPane";
+import type { RightPaneContent } from "../types";
+import { SpinTickProvider } from "../spinTick";
+
+function renderPane(content: RightPaneContent, width = 48) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("RightPane usage", () => {
+ it("renders a quota window with percent + reset countdown and the session block", () => {
+ const resetAt = Math.round(Date.now() / 1000) + 2 * 3600; // 2h out
+ const { lastFrame } = renderPane({
+ kind: "usage",
+ title: "Usage",
+ quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 42, resetAt }],
+ session: { input: 12_000, output: 3_400, cost: 0.42 },
+ });
+ const text = lastFrame() ?? "";
+ expect(text).toContain("USAGE");
+ expect(text).toContain("Rate limit");
+ expect(text).toContain("42%");
+ // Live reset countdown marker.
+ expect(text).toContain("↻");
+ // Session block.
+ expect(text).toContain("This session");
+ expect(text).toContain("12.0k");
+ expect(text).toContain("3.4k");
+ expect(text).toContain("$0.42");
+ });
+
+ it("degrades to the session block when quota windows are unavailable", () => {
+ const { lastFrame } = renderPane({
+ kind: "usage",
+ title: "Usage",
+ quotaWindows: undefined,
+ session: { input: 500, output: 100, cost: 0 },
+ });
+ const text = lastFrame() ?? "";
+ expect(text).toContain("Quota windows unavailable.");
+ expect(text).toContain("This session");
+ expect(text).toContain("$0.00");
+ });
+
+ it("renders a loading state while the snapshot is being fetched", () => {
+ const { lastFrame } = renderPane({ kind: "usage", title: "Usage", loading: true });
+ expect(lastFrame() ?? "").toContain("Loading usage…");
+ });
+
+ it("surfaces an error while keeping the pane title", () => {
+ const { lastFrame } = renderPane({
+ kind: "usage",
+ title: "Usage",
+ error: "daemon offline",
+ session: { input: 1, output: 2, cost: 3 },
+ });
+ const text = lastFrame() ?? "";
+ expect(text).toContain("USAGE");
+ expect(text).toContain("Usage unavailable");
+ expect(text).toContain("daemon offline");
+ });
+
+ it("renders a near-full window's percent (≥95% danger escalation) with no session", () => {
+ const { lastFrame } = renderPane({
+ kind: "usage",
+ title: "Usage",
+ quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 97, resetAt: null }],
+ session: null,
+ });
+ const text = lastFrame() ?? "";
+ expect(text).toContain("Rate limit");
+ expect(text).toContain("97%");
+ expect(text).toContain("No session usage yet.");
+ });
+});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx
index dee6bff06..976c05ee9 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalPane.test.tsx
@@ -8,6 +8,15 @@ function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
}
+/** Poll until `check()` is truthy (xterm write callbacks are async). */
+async function waitFor(check: () => boolean, timeoutMs = 1_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (!check()) {
+ if (Date.now() > deadline) return;
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+}
+
function row(text: string): TerminalSnapshotRow {
return {
text,
@@ -362,4 +371,127 @@ describe("TerminalPane", () => {
expect(frame).not.toContain("MCP server failed");
expect(frame).not.toContain("Resume this session");
});
+
+ it("shows the amber '↓ N new' jump chip when scrolled up and new output is pending", () => {
+ const result = render(
+ ,
+ );
+ const frame = stripAnsi(result.lastFrame() ?? "");
+ expect(frame).toContain("↓ 3 new");
+ });
+
+ it("shows a muted scrollback hint (no chip) when scrolled up with no new output", () => {
+ const result = render(
+ ,
+ );
+ const frame = stripAnsi(result.lastFrame() ?? "");
+ expect(frame).not.toContain("new");
+ expect(frame).toContain("scrollback");
+ });
+
+ it("hides the new-output chip when attached (Claude owns the terminal, always follows bottom)", () => {
+ const result = render(
+ ,
+ );
+ const frame = stripAnsi(result.lastFrame() ?? "");
+ expect(frame).not.toContain("9 new");
+ expect(frame).toContain("CLAUDE CONTROL");
+ });
+
+ it("reports viewport metrics (max scrollable rows + visible text) for keyboard scroll + copy", async () => {
+ const metrics: { maxScrollable: number; visibleText: string }[] = [];
+ render(
+ metrics.push(m)}
+ />,
+ );
+ // xterm's write() callback is async; poll until the visible text settles.
+ await waitFor(() => metrics.some((m) => m.visibleText.includes("line three")));
+ const last = metrics[metrics.length - 1];
+ expect(last).toBeDefined();
+ expect(typeof last?.maxScrollable).toBe("number");
+ expect(metrics.some((m) => m.visibleText.includes("line one"))).toBe(true);
+ expect(metrics.some((m) => m.visibleText.includes("line three"))).toBe(true);
+ });
+
+ it("advances the write cursor past 500 chunks (desync regression) without dropping later output", async () => {
+ // Feed > 500 incremental chunks; every chunk must land in the xterm buffer.
+ // The old slice(-500) on every chunk pinned the buffer and froze the write
+ // cursor at 500, so later rows were silently dropped.
+ //
+ // We assert the REAL invariant — that all 540 rows were written to the
+ // terminal buffer/scrollback — rather than that row539 happens to sit in the
+ // ~6-row viewport at the instant we sample lastFrame() (that capture is
+ // viewport-bound and timing-dependent, which flaked in CI). The component
+ // reports `maxScrollable` = the buffer's scrollback extent (viewportY), which
+ // is a deterministic, monotonic measure of how far the write cursor advanced.
+ const CHUNK_COUNT = 540;
+ const HEIGHT = 6;
+ const chunks = Array.from({ length: CHUNK_COUNT }, (_unused, index) => `row${index}\r\n`);
+ // Each "rowN\r\n" writes one line; with HEIGHT visible rows the scrollback
+ // extent (maxScrollable) settles at (linesWritten - HEIGHT). If the >500
+ // desync regression returns, the cursor stalls near 500 and maxScrollable
+ // can never reach this threshold, so the test still fails on regression.
+ const EXPECTED_MIN_SCROLLBACK = CHUNK_COUNT - HEIGHT; // 534
+
+ const metrics: { maxScrollable: number; visibleText: string }[] = [];
+ render(
+ metrics.push(m)}
+ />,
+ );
+
+ // xterm's write() callback is async; poll until the scrollback extent reports
+ // the full 540 rows landed. Generous timeout so a slow CI render still settles.
+ await waitFor(
+ () => metrics.some((m) => m.maxScrollable >= EXPECTED_MIN_SCROLLBACK),
+ 5_000,
+ );
+ const maxReported = Math.max(0, ...metrics.map((m) => m.maxScrollable));
+ expect(maxReported).toBeGreaterThanOrEqual(EXPECTED_MIN_SCROLLBACK);
+ });
});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts b/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts
new file mode 100644
index 000000000..10b02f9a0
--- /dev/null
+++ b/apps/ade-cli/src/tuiClient/__tests__/TerminalScrollState.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+import {
+ TERMINAL_SCROLL_AT_BOTTOM,
+ clampTerminalScrollOffset,
+ jumpTerminalToBottom,
+ noteTerminalNewRows,
+ readTerminalScroll,
+ scrollTerminalBy,
+ terminalPageStep,
+ type TerminalScrollBySessionId,
+} from "../components/TerminalScrollState";
+
+describe("readTerminalScroll", () => {
+ it("defaults to pinned-at-bottom for unknown / missing session", () => {
+ const map: TerminalScrollBySessionId = {};
+ expect(readTerminalScroll(map, "missing")).toEqual(TERMINAL_SCROLL_AT_BOTTOM);
+ expect(readTerminalScroll(map, null)).toEqual(TERMINAL_SCROLL_AT_BOTTOM);
+ expect(readTerminalScroll(map, undefined)).toEqual(TERMINAL_SCROLL_AT_BOTTOM);
+ });
+
+ it("returns the stored state when present", () => {
+ const state = { scrollOffset: 7, pendingNewCount: 3 };
+ expect(readTerminalScroll({ s1: state }, "s1")).toBe(state);
+ });
+});
+
+describe("clampTerminalScrollOffset", () => {
+ it("clamps to [0, maxScrollable]", () => {
+ expect(clampTerminalScrollOffset(-5, 100)).toBe(0);
+ expect(clampTerminalScrollOffset(50, 100)).toBe(50);
+ expect(clampTerminalScrollOffset(500, 100)).toBe(100);
+ });
+
+ it("floors fractional offsets and tolerates non-finite input", () => {
+ expect(clampTerminalScrollOffset(3.9, 100)).toBe(3);
+ expect(clampTerminalScrollOffset(Number.NaN, 100)).toBe(0);
+ });
+
+ it("allows scrolling well past 500 rows (desync regression: cursor must keep advancing)", () => {
+ // The old slice(-500) pinned the buffer; scrollback must reach the full
+ // retained window (e.g. 2000 rows), not be capped near 500.
+ expect(clampTerminalScrollOffset(1_900, 2_000)).toBe(1_900);
+ expect(clampTerminalScrollOffset(2_000, 2_000)).toBe(2_000);
+ });
+});
+
+describe("scrollTerminalBy", () => {
+ const max = 100;
+
+ it("scrolls up (positive delta) toward older output, clamped", () => {
+ const a = scrollTerminalBy(TERMINAL_SCROLL_AT_BOTTOM, 10, max);
+ expect(a.scrollOffset).toBe(10);
+ const b = scrollTerminalBy(a, 200, max);
+ expect(b.scrollOffset).toBe(100);
+ });
+
+ it("scrolls down (negative delta) toward newest, clearing pending at the bottom", () => {
+ const up = { scrollOffset: 20, pendingNewCount: 5 };
+ const mid = scrollTerminalBy(up, -5, max);
+ expect(mid).toEqual({ scrollOffset: 15, pendingNewCount: 5 });
+ const bottom = scrollTerminalBy(mid, -100, max);
+ expect(bottom).toEqual({ scrollOffset: 0, pendingNewCount: 0 });
+ });
+
+ it("returns the same reference when already pinned at the bottom and going further down", () => {
+ expect(scrollTerminalBy(TERMINAL_SCROLL_AT_BOTTOM, -10, max)).toBe(TERMINAL_SCROLL_AT_BOTTOM);
+ });
+});
+
+describe("jumpTerminalToBottom", () => {
+ it("resets offset and pending counter", () => {
+ expect(jumpTerminalToBottom({ scrollOffset: 40, pendingNewCount: 9 })).toEqual(
+ TERMINAL_SCROLL_AT_BOTTOM,
+ );
+ });
+
+ it("is a no-op (same ref) when already at the bottom", () => {
+ expect(jumpTerminalToBottom(TERMINAL_SCROLL_AT_BOTTOM)).toBe(TERMINAL_SCROLL_AT_BOTTOM);
+ });
+});
+
+describe("noteTerminalNewRows", () => {
+ it("accumulates the counter AND advances scrollOffset to anchor content while scrolled up", () => {
+ // viewportY grows by 3, so scrollOffset must grow by 3 to keep
+ // viewportY - scrollOffset constant (content stays pinned, no drift).
+ const scrolled = { scrollOffset: 12, pendingNewCount: 2 };
+ expect(noteTerminalNewRows(scrolled, 3)).toEqual({ scrollOffset: 15, pendingNewCount: 5 });
+ });
+
+ it("clamps the advanced scrollOffset to maxScrollable", () => {
+ expect(
+ noteTerminalNewRows({ scrollOffset: 98, pendingNewCount: 0 }, 5, 100),
+ ).toEqual({ scrollOffset: 100, pendingNewCount: 5 });
+ });
+
+ it("does not count new output while pinned to the bottom (no chip)", () => {
+ expect(noteTerminalNewRows(TERMINAL_SCROLL_AT_BOTTOM, 10)).toBe(TERMINAL_SCROLL_AT_BOTTOM);
+ });
+
+ it("ignores zero / negative arrivals", () => {
+ const scrolled = { scrollOffset: 4, pendingNewCount: 1 };
+ expect(noteTerminalNewRows(scrolled, 0)).toBe(scrolled);
+ expect(noteTerminalNewRows(scrolled, -3)).toBe(scrolled);
+ });
+});
+
+describe("terminalPageStep", () => {
+ it("pages by half the visible window, at least one row", () => {
+ expect(terminalPageStep(20)).toBe(10);
+ expect(terminalPageStep(1)).toBe(1);
+ expect(terminalPageStep(0)).toBe(1);
+ });
+});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
index 54abfc45c..08d8b67f9 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat";
-import { cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, latestGoal, latestTokenStats, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage } from "../adeApi";
+import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi";
import type { AdeCodeConnection } from "../types";
const tmpPaths: string[] = [];
@@ -56,6 +56,45 @@ describe("listLaneDiffStats", () => {
});
});
+describe("chat session archive helpers", () => {
+ it("lists chats with archived sessions hidden by default", async () => {
+ const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = [];
+ const connection = {
+ actionList: async (domain: string, action: string, argsList: unknown[]) => {
+ calls.push({ domain, action, argsList });
+ return [];
+ },
+ } as unknown as AdeCodeConnection;
+
+ await listChatSessions(connection);
+ await listChatSessions(connection, "lane-1", { includeArchived: true });
+
+ expect(calls).toEqual([
+ { domain: "chat", action: "listSessions", argsList: [null, { includeArchived: false }] },
+ { domain: "chat", action: "listSessions", argsList: ["lane-1", { includeArchived: true }] },
+ ]);
+ });
+
+ it("calls archive, unarchive, and delete chat actions with session ids", async () => {
+ const calls: Array<{ domain: string; action: string; args: Record | undefined }> = [];
+ const connection = {
+ action: async (domain: string, action: string, args?: Record) => {
+ calls.push({ domain, action, args });
+ },
+ } as unknown as AdeCodeConnection;
+
+ await archiveChatSession(connection, "chat-1");
+ await unarchiveChatSession(connection, "chat-2");
+ await deleteChatSession(connection, "chat-3");
+
+ expect(calls).toEqual([
+ { domain: "chat", action: "archiveSession", args: { sessionId: "chat-1" } },
+ { domain: "chat", action: "unarchiveSession", args: { sessionId: "chat-2" } },
+ { domain: "chat", action: "deleteSession", args: { sessionId: "chat-3" } },
+ ]);
+ });
+});
+
describe("latestTokenStats", () => {
it("tracks streaming state, context percentage, token counts, and cost", () => {
const events = [
diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
index c187858a8..8aae7ebb2 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
@@ -10,7 +10,12 @@ import {
drawerMouseHitForLine,
encodeTerminalPromptSubmit,
encodeTerminalPromptSubmitConfirm,
+ applyCoalescedPromptInput,
+ firstUrlInText,
footerControlsForAvailability,
+ inlineRowCellOrder,
+ formatGitConflictReport,
+ formatLaneDeleteRisk,
formFieldUsesPromptInput,
isChatSessionAnimating,
isPromptLineBackspace,
@@ -29,6 +34,8 @@ import {
chatSelectionFromAnchor,
chatSessionToOptimisticSummary,
chatSelectionPointFromVisibleRows,
+ codexApprovalSandboxLabel,
+ cursorModeIdsForState,
moveChatSelectionFocusByRows,
mergeOptimisticChatSessions,
insertPromptText,
@@ -39,7 +46,6 @@ import {
promptDisplayRows,
promptDisplayRowsWithCursor,
promptHitLine,
- modelPickerSurfaceForSetupPane,
resolveContextDefault,
resolveDrawerPaneWidth,
resolveModelPickerEscape,
@@ -360,23 +366,15 @@ describe("right pane context defaults", () => {
});
expect(pane).toMatchObject({
- kind: "new-chat-setup",
+ kind: "model-picker",
+ surface: "new-chat",
laneId: "lane-1",
laneLabel: "Lane one",
+ selection: { kind: "provider", provider: "claude" },
});
});
});
-describe("model setup picker routing", () => {
- it("opens the rich picker against the current chat from /model or /effort setup panes", () => {
- expect(modelPickerSurfaceForSetupPane("model-setup")).toBe("chat");
- });
-
- it("keeps the new-chat picker scoped to the draft setup pane", () => {
- expect(modelPickerSurfaceForSetupPane("new-chat-setup")).toBe("new-chat");
- });
-});
-
describe("drawer mouse hit testing", () => {
it("widens the drawer responsively on larger terminals", () => {
expect(resolveDrawerPaneWidth(100, false)).toBe(0);
@@ -542,12 +540,153 @@ describe("footer control ordering", () => {
});
});
+describe("firstUrlInText", () => {
+ it("finds a bare URL with its index + width and strips trailing punctuation", () => {
+ const hit = firstUrlInText("see https://example.com/docs. thanks");
+ expect(hit?.url).toBe("https://example.com/docs");
+ expect(hit?.index).toBe(4);
+ expect(hit?.width).toBe("https://example.com/docs".length);
+ });
+ it("resolves a markdown link to its href but spans the visible label", () => {
+ const hit = firstUrlInText("[the docs](https://example.com/x)");
+ expect(hit?.url).toBe("https://example.com/x");
+ expect(hit?.index).toBe(0);
+ expect(hit?.width).toBe("the docs".length);
+ });
+ it("returns null when there is no link", () => {
+ expect(firstUrlInText("just some plain text")).toBeNull();
+ });
+});
+
+describe("inlineRowCellOrder", () => {
+ it("includes fast + reasoning only when supported, and provider/subagents per context", () => {
+ expect(inlineRowCellOrder({ providerLocked: false, fastSupported: true, reasoningSupported: true, subagentsVisible: true }))
+ .toEqual(["provider", "model", "fast", "reasoning", "permission", "subagents"]);
+ // No fast/reasoning support → those cells are absent (not dead focus stops).
+ expect(inlineRowCellOrder({ providerLocked: false, fastSupported: false, reasoningSupported: false, subagentsVisible: false }))
+ .toEqual(["provider", "model", "permission"]);
+ // Provider locked (chat underway) drops the provider cell.
+ expect(inlineRowCellOrder({ providerLocked: true, fastSupported: true, reasoningSupported: false, subagentsVisible: false }))
+ .toEqual(["model", "fast", "permission"]);
+ });
+});
+
+describe("provider permission helpers", () => {
+ it("summarizes Codex approval and sandbox as a footer detail", () => {
+ expect(codexApprovalSandboxLabel({
+ codexApprovalPolicy: "on-request",
+ codexSandbox: "workspace-write",
+ })).toBe("on-request · workspace-write");
+ });
+
+ it("uses Cursor runtime snapshot modes before falling back to static modes", () => {
+ expect(cursorModeIdsForState({ cursorAvailableModeIds: ["ask", "plan"] })).toEqual(["ask", "plan"]);
+ expect(cursorModeIdsForState({ cursorAvailableModeIds: [] })).toContain("agent");
+ });
+});
+
+describe("formatGitConflictReport", () => {
+ it("lists conflicted files and the continue/abort actions for a rebase", () => {
+ const report = formatGitConflictReport({
+ laneId: "lane-1",
+ kind: "rebase",
+ inProgress: true,
+ conflictedFiles: ["src/a.ts", "src/b.ts"],
+ canContinue: true,
+ canAbort: true,
+ });
+ expect(report.title).toBe("Rebase conflict");
+ expect(report.body).toContain("2 files need resolution");
+ expect(report.body).toContain("src/a.ts");
+ expect(report.body).toContain("/pull --continue");
+ expect(report.body).toContain("/pull --abort");
+ expect(report.summary).toContain("Rebase conflict — 2 files");
+ });
+
+ it("uses merge wording and falls back when no continue/abort is available", () => {
+ const report = formatGitConflictReport({
+ laneId: "lane-1",
+ kind: "merge",
+ inProgress: true,
+ conflictedFiles: [],
+ canContinue: false,
+ canAbort: false,
+ });
+ expect(report.title).toBe("Merge conflict");
+ expect(report.body).toContain("0 files need resolution");
+ expect(report.body).toContain("git did not report specific files");
+ expect(report.body).toContain("Resolve the conflicts in your editor");
+ expect(report.body).not.toContain("/pull --continue");
+ });
+});
+
+describe("applyCoalescedPromptInput", () => {
+ const DEL = "\u007f";
+ it("inserts pure printable input unchanged", () => {
+ expect(applyCoalescedPromptInput("", 0, "abc")).toEqual({ value: "abc", cursor: 3 });
+ expect(applyCoalescedPromptInput("ac", 1, "b")).toEqual({ value: "abc", cursor: 2 });
+ });
+ it("applies a backspace that was coalesced with a typed char (the bug)", () => {
+ // "x" typed then immediately backspaced, delivered as one chunk.
+ expect(applyCoalescedPromptInput("", 0, `x${DEL}`)).toEqual({ value: "", cursor: 0 });
+ });
+ it("applies multiple coalesced backspaces", () => {
+ expect(applyCoalescedPromptInput("ab", 2, `${DEL}${DEL}`)).toEqual({ value: "", cursor: 0 });
+ });
+ it("interleaves deletes and inserts in order", () => {
+ expect(applyCoalescedPromptInput("", 0, `a${DEL}b`)).toEqual({ value: "b", cursor: 1 });
+ expect(applyCoalescedPromptInput("yz", 2, `${DEL}x`)).toEqual({ value: "yx", cursor: 2 });
+ });
+ it("strips other control bytes but keeps text", () => {
+ expect(applyCoalescedPromptInput("", 0, "a\u0000b")).toEqual({ value: "ab", cursor: 2 });
+ });
+});
+
+describe("formatLaneDeleteRisk", () => {
+ const base = {
+ laneId: "lane-1",
+ branchRef: "feat/x",
+ dirty: false,
+ hasUnpushedCommits: false,
+ unpushedCommitCount: 0,
+ remoteBranchExists: false,
+ runningProcessCount: 0,
+ activePtyCount: 0,
+ activeWatcherCount: 0,
+ envInitialized: false,
+ };
+
+ it("summarizes everything that would be lost, pluralizing correctly", () => {
+ const summary = formatLaneDeleteRisk({
+ ...base,
+ dirty: true,
+ hasUnpushedCommits: true,
+ unpushedCommitCount: 1,
+ runningProcessCount: 2,
+ activePtyCount: 1,
+ remoteBranchExists: true,
+ });
+ expect(summary).toContain("uncommitted changes");
+ expect(summary).toContain("1 unpushed commit");
+ expect(summary).not.toContain("1 unpushed commits");
+ expect(summary).toContain("2 running processes");
+ expect(summary).toContain("1 terminal");
+ expect(summary).toContain("remote branch exists");
+ expect(summary.startsWith("⚠")).toBe(true);
+ });
+
+ it("reports a clean lane when there is nothing at risk", () => {
+ expect(formatLaneDeleteRisk(base)).toBe("Clean — no unpushed work or running processes.");
+ });
+});
+
describe("model picker escape handling", () => {
const picker = {
kind: "model-picker" as const,
surface: "chat" as const,
query: "",
searchMode: false,
+ showAll: false,
selection: { kind: "favorites" as const },
focusedIndex: 3,
};
diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
index 0e2d1771f..1c3feec6c 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
@@ -40,6 +40,27 @@ describe("commands", () => {
}));
});
+ it("parses the /lane management commands as multi-word ADE commands", () => {
+ const rename = parseCommand("/lane rename My New Lane");
+ expect(rename?.name).toBe("/lane rename");
+ expect(rename?.args).toBe("My New Lane");
+ expect(rename ? commandPlacement(rename) : null).toBe("right");
+
+ const archive = parseCommand("/lane archive");
+ expect(archive?.name).toBe("/lane archive");
+ expect(archive?.args).toBe("");
+
+ const unarchive = parseCommand("/lane unarchive feat/x");
+ expect(unarchive?.name).toBe("/lane unarchive");
+ expect(unarchive?.args).toBe("feat/x");
+
+ // /lane delete must still match (longest-name-first ordering).
+ expect(parseCommand("/lane delete")?.name).toBe("/lane delete");
+ expect(paletteCommands("/lane").map((c) => c.name)).toEqual(
+ expect.arrayContaining(["/lane rename", "/lane archive", "/lane unarchive", "/lane archived", "/lane delete"]),
+ );
+ });
+
it("routes /effort to the ADE Code right pane", () => {
const parsed = parseCommand("/effort");
expect(parsed?.spec?.name).toBe("/effort");
@@ -187,7 +208,7 @@ describe("commands", () => {
});
it("filters provider-specific ADE commands outside supported chats", () => {
- expect(paletteCommands("/context", [], { provider: "codex" })).not.toContainEqual(
+ expect(paletteCommands("/context", [], { provider: "codex" })).toContainEqual(
expect.objectContaining({ name: "/context" }),
);
expect(paletteCommands("/output-style", [], { provider: "codex" })).not.toContainEqual(
diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts
index fe07ff001..107b03140 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts
@@ -621,6 +621,46 @@ describe("JsonRpcClient", () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
+
+ it("fires onClose when the socket drops unexpectedly", async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-"));
+ const socketPath = path.join(tmpDir, "rpc.sock");
+ let resolveServerSocket: (socket: net.Socket) => void = () => {};
+ const serverSocketReady = new Promise((resolve) => {
+ resolveServerSocket = resolve;
+ });
+ const server = net.createServer((socket) => resolveServerSocket(socket));
+ await listenRpc(server, socketPath);
+ const client = await JsonRpcClient.connect(socketPath);
+ const socket = await serverSocketReady;
+ try {
+ const closed = new Promise((resolve) => client.onClose(resolve));
+ socket.destroy();
+ await expect(closed).resolves.toBeUndefined();
+ } finally {
+ client.close();
+ await closeServer(server);
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not fire onClose on an intentional close()", async () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-"));
+ const socketPath = path.join(tmpDir, "rpc.sock");
+ const server = net.createServer(() => {});
+ await listenRpc(server, socketPath);
+ const client = await JsonRpcClient.connect(socketPath);
+ try {
+ const onClose = vi.fn();
+ client.onClose(onClose);
+ client.close();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(onClose).not.toHaveBeenCalled();
+ } finally {
+ await closeServer(server);
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
});
async function loadStateModule(home: string): Promise {
diff --git a/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts b/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts
new file mode 100644
index 000000000..db46c7af4
--- /dev/null
+++ b/apps/ade-cli/src/tuiClient/__tests__/feedbackForm.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, expect } from "vitest";
+import {
+ FEEDBACK_TYPES,
+ feedbackFormInitialState,
+ feedbackFormReducer,
+ feedbackFormCanSubmit,
+ feedbackFormToFormValues,
+ feedbackTypeToCategory,
+ feedbackSummaryLine,
+ serializeContextFooter,
+ cycleFeedbackType,
+ type ContextFooterInfo,
+ type FeedbackType,
+} from "../feedbackForm.js";
+import { buildFeedbackDraftInput } from "../feedback.js";
+
+describe("feedbackFormInitialState", () => {
+ it("defaults to bug type, empty text, context enabled", () => {
+ const s = feedbackFormInitialState();
+ expect(s.type).toBe("bug");
+ expect(s.text).toBe("");
+ expect(s.showContext).toBe(true);
+ expect(s.context).toEqual({});
+ });
+
+ it("captures the provided context snapshot by copy", () => {
+ const ctx: ContextFooterInfo = { provider: "anthropic", model: "opus" };
+ const s = feedbackFormInitialState(ctx);
+ expect(s.context).toEqual(ctx);
+ ctx.provider = "mutated";
+ expect(s.context.provider).toBe("anthropic");
+ });
+});
+
+describe("feedbackTypeToCategory", () => {
+ it("maps UI types onto existing daemon categories", () => {
+ expect(feedbackTypeToCategory("bug")).toBe("bug");
+ expect(feedbackTypeToCategory("idea")).toBe("feature");
+ expect(feedbackTypeToCategory("praise")).toBe("enhancement");
+ });
+});
+
+describe("cycleFeedbackType", () => {
+ it("cycles forward with wrap", () => {
+ expect(cycleFeedbackType("bug", 1)).toBe("idea");
+ expect(cycleFeedbackType("idea", 1)).toBe("praise");
+ expect(cycleFeedbackType("praise", 1)).toBe("bug");
+ });
+ it("cycles backward with wrap", () => {
+ expect(cycleFeedbackType("bug", -1)).toBe("praise");
+ expect(cycleFeedbackType("praise", -1)).toBe("idea");
+ });
+ it("covers every declared type", () => {
+ let t: FeedbackType = FEEDBACK_TYPES[0];
+ const seen = new Set();
+ for (let i = 0; i < FEEDBACK_TYPES.length; i++) {
+ seen.add(t);
+ t = cycleFeedbackType(t, 1);
+ }
+ expect(seen.size).toBe(FEEDBACK_TYPES.length);
+ });
+});
+
+describe("feedbackFormReducer", () => {
+ const base = feedbackFormInitialState();
+
+ it("setType changes the type", () => {
+ expect(feedbackFormReducer(base, { kind: "setType", type: "idea" }).type).toBe("idea");
+ });
+ it("setType is a no-op (same reference) when unchanged", () => {
+ expect(feedbackFormReducer(base, { kind: "setType", type: "bug" })).toBe(base);
+ });
+
+ it("cycleType moves through types", () => {
+ expect(feedbackFormReducer(base, { kind: "cycleType", direction: 1 }).type).toBe("idea");
+ });
+
+ it("setText replaces the body", () => {
+ expect(feedbackFormReducer(base, { kind: "setText", text: "hello" }).text).toBe("hello");
+ });
+
+ it("appendChar / appendNewline / backspace edit the body", () => {
+ let s = feedbackFormReducer(base, { kind: "appendChar", char: "a" });
+ s = feedbackFormReducer(s, { kind: "appendNewline" });
+ s = feedbackFormReducer(s, { kind: "appendChar", char: "b" });
+ expect(s.text).toBe("a\nb");
+ s = feedbackFormReducer(s, { kind: "backspace" });
+ expect(s.text).toBe("a\n");
+ });
+
+ it("backspace on empty text is a no-op (same reference)", () => {
+ expect(feedbackFormReducer(base, { kind: "backspace" })).toBe(base);
+ });
+
+ it("toggleContext flips the flag", () => {
+ const off = feedbackFormReducer(base, { kind: "toggleContext" });
+ expect(off.showContext).toBe(false);
+ expect(feedbackFormReducer(off, { kind: "toggleContext" }).showContext).toBe(true);
+ });
+
+ it("setContext replaces and copies context", () => {
+ const ctx: ContextFooterInfo = { lane: "my-lane" };
+ const s = feedbackFormReducer(base, { kind: "setContext", context: ctx });
+ expect(s.context).toEqual(ctx);
+ expect(s.context).not.toBe(ctx);
+ });
+
+ it("reset returns to initial state preserving context", () => {
+ let s = feedbackFormReducer(base, { kind: "setText", text: "abc" });
+ s = feedbackFormReducer(s, { kind: "setContext", context: { lane: "L" } });
+ const r = feedbackFormReducer(s, { kind: "reset" });
+ expect(r.text).toBe("");
+ expect(r.type).toBe("bug");
+ expect(r.showContext).toBe(true);
+ expect(r.context).toEqual({ lane: "L" });
+ });
+});
+
+describe("feedbackFormCanSubmit", () => {
+ it("is false for empty / whitespace-only text", () => {
+ expect(feedbackFormCanSubmit(feedbackFormInitialState())).toBe(false);
+ expect(
+ feedbackFormCanSubmit({ ...feedbackFormInitialState(), text: " \n\t " }),
+ ).toBe(false);
+ });
+ it("is true once there is real content", () => {
+ expect(
+ feedbackFormCanSubmit({ ...feedbackFormInitialState(), text: " x " }),
+ ).toBe(true);
+ });
+});
+
+describe("feedbackSummaryLine", () => {
+ it("uses the first non-empty line of a multiline body", () => {
+ expect(feedbackSummaryLine("\n\n the title \nmore detail")).toBe("the title");
+ });
+ it("is empty when the body is blank", () => {
+ expect(feedbackSummaryLine(" \n ")).toBe("");
+ });
+});
+
+describe("serializeContextFooter", () => {
+ it("returns empty string when nothing useful is present", () => {
+ expect(serializeContextFooter({})).toBe("");
+ expect(serializeContextFooter({ provider: "", model: null, lane: " " })).toBe("");
+ });
+ it("combines provider and model on one line", () => {
+ expect(serializeContextFooter({ provider: "anthropic", model: "opus" })).toContain(
+ "Provider/Model: anthropic / opus",
+ );
+ });
+ it("emits only the fields that are present", () => {
+ const out = serializeContextFooter({ model: "opus", lane: "lane-1" });
+ expect(out).toContain("Provider/Model: opus");
+ expect(out).toContain("Lane: lane-1");
+ expect(out).not.toContain("Last error");
+ });
+ it("includes last error/notice", () => {
+ expect(serializeContextFooter({ lastError: "boom failed" })).toContain(
+ "Last error/notice: boom failed",
+ );
+ });
+});
+
+describe("feedbackFormToFormValues", () => {
+ it("derives summary + multiline details and maps the category", () => {
+ const s = {
+ ...feedbackFormInitialState(),
+ type: "idea" as const,
+ text: "the headline\nline two\nline three",
+ showContext: false,
+ };
+ const values = feedbackFormToFormValues(s);
+ expect(values.category).toBe("feature");
+ expect(values.summary).toBe("the headline");
+ expect(values.details).toBe("the headline\nline two\nline three");
+ expect(values.additionalContext).toBeUndefined();
+ });
+
+ it("puts the context footer into additionalContext when enabled", () => {
+ const s = {
+ ...feedbackFormInitialState(),
+ text: "it broke",
+ showContext: true,
+ context: { provider: "anthropic", model: "opus", lane: "L", lastError: "E" },
+ };
+ const values = feedbackFormToFormValues(s);
+ expect(values.additionalContext).toContain("--- Context ---");
+ expect(values.additionalContext).toContain("Provider/Model: anthropic / opus");
+ expect(values.additionalContext).toContain("Lane: L");
+ expect(values.additionalContext).toContain("Last error/notice: E");
+ });
+
+ it("omits additionalContext when context is toggled off", () => {
+ const s = {
+ ...feedbackFormInitialState(),
+ text: "it broke",
+ showContext: false,
+ context: { provider: "anthropic" },
+ };
+ expect(feedbackFormToFormValues(s).additionalContext).toBeUndefined();
+ });
+});
+
+describe("integration with buildFeedbackDraftInput (existing daemon path)", () => {
+ it("multiline body survives into a bug draft and context lands in additionalContext", () => {
+ const s = {
+ ...feedbackFormInitialState(),
+ type: "bug" as const,
+ text: "crash on launch\nstep 1\nstep 2",
+ showContext: true,
+ context: { provider: "anthropic", model: "opus", lane: "L" },
+ };
+ const draft = buildFeedbackDraftInput(feedbackFormToFormValues(s));
+ expect(draft.category).toBe("bug");
+ expect(draft.summary).toBe("crash on launch");
+ // details -> stepsToReproduce for bug category; newlines preserved.
+ if (draft.category === "bug") {
+ expect(draft.stepsToReproduce).toBe("crash on launch\nstep 1\nstep 2");
+ }
+ expect(draft.additionalContext).toContain("--- Context ---");
+ expect(draft.additionalContext).toContain("Provider/Model: anthropic / opus");
+ });
+
+ it("idea maps to a feature draft", () => {
+ const s = {
+ ...feedbackFormInitialState(),
+ type: "idea" as const,
+ text: "add dark mode\nwould be nice",
+ showContext: false,
+ };
+ const draft = buildFeedbackDraftInput(feedbackFormToFormValues(s));
+ expect(draft.category).toBe("feature");
+ expect(draft.summary).toBe("add dark mode");
+ });
+});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts
index 012473af3..4aae37756 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts
@@ -1,5 +1,21 @@
import { describe, expect, it } from "vitest";
-import { latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format";
+import { diffLineKind, latestExpandableFailureId, parseAssistantMarkdown, parseInlineRuns, renderChatLines, renderObject } from "../format";
+
+describe("diffLineKind", () => {
+ it("classifies hunk, meta, add, del, and context lines", () => {
+ expect(diffLineKind("@@ -1,4 +1,6 @@ fn main()")).toBe("hunk");
+ expect(diffLineKind("diff --git a/x b/x")).toBe("meta");
+ expect(diffLineKind("index 1234..5678 100644")).toBe("meta");
+ expect(diffLineKind("--- a/x")).toBe("meta");
+ expect(diffLineKind("+++ b/x")).toBe("meta");
+ expect(diffLineKind("new file mode 100644")).toBe("meta");
+ expect(diffLineKind("\\ No newline at end of file")).toBe("meta");
+ expect(diffLineKind("+added content")).toBe("add");
+ expect(diffLineKind("-removed content")).toBe("del");
+ expect(diffLineKind(" unchanged context")).toBe("context");
+ expect(diffLineKind("")).toBe("context");
+ });
+});
describe("renderChatLines", () => {
it("parses assistant markdown into stable blocks", () => {
diff --git a/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts b/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts
new file mode 100644
index 000000000..852265095
--- /dev/null
+++ b/apps/ade-cli/src/tuiClient/__tests__/helpIndex.test.ts
@@ -0,0 +1,216 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildHelpIndex,
+ buildHelpRows,
+ flattenHelpRows,
+ formatChordForDisplay,
+ getKeybindForCommand,
+ helpMatchScore,
+ pushRecent,
+ type HelpGroup,
+} from "../helpIndex";
+import { BUILTIN_COMMANDS, COMMAND_CATEGORY_ORDER } from "../commands";
+import type { ClaudeKeybinding } from "../keybindings";
+
+function rowNames(groups: HelpGroup[]): string[] {
+ return flattenHelpRows(groups).map((r) => r.name);
+}
+
+// A minimal live-registry double: /help bound to ctrl+/, /model to ctrl+o ctrl+m.
+const BINDINGS: ClaudeKeybinding[] = [
+ { context: "Global", key: "ctrl+/", action: "app:help", rawAction: "app:help", implemented: true },
+ { context: "Global", key: "ctrl+o ctrl+m", action: "chat:modelPicker", rawAction: "chat:modelPicker", implemented: true },
+ { context: "Global", key: "ctrl+z", action: "app:redraw", rawAction: "app:redraw", implemented: false },
+];
+
+describe("buildHelpIndex", () => {
+ it("groups every built-in command into a category bucket", () => {
+ const groups = buildHelpIndex();
+ const total = groups.reduce((sum, g) => sum + g.rows.length, 0);
+ expect(total).toBe(BUILTIN_COMMANDS.length);
+ });
+
+ it("only emits non-empty groups, in category order", () => {
+ const groups = buildHelpIndex();
+ for (const g of groups) expect(g.rows.length).toBeGreaterThan(0);
+ const order = groups.map((g) => g.category);
+ const expectedOrder = COMMAND_CATEGORY_ORDER.filter((c) => order.includes(c));
+ expect(order).toEqual(expectedOrder);
+ });
+
+ it("preserves command metadata (name + description)", () => {
+ const groups = buildHelpIndex();
+ const commit = flattenHelpRows(groups).find((r) => r.name === "/commit");
+ expect(commit).toBeDefined();
+ expect(commit?.description).toBe("Commit lane changes");
+ expect(commit?.category).toBe("Lanes");
+ });
+
+ it("places /commit under Lanes and /pr under PRs", () => {
+ const groups = buildHelpIndex();
+ const lanes = groups.find((g) => g.category === "Lanes");
+ const prs = groups.find((g) => g.category === "PRs");
+ expect(lanes?.rows.some((r) => r.name === "/commit")).toBe(true);
+ expect(prs?.rows.some((r) => r.name === "/pr")).toBe(true);
+ });
+
+ it("attaches keybinds for commands bound in the live registry", () => {
+ const groups = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS);
+ const help = flattenHelpRows(groups).find((r) => r.name === "/help");
+ expect(help?.keybind).toBe("Ctrl+/");
+ const model = flattenHelpRows(groups).find((r) => r.name === "/model");
+ expect(model?.keybind).toBe("Ctrl+O Ctrl+M");
+ });
+
+ it("leaves unbound commands without a keybind", () => {
+ const groups = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS);
+ const commit = flattenHelpRows(groups).find((r) => r.name === "/commit");
+ expect(commit?.keybind).toBeUndefined();
+ });
+
+ it("renders no keybinds when the registry is empty (the default)", () => {
+ const groups = buildHelpIndex(BUILTIN_COMMANDS, []);
+ expect(flattenHelpRows(groups).every((r) => r.keybind === undefined)).toBe(true);
+ });
+});
+
+describe("getKeybindForCommand", () => {
+ it("returns the formatted chord for a bound command", () => {
+ expect(getKeybindForCommand("/help", BINDINGS)).toBe("Ctrl+/");
+ });
+
+ it("returns undefined for an unbound command", () => {
+ expect(getKeybindForCommand("/commit", BINDINGS)).toBeUndefined();
+ });
+
+ it("ignores unimplemented bindings", () => {
+ // app:redraw is present but implemented:false, and no command maps to it anyway.
+ expect(getKeybindForCommand("/quit", BINDINGS)).toBeUndefined();
+ });
+
+ it("degrades to undefined when no registry is provided", () => {
+ expect(getKeybindForCommand("/help", null)).toBeUndefined();
+ expect(getKeybindForCommand("/help", [])).toBeUndefined();
+ });
+});
+
+describe("formatChordForDisplay", () => {
+ it("capitalizes modifiers and single keys", () => {
+ expect(formatChordForDisplay("ctrl+p")).toBe("Ctrl+P");
+ expect(formatChordForDisplay("shift+tab")).toBe("Shift+Tab");
+ });
+
+ it("handles multi-stroke chords", () => {
+ expect(formatChordForDisplay("ctrl+o ctrl+m")).toBe("Ctrl+O Ctrl+M");
+ });
+});
+
+describe("helpMatchScore", () => {
+ const row = { name: "/push", description: "Push the active lane branch", source: "ade" as const, category: "Lanes" as const };
+
+ it("scores exact name highest", () => {
+ expect(helpMatchScore("/push", row)).toBe(1000);
+ expect(helpMatchScore("push", row)).toBe(1000);
+ });
+
+ it("scores prefix above description", () => {
+ const prefix = helpMatchScore("pus", row);
+ const desc = helpMatchScore("branch", row);
+ expect(prefix).toBeGreaterThan(desc);
+ });
+
+ it("returns 0 for an empty query", () => {
+ expect(helpMatchScore("", row)).toBe(0);
+ });
+
+ it("disqualifies rows where a token matches nowhere", () => {
+ expect(helpMatchScore("zzzqqq", row)).toBe(-1);
+ });
+
+ it("is case-insensitive", () => {
+ expect(helpMatchScore("PUSH", row)).toBe(1000);
+ });
+});
+
+describe("buildHelpRows", () => {
+ const index = buildHelpIndex(BUILTIN_COMMANDS, BINDINGS);
+
+ it("with an empty filter keeps every command", () => {
+ const rows = buildHelpRows(index, "", []);
+ const total = rows.reduce((sum, g) => sum + g.rows.length, 0);
+ expect(total).toBe(BUILTIN_COMMANDS.length);
+ });
+
+ it("narrows to matching commands and drops emptied groups", () => {
+ const rows = buildHelpRows(index, "push", []);
+ const names = rowNames(rows);
+ expect(names).toContain("/push");
+ // No Linear command contains "push", so the Linear group must be gone.
+ expect(rows.some((g) => g.category === "Linear")).toBe(false);
+ });
+
+ it("is case-insensitive in filtering", () => {
+ const rows = buildHelpRows(index, "CHAT", []);
+ const names = rowNames(rows);
+ expect(names).toContain("/chat rename");
+ });
+
+ it("ranks prefix matches before non-prefix matches within a group", () => {
+ const rows = buildHelpRows(index, "pr", []);
+ const prGroup = rows.find((g) => g.category === "PRs");
+ expect(prGroup).toBeDefined();
+ // "/pr" is an exact/prefix hit; it must sort to the top of the PRs group.
+ expect(prGroup!.rows[0].name).toBe("/pr");
+ });
+
+ it("floats a recent command above a non-recent peer at the same score", () => {
+ // Empty filter ⇒ all scores equal 0; recents decide order within a group.
+ const rows = buildHelpRows(index, "", ["/push"]);
+ const lanes = rows.find((g) => g.category === "Lanes");
+ expect(lanes).toBeDefined();
+ expect(lanes!.rows[0].name).toBe("/push");
+ });
+
+ it("orders multiple recents most-recent-first", () => {
+ const rows = buildHelpRows(index, "", ["/commit", "/push"]);
+ const lanes = rows.find((g) => g.category === "Lanes")!;
+ const commitIdx = lanes.rows.findIndex((r) => r.name === "/commit");
+ const pushIdx = lanes.rows.findIndex((r) => r.name === "/push");
+ expect(commitIdx).toBeLessThan(pushIdx);
+ });
+
+ it("does not break ranking with an empty recents list", () => {
+ const rows = buildHelpRows(index, "", []);
+ const lanes = rows.find((g) => g.category === "Lanes")!;
+ // Pure alphabetical fallback.
+ const names = lanes.rows.map((r) => r.name);
+ const sorted = [...names].sort((a, b) => a.localeCompare(b));
+ expect(names).toEqual(sorted);
+ });
+});
+
+describe("flattenHelpRows", () => {
+ it("flattens groups in order", () => {
+ const groups = buildHelpRows(buildHelpIndex(), "", []);
+ const flat = flattenHelpRows(groups);
+ expect(flat.length).toBe(BUILTIN_COMMANDS.length);
+ // First flat row belongs to the first group.
+ expect(flat[0].category).toBe(groups[0].category);
+ });
+});
+
+describe("pushRecent", () => {
+ it("adds to the front, de-dupes, and caps length", () => {
+ let recents: string[] = [];
+ recents = pushRecent(recents, "/a");
+ recents = pushRecent(recents, "/b");
+ recents = pushRecent(recents, "/a"); // re-run /a moves it to front
+ expect(recents).toEqual(["/a", "/b"]);
+ });
+
+ it("respects the limit", () => {
+ let recents: string[] = [];
+ for (const n of ["/1", "/2", "/3", "/4", "/5", "/6"]) recents = pushRecent(recents, n, 5);
+ expect(recents).toEqual(["/6", "/5", "/4", "/3", "/2"]);
+ });
+});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts
index 520b47260..18be57639 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/multiChatLayout.test.ts
@@ -26,6 +26,33 @@ describe("multi chat layout", () => {
]);
});
+ it("fills the full pane with no dead margin when dimensions don't divide evenly", () => {
+ // 100 / 3 cols and 31 / 2 rows do not divide evenly — the old floor math
+ // left a dead column/row. Every grid row must now span the full width, and
+ // every column must span the full height.
+ for (const count of [2, 3, 4, 5, 6] as const) {
+ const rects = computeTileRects(count, 100, 31);
+ // Rightmost edge across each pattern row reaches the full width.
+ const byRow = new Map();
+ for (const rect of rects) {
+ const bucket = byRow.get(rect.y) ?? [];
+ bucket.push(rect);
+ byRow.set(rect.y, bucket);
+ }
+ for (const row of byRow.values()) {
+ const rightmost = Math.max(...row.map((r) => r.x + r.w));
+ expect(rightmost).toBe(100);
+ // No gaps/overlaps within a row: sorted edges are contiguous.
+ const sorted = [...row].sort((a, b) => a.x - b.x);
+ for (let i = 1; i < sorted.length; i += 1) {
+ expect(sorted[i]!.x).toBe(sorted[i - 1]!.x + sorted[i - 1]!.w);
+ }
+ }
+ // Bottom edge reaches the full height.
+ expect(Math.max(...rects.map((r) => r.y + r.h))).toBe(31);
+ }
+ });
+
it("flags layouts that are too narrow or too short for readable tiles", () => {
expect(canRenderMultiChatGrid(6, 120, 24)).toBe(true);
expect(canRenderMultiChatGrid(6, 80, 24)).toBe(false);
diff --git a/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts b/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts
new file mode 100644
index 000000000..8555b8fea
Binary files /dev/null and b/apps/ade-cli/src/tuiClient/__tests__/multilinePaste.test.ts differ
diff --git a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts
index 6f363fb19..14906bc11 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts
@@ -103,4 +103,32 @@ describe("pendingInput", () => {
highStakes: true,
}));
});
+
+ it("keeps plan approvals on the one-key approval path", () => {
+ const request: PendingInputRequest = {
+ ...baseRequest,
+ kind: "plan_approval",
+ title: "Approve plan",
+ questions: [],
+ allowsFreeform: false,
+ };
+ const events: AgentChatEventEnvelope[] = [{
+ sessionId: "s1",
+ timestamp: "2026-01-01T00:00:00.000Z",
+ sequence: 1,
+ event: {
+ type: "approval_request",
+ itemId: "item-plan",
+ kind: "tool_call",
+ description: "Approve plan",
+ detail: { request },
+ },
+ }];
+
+ expect(latestPendingApproval(events)).toEqual(expect.objectContaining({
+ itemId: "item-plan",
+ mode: "approval",
+ highStakes: false,
+ }));
+ });
});
diff --git a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts
index 90705882d..a7aba0888 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts
@@ -20,6 +20,7 @@ function baseModelState(overrides: Partial): AdeCodeModelStat
opencodePermissionMode: "edit",
droidPermissionMode: "auto-low",
cursorModeId: "agent",
+ cursorAvailableModeIds: [],
cursorConfigValues: {},
...overrides,
};
diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts
index 8824c951d..3ef845b44 100644
--- a/apps/ade-cli/src/tuiClient/adeApi.ts
+++ b/apps/ade-cli/src/tuiClient/adeApi.ts
@@ -50,9 +50,12 @@ import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateReque
export const DEFAULT_CODEX_REASONING_EFFORT = "low";
-export async function listLanes(connection: AdeCodeConnection): Promise {
+export async function listLanes(
+ connection: AdeCodeConnection,
+ options: { includeArchived?: boolean } = {},
+): Promise {
return await connection.action("lane", "list", {
- includeArchived: false,
+ includeArchived: options.includeArchived ?? false,
includeStatus: true,
});
}
@@ -69,11 +72,34 @@ export async function listLaneDiffStats(
export async function listChatSessions(
connection: AdeCodeConnection,
laneId?: string | null,
+ options: { includeArchived?: boolean } = {},
): Promise {
- const argsList = laneId ? [laneId] : [];
+ const listOptions = { includeArchived: options.includeArchived ?? false };
+ const argsList = laneId ? [laneId, listOptions] : [null, listOptions];
return await connection.actionList("chat", "listSessions", argsList);
}
+export async function archiveChatSession(
+ connection: AdeCodeConnection,
+ sessionId: string,
+): Promise {
+ await connection.action("chat", "archiveSession", { sessionId });
+}
+
+export async function unarchiveChatSession(
+ connection: AdeCodeConnection,
+ sessionId: string,
+): Promise {
+ await connection.action("chat", "unarchiveSession", { sessionId });
+}
+
+export async function deleteChatSession(
+ connection: AdeCodeConnection,
+ sessionId: string,
+): Promise {
+ await connection.action("chat", "deleteSession", { sessionId });
+}
+
const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([
"codex-chat",
"claude-chat",
diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx
index aa9cd4aa4..9efb90e99 100644
--- a/apps/ade-cli/src/tuiClient/app.tsx
+++ b/apps/ade-cli/src/tuiClient/app.tsx
@@ -22,7 +22,6 @@ import type {
AgentChatCodexSandbox,
AgentChatClaudePlugin,
AgentChatReloadClaudePluginsResult,
- AgentChatContextUsage,
AgentChatEventEnvelope,
AgentChatFileRef,
AgentChatModelCatalog,
@@ -36,15 +35,17 @@ import type {
CodexThreadGoal,
} from "../../../desktop/src/shared/types/chat";
import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config";
-import type { DiffLineStats } from "../../../desktop/src/shared/types/git";
-import type { LaneSummary } from "../../../desktop/src/shared/types/lanes";
+import type { DiffLineStats, GitConflictState } from "../../../desktop/src/shared/types/git";
+import type { LaneDeleteRisk, LaneSummary } from "../../../desktop/src/shared/types/lanes";
import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback";
import type { ChatTerminalPreviewResult, ChatTerminalSession } from "../../../desktop/src/shared/types";
import {
DEFAULT_CODEX_REASONING_EFFORT,
approveToolUse,
+ archiveChatSession,
cancelSteerMessage,
createChatSession,
+ deleteChatSession,
discoverProjectSlashCommands,
dispatchSteerMessage,
editSteerMessage,
@@ -86,13 +87,15 @@ import {
startClaudeTerminalSession,
steerChatMessage,
tagChat,
+ unarchiveChatSession,
updateChatModel,
writeTerminal,
type TokenStats,
} from "./adeApi";
-import { derivePendingSteers } from "./aggregate";
+import { aggregateChatBlocks, derivePendingSteers, type AggregatedBlock } from "./aggregate";
import { deriveChatInfoSnapshot } from "./chatInfo";
-import { paletteCommands, parseCommand } from "./commands";
+import { BUILTIN_COMMANDS, paletteCommands, parseCommand } from "./commands";
+import { buildHelpIndex, buildHelpRows, flattenHelpRows, pushRecent } from "./helpIndex";
import { hasFirstUserMessage, isPlanMode } from "./planMode";
import { connectToAde } from "./connection";
import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount, type DrawerPrSummary } from "./components/Drawer";
@@ -106,11 +109,22 @@ import {
type ChatTextSelection,
} from "./components/ChatView";
import { TerminalPane, clampTerminalPaneCols } from "./components/TerminalPane";
+import {
+ type TerminalScrollBySessionId,
+ clampTerminalScrollOffset,
+ jumpTerminalToBottom,
+ noteTerminalNewRows,
+ readTerminalScroll,
+ scrollTerminalBy,
+ terminalPageStep,
+} from "./components/TerminalScrollState";
import { Header } from "./components/Header";
-import { computeLaneChatCounts, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, RightPane } from "./components/RightPane";
+import { computeLaneChatCounts, DETAILS_BODY_MAX_LINES, LANE_DETAIL_ACTIONS, LANE_DETAIL_PR_ACTION_INDEX, laneDetailsInteractionLayout, rightPaneScrollableRowCount, RightPane } from "./components/RightPane";
import { buildModelPickerLayout, defaultSelectionFor, railEntrySelection } from "./components/ModelPicker/modelPickerLayout";
-import { SlashPalette, SLASH_PALETTE_ROWS } from "./components/SlashPalette";
+import { modelPickerGeometry } from "./components/ModelPicker/modelPickerGeometry";
+import { SlashPalette, slashPaletteReservedRows } from "./components/SlashPalette";
import { MentionPalette, MENTION_PALETTE_ROWS } from "./components/MentionPalette";
+import { CommandPalette, COMMAND_PALETTE_ROWS, type CommandPaletteItem } from "./components/CommandPalette";
import { ApprovalPrompt } from "./components/ApprovalPrompt";
import { ModelStatus } from "./components/ModelStatus";
import { FooterControls } from "./components/FooterControls";
@@ -123,7 +137,7 @@ import { sortLanesForStackGraph } from "./laneTree";
import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format";
import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat";
import { isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets";
-import { appendDedupedTuiEvent, appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup";
+import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup";
import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state";
import { SpinTickProvider } from "./spinTick";
import { buildLinearToolRequest } from "./linearCommands";
@@ -143,6 +157,13 @@ import {
feedbackSubmissionNotice,
type FeedbackFormValues,
} from "./feedback";
+import {
+ cycleFeedbackType,
+ feedbackFormCanSubmit,
+ feedbackFormToFormValues,
+ type FeedbackFormState,
+ type FeedbackType,
+} from "./feedbackForm";
import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput";
import { claudeHomePath, defaultKeybindingsPath, dispatchKeybinding, openKeybindingsFile, readClaudeKeybindingsFile, type KeybindingDispatchState, type TuiKeybindingAction } from "./keybindings";
import { buildDeeplinkForRow, type DeeplinkRow } from "./deeplinkRow";
@@ -174,6 +195,7 @@ import type {
PendingApproval,
ProviderReadinessRow,
ProjectLaunchContext,
+ FeedbackContextMeta,
RightPaneContent,
SetupPaneRow,
SetupPaneRowKind,
@@ -206,10 +228,92 @@ export type FooterControl = "drawer" | "details" | "agents";
type DrawerLaneAction = "new-lane";
type DrawerChatAction = "new-chat";
+// Streaming chat events are coalesced into a single React render per frame
+// (~40fps) instead of one render per token. Lifecycle edges (turn start/stop,
+// done, user messages, subagent/error) force an immediate flush so the spinner,
+// interrupt flags, and right pane stay responsive.
+const CHAT_EVENT_FLUSH_MS = 24;
+
+function isChatFlushEdge(eventType: string): boolean {
+ // Only the event types that drive an immediate side-effect below (spinner /
+ // interrupt / right-pane) force a synchronous flush. Notably NOT the broad
+ // "subagent" prefix — subagent_progress is high-frequency and would defeat
+ // coalescing; only subagent_started opens the right pane.
+ return (
+ eventType === "status"
+ || eventType === "done"
+ || eventType === "user_message"
+ || eventType === "error"
+ || eventType === "subagent_started"
+ || eventType === "subagent.started"
+ );
+}
+
export function footerControlsForAvailability(agentsAvailable: boolean): FooterControl[] {
return agentsAvailable ? ["agents", "drawer", "details"] : ["drawer", "details"];
}
+export type InlineRowCellName = "provider" | "model" | "fast" | "reasoning" | "permission" | "subagents";
+
+// Single source of truth for the footer's inline cells. A cell only appears (and
+// is focusable by keyboard/mouse) when it applies — so fast mode and reasoning
+// are reachable exactly when supported, and neither is a dead focus stop.
+export function inlineRowCellOrder(opts: {
+ providerLocked: boolean;
+ fastSupported: boolean;
+ reasoningSupported: boolean;
+ subagentsVisible: boolean;
+}): InlineRowCellName[] {
+ const cells: InlineRowCellName[] = [];
+ if (!opts.providerLocked) cells.push("provider");
+ cells.push("model");
+ if (opts.fastSupported) cells.push("fast");
+ if (opts.reasoningSupported) cells.push("reasoning");
+ cells.push("permission");
+ if (opts.subagentsVisible) cells.push("subagents");
+ return cells;
+}
+
+// Turn a git conflict into a right-pane detail body + a one-line notice. Used by
+// /pull and /reparent so a rebase/merge conflict is surfaced instead of being
+// silently reported as success.
+export function formatGitConflictReport(state: GitConflictState): { title: string; body: string; summary: string } {
+ const label = state.kind === "rebase" ? "Rebase" : "Merge";
+ const count = state.conflictedFiles.length;
+ const plural = count === 1 ? "" : "s";
+ const fileList = count
+ ? state.conflictedFiles.map((file) => ` • ${file}`).join("\n")
+ : " (git did not report specific files)";
+ const resolveActions = [
+ state.canContinue ? "/pull --continue to finish" : null,
+ state.canAbort ? "/pull --abort to back out" : null,
+ ].filter((value): value is string => Boolean(value));
+ const actionsLine = resolveActions.length
+ ? `Resolve the conflicts, then run ${resolveActions.join(", or ")}.`
+ : "Resolve the conflicts in your editor to continue.";
+ return {
+ title: `${label} conflict`,
+ body: [`${count} file${plural} need resolution:`, fileList, "", actionsLine].join("\n"),
+ summary: `${label} conflict — ${count} file${plural} need resolution. ${actionsLine}`,
+ };
+}
+
+// One-line summary of what deleting a lane would lose, shown in the delete form
+// so the confirmation isn't blind (mirrors the desktop's delete-risk surface).
+export function formatLaneDeleteRisk(risk: LaneDeleteRisk): string {
+ const parts: string[] = [];
+ if (risk.dirty) parts.push("uncommitted changes");
+ if (risk.hasUnpushedCommits) {
+ parts.push(`${risk.unpushedCommitCount} unpushed commit${risk.unpushedCommitCount === 1 ? "" : "s"}`);
+ }
+ if (risk.runningProcessCount > 0) {
+ parts.push(`${risk.runningProcessCount} running process${risk.runningProcessCount === 1 ? "" : "es"}`);
+ }
+ if (risk.activePtyCount > 0) parts.push(`${risk.activePtyCount} terminal${risk.activePtyCount === 1 ? "" : "s"}`);
+ if (risk.remoteBranchExists) parts.push("remote branch exists");
+ return parts.length ? `⚠ ${parts.join(" · ")}` : "Clean — no unpushed work or running processes.";
+}
+
export type ModelPickerEscapeAction =
| { kind: "clear-search"; pane: Extract }
| { kind: "return-new-chat" }
@@ -258,6 +362,90 @@ export function isTerminalSessionFastPollActive(session: TerminalSessionActivity
&& isProcessLikelyAlive(session.pid);
}
+const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s<>"')\]]+/gi;
+const MARKDOWN_LINK_IN_TEXT_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/i;
+
+export function firstUrlInText(value: string): { url: string; index: number; width: number } | null {
+ const markdownMatch = MARKDOWN_LINK_IN_TEXT_RE.exec(value);
+ const markdownCandidate = markdownMatch?.[1] && markdownMatch[2]
+ ? {
+ url: markdownMatch[2].replace(/[.,;:!?]+$/u, ""),
+ index: markdownMatch.index,
+ width: markdownMatch[1].length,
+ }
+ : null;
+ URL_IN_TEXT_RE.lastIndex = 0;
+ const match = URL_IN_TEXT_RE.exec(value);
+ const rawCandidate = match?.[0]
+ ? {
+ url: match[0].replace(/[.,;:!?]+$/u, ""),
+ index: match.index,
+ width: match[0].replace(/[.,;:!?]+$/u, "").length,
+ }
+ : null;
+ if (markdownCandidate && rawCandidate) {
+ return markdownCandidate.index <= rawCandidate.index ? markdownCandidate : rawCandidate;
+ }
+ return markdownCandidate ?? rawCandidate;
+}
+
+function paletteMatchScore(item: CommandPaletteItem, query: string): number | null {
+ const trimmed = query.trim().toLowerCase();
+ if (!trimmed) return 0;
+ const haystack = `${item.label} ${item.detail}`.toLowerCase();
+ if (haystack.includes(trimmed)) return haystack.indexOf(trimmed);
+ let cursor = 0;
+ let score = 0;
+ for (const char of trimmed) {
+ const found = haystack.indexOf(char, cursor);
+ if (found < 0) return null;
+ score += found - cursor;
+ cursor = found + 1;
+ }
+ return score + haystack.length;
+}
+
+// Rebuild the framework-free FeedbackFormState (feedbackForm.ts) from the
+// FeedbackContextMeta carried on the feedback form content. Keeps validation +
+// serialization (feedbackFormCanSubmit / feedbackFormToFormValues) in lock-step
+// with what the right pane renders.
+function feedbackStateFromMeta(meta: FeedbackContextMeta): FeedbackFormState {
+ const rawType = (meta.type ?? "bug") as FeedbackType;
+ const type: FeedbackType = rawType === "bug" || rawType === "idea" || rawType === "praise" ? rawType : "bug";
+ return {
+ type,
+ text: meta.body ?? "",
+ showContext: meta.showContext !== false,
+ context: {
+ provider: meta.provider ?? null,
+ model: meta.model ?? null,
+ lane: meta.lane ?? null,
+ lastError: meta.lastError ?? null,
+ },
+ };
+}
+
+function openExternalUrl(url: string, notice: (message: string, tone?: LocalNotice["tone"]) => void): boolean {
+ const trimmed = url.trim();
+ if (!/^https?:\/\//i.test(trimmed)) return false;
+ const bridge = (globalThis as { window?: { ade?: { app?: { openExternal?: (url: string) => unknown } } } }).window;
+ const opener = bridge?.ade?.app?.openExternal;
+ if (typeof opener === "function") {
+ try {
+ opener(trimmed);
+ notice("Opening link in browser…", "info");
+ return true;
+ } catch {
+ // Fall through to the platform opener.
+ }
+ }
+ const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
+ if (!command) return false;
+ spawn(command, [trimmed], { stdio: "ignore", detached: true }).unref();
+ notice("Opening link in browser…", "info");
+ return true;
+}
+
export function isTerminalSessionResumable(session: ChatTerminalSession | null | undefined): boolean {
return Boolean(
session
@@ -434,6 +622,7 @@ function initialModelState(): AdeCodeModelState {
opencodePermissionMode: "edit",
droidPermissionMode: "auto-low",
cursorModeId: "agent",
+ cursorAvailableModeIds: [],
cursorConfigValues: {},
};
}
@@ -538,6 +727,10 @@ function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custo
return "custom";
}
+export function codexApprovalSandboxLabel(modelState: Pick): string {
+ return `${modelState.codexApprovalPolicy} · ${modelState.codexSandbox}`;
+}
+
function codexPresetPatch(preset: CodexPreset): Pick {
if (preset === "full-auto") {
return {
@@ -591,6 +784,13 @@ function cursorModeLabel(modeId: string | null | undefined): string {
return CURSOR_MODE_LABELS[normalized] ?? normalized;
}
+export function cursorModeIdsForState(modelState: Pick): string[] {
+ const snapshotIds = modelState.cursorAvailableModeIds
+ .map((modeId) => modeId.trim())
+ .filter(Boolean);
+ return snapshotIds.length ? snapshotIds : [...CURSOR_AVAILABLE_MODE_IDS];
+}
+
function permissionSummary(modelState: AdeCodeModelState): string {
if (modelState.provider === "codex") return resolveCodexPreset(modelState);
if (modelState.provider === "claude") {
@@ -637,7 +837,7 @@ function permissionOptionsDetail(modelState: AdeCodeModelState): string {
if (modelState.provider === "claude") return "default · plan · auto · bypass";
if (modelState.provider === "opencode") return OPENCODE_PERMISSION_OPTIONS.join(" · ");
if (modelState.provider === "droid") return DROID_PERMISSION_OPTIONS.join(" · ");
- return CURSOR_AVAILABLE_MODE_IDS.map((modeId) => cursorModeLabel(modeId)).join(" · ");
+ return cursorModeIdsForState(modelState).map((modeId) => cursorModeLabel(modeId)).join(" · ");
}
function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial {
@@ -741,22 +941,6 @@ function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null {
return right.length ? `◎ ${objective} ${right.join(" · ")}` : `◎ ${objective}`;
}
-function formatContextUsage(usage: AgentChatContextUsage | null): string {
- if (!usage) return "Context usage is not available for this session yet.";
- const total = compactNumber(usage.totalTokens);
- const max = compactNumber(usage.maxTokens);
- const header = `Context usage: ${total} / ${max} tokens (${usage.percentage.toFixed(0)}%)`;
- const rows = usage.categories.map((category) => {
- const pct = category.percentage < 10 && category.percentage > 0
- ? category.percentage.toFixed(1)
- : category.percentage.toFixed(0);
- return `${category.name.padEnd(22)} ${compactNumber(category.tokens).padStart(7)} ${pct.padStart(5)}%`;
- });
- return [header, usage.model ? `Model: ${usage.model}` : null, "", ...rows]
- .filter((line): line is string => line != null)
- .join("\n");
-}
-
import { subagentSnapshotsFromEvents } from "../../../desktop/src/shared/chatSubagents";
export { subagentSnapshotsFromEvents };
@@ -890,10 +1074,18 @@ export function resolveContextDefault(args: ContextDefaultArgs): RightPaneConten
return seedLaneDetails(nav.lane, !args.unavailableLaneIds.has(nav.lane.id));
case "new-chat":
return {
- kind: "new-chat-setup",
+ kind: "model-picker",
+ surface: "new-chat",
+ query: "",
+ searchMode: false,
+ showAll: true,
+ selection: { kind: "provider", provider: args.provider },
+ providerTabKey: null,
+ focusedIndex: 0,
+ footerFocus: "apply",
+ settingsRows: nav.rows,
laneId: nav.laneId,
laneLabel: nav.laneLabel,
- rows: nav.rows,
};
case "chat":
return { kind: "chat-info", info: nav.info };
@@ -905,10 +1097,18 @@ export function resolveContextDefault(args: ContextDefaultArgs): RightPaneConten
&& !args.unavailableLaneIds.has(args.newChatSetup.laneId)
) {
return {
- kind: "new-chat-setup",
+ kind: "model-picker",
+ surface: "new-chat",
+ query: "",
+ searchMode: false,
+ showAll: true,
+ selection: { kind: "provider", provider: args.provider },
+ providerTabKey: null,
+ focusedIndex: 0,
+ footerFocus: "apply",
+ settingsRows: args.newChatSetup.rows,
laneId: args.newChatSetup.laneId,
laneLabel: args.newChatSetup.laneLabel,
- rows: args.newChatSetup.rows,
};
}
if (args.drawerMode === "lanes" && args.highlightedDrawerLane) {
@@ -1224,7 +1424,7 @@ function buildSetupRows(args: {
if (args.includeApply) {
rows.push({
kind: "apply",
- label: "Use these settings",
+ label: "Confirm",
value: "ready",
detail: "returns focus to the chat composer",
});
@@ -1499,6 +1699,43 @@ export function deletePromptForward(value: string, cursor: number): PromptEditRe
};
}
+// Apply a possibly-coalesced input chunk to the prompt, character by character.
+// Ink emits multiple fast keystrokes as ONE chunk and only recognizes a *lone*
+// DEL/BS byte as backspace — so a burst like "x" (type then delete) arrives
+// as plain text with no backspace flag, and naive insertion would drop the
+// Like printableInput but keeps tabs (0x09) and newlines (0x0a), normalizing
+// CR/LF to "\n", so a pasted multi-line / tabbed block survives verbatim in the
+// feedback body editor. Strips the remaining C0 controls and DEL.
+function printableMultilineInput(input: string): string {
+ return input
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n")
+ .replace(/[ --]/g, "");
+}
+
+// delete. Here we walk the chunk: printable runs are inserted, embedded
+// DEL/BS bytes delete backward, all in order. Fixes intermittent "backspace
+// does nothing" when typing quickly.
+export function applyCoalescedPromptInput(value: string, cursor: number, input: string, preserveMultiline = false): PromptEditResult {
+ let result: PromptEditResult = { value, cursor: clampPromptCursor(value, cursor) };
+ let buffer = "";
+ const flush = () => {
+ const printable = preserveMultiline ? printableMultilineInput(buffer) : printableInput(buffer);
+ buffer = "";
+ if (printable) result = insertPromptText(result.value, result.cursor, printable);
+ };
+ for (const ch of input) {
+ if (ch === "\u007f" || ch === "\b") {
+ flush();
+ result = deletePromptBackward(result.value, result.cursor, "char");
+ } else {
+ buffer += ch;
+ }
+ }
+ flush();
+ return result;
+}
+
function inputBeforeLineBreak(input: string): string | null {
const index = input.search(/[\r\n]/);
return index === -1 ? null : input.slice(0, index);
@@ -1895,12 +2132,6 @@ export function formFieldUsesPromptInput(command: string, fieldName: string): bo
return true;
}
-export function modelPickerSurfaceForSetupPane(
- paneKind: "new-chat-setup" | "model-setup",
-): "new-chat" | "chat" {
- return paneKind === "new-chat-setup" ? "new-chat" : "chat";
-}
-
export function clampChatScrollOffsetRows(value: number, maxOffset: number): number {
const safeMax = Number.isFinite(maxOffset) ? Math.max(0, Math.floor(maxOffset)) : 0;
if (Number.isNaN(value)) return 0;
@@ -2238,6 +2469,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
useTerminalMouseTracking();
const [connection, setConnection] = useState(null);
const [mode, setMode] = useState("connecting");
+ // True after an attached socket drops unexpectedly, until we re-attach. Drives
+ // the reconnect probe below and a one-shot "reconnecting…" notice.
+ const [connectionLost, setConnectionLost] = useState(false);
const [lanes, setLanes] = useState([]);
const [prByLaneId, setPrByLaneId] = useState>({});
const [diffByLaneId, setDiffByLaneId] = useState>({});
@@ -2246,6 +2480,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
const [terminalPreview, setTerminalPreview] = useState(null);
const [attachedTerminalId, setAttachedTerminalId] = useState(null);
const [terminalLiveChunks, setTerminalLiveChunks] = useState>({});
+ // Scrollback position + "↓ N new" counter per Claude PTY session.
+ const [terminalScrollBySessionId, setTerminalScrollBySessionId] = useState({});
+ // Pending pty_data chunks buffered off-React; flushed on a ~16ms timer so the
+ // write cursor keeps advancing (no per-chunk O(n) array rebuild + slice(-500)
+ // that would pin the buffer at 500 and freeze TerminalPane's incremental write).
+ const pendingPtyChunksRef = useRef