Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2eda3bb4 | ||
|
|
e6718fa118 | ||
|
|
f98030f28a | ||
|
|
93e5464370 | ||
|
|
f5b04214cf | ||
|
|
008f737e1f | ||
|
|
2100e7ab54 | ||
|
|
c53fdfa7c9 | ||
|
|
5912a35bbb | ||
|
|
d86d6aeef2 | ||
|
|
30cc579773 | ||
|
|
a54f1644f5 |
@@ -1,3 +1,30 @@
|
||||
# [1.24.0](https://github.com/asepharyana/zesdex/compare/v1.23.2...v1.24.0) (2026-09-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tui:** add new flow helpers for bash exit code handling and verification prompts ([f98030f](https://github.com/asepharyana/zesdex/commit/f98030f28a20e3d9ba807bb98ddbf142511a3964))
|
||||
* **tui:** enhance autocomplete functionality and UI interactions ([008f737](https://github.com/asepharyana/zesdex/commit/008f737e1f5d0eef6a07ebe0fff2c9730bd44eb4))
|
||||
* **tui:** enhance command execution on autocomplete and improve UI responsiveness ([f5b0421](https://github.com/asepharyana/zesdex/commit/f5b04214cf385ba2e4a02f6ce202436ffab23c35))
|
||||
* **tui:** enhance model selection and improve UI components ([c53fdfa](https://github.com/asepharyana/zesdex/commit/c53fdfa7c99887e8f3dfe68e7f1b2e766de039b1))
|
||||
* **tui:** implement interactive menu system for model and config selection ([2100e7a](https://github.com/asepharyana/zesdex/commit/2100e7ab54460f28b67c3bb07855eef8f04eb9ec))
|
||||
* **tui:** implement live model fetching and enhance model selection menus ([93e5464](https://github.com/asepharyana/zesdex/commit/93e54643703ce7f18286bd6a988e0fab18cc9e66))
|
||||
* **tui:** implement message compaction and summarization logic for efficient conversation handling ([e6718fa](https://github.com/asepharyana/zesdex/commit/e6718fa11825cccc74a9d100755226b7dfefc106))
|
||||
|
||||
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
|
||||
|
||||
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tui:** repair layout overflow + streaming dup; polish UI/state ([a54f164](https://github.com/asepharyana/zesdex/commit/a54f1644f5943399568194b4f129185a9d841f1d))
|
||||
|
||||
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zesdex",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
|
||||
@@ -4,8 +4,25 @@ import {
|
||||
conversationChars,
|
||||
ErrorTracker,
|
||||
truncateToolOutput,
|
||||
bashExitCode,
|
||||
isToolFailure,
|
||||
deriveVerifyCommand,
|
||||
AgentTurnServiceImpl,
|
||||
takeRecentTail,
|
||||
reduceMessagesToDigest,
|
||||
compactMessagesWithAi,
|
||||
} from "./turn_service.ts";
|
||||
import { type ChatMessage, newConversation, systemMessage, userMessage, toolResultMessage } from "@zesdex/domain";
|
||||
import {
|
||||
type ChatMessage,
|
||||
newConversation,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
toolResultMessage,
|
||||
} from "@zesdex/domain";
|
||||
import type { AgentTurnParams } from "@zesdex/agent";
|
||||
import type { ToolExecutor } from "./index.ts";
|
||||
import type { ProviderService } from "./ports.ts";
|
||||
|
||||
describe("truncateToolOutput", () => {
|
||||
it("short output is unchanged", () => {
|
||||
@@ -56,4 +73,312 @@ describe("conversationChars", () => {
|
||||
conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output"));
|
||||
expect(conversationChars(conv.messages)).toBe(3 + 11 + 6);
|
||||
});
|
||||
});
|
||||
});
|
||||
/* ── New flow helpers ─────────────────────────────────────────────── */
|
||||
|
||||
describe("bashExitCode / isToolFailure", () => {
|
||||
it("extracts a non-zero exit code", () => {
|
||||
expect(bashExitCode("boom\n\nExit code: 1 (1s)")).toBe(1);
|
||||
expect(bashExitCode("done\n\nExit code: 0 (0.5s)")).toBe(0);
|
||||
});
|
||||
it("returns null when no exit-code line exists", () => {
|
||||
expect(bashExitCode("plain output")).toBeNull();
|
||||
});
|
||||
it("isToolFailure flags bash non-zero exits as failures", () => {
|
||||
expect(isToolFailure("bash", "nope\n\nExit code: 2 (1s)")).toBe(true);
|
||||
expect(isToolFailure("bash", "ok\n\nExit code: 0 (1s)")).toBe(false);
|
||||
});
|
||||
it("isToolFailure still flags Error: prefixes for other tools", () => {
|
||||
expect(isToolFailure("read", "Error: no such file")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveVerifyCommand", () => {
|
||||
it("defaults to bun check+test for a Bun repo", () => {
|
||||
// No real manifests in a control dir we guarantee to not exist.
|
||||
expect(deriveVerifyCommand("/nonexistent-zesdex-dir")).toContain("bun");
|
||||
});
|
||||
});
|
||||
|
||||
/* ── runTurn flow tests (fakes, no network) ───────────────────────── */
|
||||
|
||||
interface ScriptedStep {
|
||||
content?: string | null;
|
||||
tools?: Array<{ name: string; args?: string; id?: string }>;
|
||||
}
|
||||
|
||||
/** A ProviderService that replays scripted chatStream responses. */
|
||||
function makeScriptedProvider(script: ScriptedStep[]): {
|
||||
provider: ProviderService;
|
||||
seen: Array<ChatMessage[]>;
|
||||
} {
|
||||
const seen: Array<ChatMessage[]> = [];
|
||||
let step = 0;
|
||||
const chatResponses: string[] = [];
|
||||
const provider: ProviderService = {
|
||||
async chat(_messages, _tools, _maxTokens, _temperature) {
|
||||
// Used by compaction and review. For review tests we script below.
|
||||
const text = chatResponses.shift() ?? "";
|
||||
return { message: { role: "assistant", content: text || null }, usage: [10, 5] };
|
||||
},
|
||||
async chatStream(messages, _tools, _max, _temp, onEvent) {
|
||||
// Snapshot a copy: runTurn freely mutates the live array (splice/compact).
|
||||
seen.push([...messages]);
|
||||
const s = script[Math.min(step, script.length - 1)] ?? { content: "done" };
|
||||
step += 1;
|
||||
if (s.content !== undefined && s.content !== null) onEvent({ kind: "token", content: s.content });
|
||||
if (s.tools && s.tools.length > 0) {
|
||||
const msg = assistantMessage(null) as ChatMessage;
|
||||
msg.tool_calls = s.tools.map((t, i) => ({
|
||||
id: t.id ?? `call-${i}`,
|
||||
type: "function",
|
||||
function: { name: t.name, arguments: t.args ?? "{}" },
|
||||
}));
|
||||
return { message: msg, usage: [10, 2] };
|
||||
}
|
||||
return {
|
||||
message: { role: "assistant", content: s.content ?? null },
|
||||
usage: [10, 2],
|
||||
};
|
||||
},
|
||||
// expose a way for tests to script chat replies
|
||||
} as ProviderService;
|
||||
(provider as unknown as { setChatReply: (s: string) => void }).setChatReply = (text: string) => {
|
||||
chatResponses.push(text);
|
||||
};
|
||||
return { provider, seen };
|
||||
}
|
||||
|
||||
/** A ToolExecutor that echoes fixed outputs per tool. */
|
||||
function fixedExecutor(outs: Record<string, string>): ToolExecutor {
|
||||
return {
|
||||
async execute(name) {
|
||||
return outs[name] ?? "ok";
|
||||
},
|
||||
isParallelSafe() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildParams(userText: string): {
|
||||
params: AgentTurnParams;
|
||||
events: Array<any>;
|
||||
} {
|
||||
const events: Array<any> = [];
|
||||
const params: AgentTurnParams = {
|
||||
messages: [userMessage(userText)],
|
||||
session_dir: "/tmp/zesdex-flow-test",
|
||||
workspace_roots: ["/nonexistent-zesdex-dir"], // no AGENTS.md/package.json side effects
|
||||
turn_events: { push: (e) => events.push(e), drain: () => [] },
|
||||
in_flight: { value: false },
|
||||
abort: new AbortController(),
|
||||
api_key: "k",
|
||||
model: "m",
|
||||
api_base: "https://x",
|
||||
};
|
||||
return { params, events };
|
||||
}
|
||||
|
||||
/** Extract the `[Flow]` steering system message from a captured batch. */
|
||||
function flowDirective(batch: ChatMessage[]): string {
|
||||
const m = batch.find((x) => (x.content ?? "").includes("[Flow]"));
|
||||
return m?.content ?? "";
|
||||
}
|
||||
|
||||
describe("runTurn complexity steering", () => {
|
||||
it("injects a complex-request directive for a complex prompt", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([
|
||||
{ content: "final answer" },
|
||||
]);
|
||||
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||
const { params } = buildParams("Please refactor the architecture across multiple files");
|
||||
await svc.runTurn(params);
|
||||
// The complexity directive system message must be present before the LLM.
|
||||
const flow = flowDirective(seen[0] ?? []);
|
||||
expect(flow).toContain("flagged as complex");
|
||||
});
|
||||
|
||||
it("injects a keep-it-simple directive instead", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([{ content: "hi" }]);
|
||||
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||
const { params } = buildParams("what is 2+2");
|
||||
await svc.runTurn(params);
|
||||
const flow = flowDirective(seen[0] ?? []);
|
||||
expect(flow).toContain("looks simple");
|
||||
expect(flow).not.toContain("complex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn verify-after-edit", () => {
|
||||
it("injects a verify nudge after a write, cleared after a successful bash", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([
|
||||
{ tools: [{ name: "write" }] },
|
||||
{ tools: [{ name: "bash" }] },
|
||||
{ content: "done" },
|
||||
]);
|
||||
const executor = fixedExecutor({ write: "wrote it", bash: "ok\n\nExit code: 0 (1s)" });
|
||||
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||
const { params } = buildParams("add a comment");
|
||||
await svc.runTurn(params);
|
||||
// The LLM call after the write should carry the verify nudge.
|
||||
const second = seen[1] ?? [];
|
||||
expect(second.some((m) => (m.content ?? "").includes("Run the verify command"))).toBe(true);
|
||||
// The successful bash exits 0, which clears pendingVerify — so the nudge is
|
||||
// injected exactly once, not repeated on every later call.
|
||||
const finalBatch = seen[seen.length - 1] ?? [];
|
||||
const nudgeCount = finalBatch.filter((m) => (m.content ?? "").includes("Run the verify command")).length;
|
||||
expect(nudgeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn convergence guard", () => {
|
||||
it("stops after repeated identical read calls", async () => {
|
||||
const script: ScriptedStep[] = [];
|
||||
for (let i = 0; i < 6; i++) script.push({ tools: [{ name: "read" }] });
|
||||
const { provider, seen } = makeScriptedProvider(script);
|
||||
const executor = fixedExecutor({ read: "same content" });
|
||||
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||
const { params, events } = buildParams("read something");
|
||||
await svc.runTurn(params);
|
||||
const warned = events.some((e) => e.kind === "system_note" && /without any progress|repeated/.test(e.message ?? ""));
|
||||
expect(warned).toBe(true);
|
||||
// Not every call got issued — the guard broke early.
|
||||
expect(seen.length).toBeLessThan(script.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn self-review", () => {
|
||||
it("feeds a reviewer critique back as a system message after a mutator", async () => {
|
||||
const { provider, seen } = makeScriptedProvider([
|
||||
{ tools: [{ name: "edit" }] },
|
||||
{ content: "fixed" },
|
||||
]);
|
||||
const withChat = provider as unknown as { setChatReply(s: string): void };
|
||||
withChat.setChatReply("- [PRIORITY: high] handle empty input in parse()");
|
||||
const executor = fixedExecutor({ edit: "edited" });
|
||||
const svc = new AgentTurnServiceImpl(provider as never, executor, [] as never);
|
||||
const { params, events } = buildParams("fix parse()");
|
||||
await svc.runTurn(params);
|
||||
// The reviewer critique must have reached the next LLM call's history.
|
||||
const second = seen[1] ?? [];
|
||||
expect(second.some((m) => (m.content ?? "").includes("[Reviewer]"))).toBe(true);
|
||||
// review_usage emitted.
|
||||
expect(events.some((e) => e.kind === "review_usage")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Compaction: efficient + accurate ─────────────────────────────── */
|
||||
|
||||
describe("takeRecentTail", () => {
|
||||
it("keeps the most recent messages and evicts an oversized older blob", () => {
|
||||
const messages = [
|
||||
toolResultMessage("t0", "y".repeat(5000)), // huge old tool output
|
||||
userMessage("old request"),
|
||||
assistantMessage("recent reply A"),
|
||||
userMessage("recent reply B"),
|
||||
];
|
||||
const { tail, evicted } = takeRecentTail(messages, 25, 2);
|
||||
expect(evicted.length).toBe(2); // the blob + the old request
|
||||
expect(tail.map((m) => m.content)).toEqual(["recent reply A", "recent reply B"]);
|
||||
});
|
||||
|
||||
it("gives the whole history as tail when it is small enough", () => {
|
||||
const messages = [userMessage("a"), assistantMessage("b")];
|
||||
const { tail, evicted } = takeRecentTail(messages, 1000, 6);
|
||||
expect(evicted).toEqual([]);
|
||||
expect(tail.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not let a huge tool output blow the tail past the min count", () => {
|
||||
// minTail 2: newest two kept regardless; the huge tool body stays evicted.
|
||||
const messages = [
|
||||
toolResultMessage("t0", "x".repeat(50000)),
|
||||
userMessage("keep1"),
|
||||
assistantMessage("keep2"),
|
||||
];
|
||||
const { tail, evicted } = takeRecentTail(messages, 8000, 2);
|
||||
expect(tail.map((m) => m.content)).toEqual(["keep1", "keep2"]);
|
||||
expect(evicted.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceMessagesToDigest", () => {
|
||||
it("stubs tool bodies entirely and previews text", () => {
|
||||
const digest = reduceMessagesToDigest([
|
||||
toolResultMessage("t0", "y".repeat(5000)),
|
||||
userMessage("short request"),
|
||||
]);
|
||||
expect(digest).toContain("- tool");
|
||||
expect(digest).not.toContain("yyyy");
|
||||
expect(digest).toContain("short request");
|
||||
});
|
||||
|
||||
it("keeps user previews well above the shorter assistant cap", () => {
|
||||
const long = "w".repeat(2000);
|
||||
// User budget is 1500 chars — larger than the 400-char assistant cap, so
|
||||
// a long user request survives far more of its body than a long assistant
|
||||
// reply would (requests matter most for summary accuracy).
|
||||
const digestUser = reduceMessagesToDigest([userMessage(long)]);
|
||||
const digestAssistant = reduceMessagesToDigest([assistantMessage(long)]);
|
||||
expect(digestUser.length).toBeGreaterThan(800);
|
||||
expect(digestAssistant.length).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("respects the hard input cap", () => {
|
||||
const many = [];
|
||||
for (let i = 0; i < 200; i++) many.push(userMessage("r".repeat(300)));
|
||||
const digest = reduceMessagesToDigest(many);
|
||||
expect(digest.length).toBeLessThanOrEqual(20_000 + 40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactMessagesWithAi", () => {
|
||||
it("keeps the recent tail and prepends an AI summary", async () => {
|
||||
// Long enough to bypass the min-size guard, with an oversized OLD tool blob
|
||||
// (should be evicted + stubbed, not kept verbatim).
|
||||
const messages: ChatMessage[] = [
|
||||
userMessage("old request one"),
|
||||
assistantMessage("old response"),
|
||||
toolResultMessage("t0", "big".repeat(4000)),
|
||||
];
|
||||
for (let i = 0; i < 7; i++) messages.push(userMessage(`mid ${i}`));
|
||||
messages.push(toolResultMessage("t1", "tail-result"));
|
||||
messages.push(userMessage("newest request"));
|
||||
messages.push(assistantMessage("newest response"));
|
||||
|
||||
const provider: ProviderService = {
|
||||
async chat() {
|
||||
return { message: { role: "assistant", content: "## Requests\n- old request one" }, usage: null };
|
||||
},
|
||||
async chatStream() {
|
||||
return { message: { role: "assistant", content: null }, usage: null };
|
||||
},
|
||||
};
|
||||
await compactMessagesWithAi(messages, provider);
|
||||
// Summary first, recent tail preserved verbatim, tool blobs gone.
|
||||
expect(messages[0]?.content).toContain("[AI Summary of Previous Conversation]");
|
||||
expect(messages.some((m) => m.content === "newest request")).toBe(true);
|
||||
expect(messages.some((m) => m.content === "newest response")).toBe(true);
|
||||
expect(messages.some((m) => (m.content ?? "").includes("big".repeat(10)))).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades gracefully on summarizer failure, preserving the tail", async () => {
|
||||
const messages = [
|
||||
userMessage("old"),
|
||||
toolResultMessage("t0", "x".repeat(100)),
|
||||
userMessage("recent request"),
|
||||
];
|
||||
const failing: ProviderService = {
|
||||
async chat() {
|
||||
throw new Error("network down");
|
||||
},
|
||||
async chatStream() {
|
||||
return { message: { role: "assistant", content: null }, usage: null };
|
||||
},
|
||||
};
|
||||
await compactMessagesWithAi(messages, failing);
|
||||
// The recent message survives even when the LLM call fails.
|
||||
expect(messages.some((m) => m.content === "recent request")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ToolCall,
|
||||
type ToolDef,
|
||||
type JsonValue,
|
||||
Roles,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
mainAgentPromptWithProjectContext,
|
||||
compactionPrompt,
|
||||
errorRecoveryNote,
|
||||
reviewerPrompt,
|
||||
} from "@zesdex/agent";
|
||||
import { isComplexRequest } from "@zesdex/workflow";
|
||||
import type { ProviderService } from "./ports.ts";
|
||||
import type { ToolExecutor } from "./index.ts";
|
||||
|
||||
@@ -38,11 +41,89 @@ const PROJECT_CONTEXT_MAX_CHARS = 12_000;
|
||||
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
||||
const COMPACT_KEEP_TAIL = 6;
|
||||
|
||||
/** Recent-tail budget (chars): the most recent history kept verbatim on compact. */
|
||||
const COMPACT_TAIL_CHARS = 8_000;
|
||||
/** Caps what the summarizer actually receives, keeping input small and focused. */
|
||||
const COMPACT_MAX_INPUT_CHARS = 20_000;
|
||||
/** Preview budget for a user request inside the digest (requests matter most). */
|
||||
const COMPACT_USER_PREVIEW_CHARS = 1_500;
|
||||
/** Preview budget for assistant text inside the digest. */
|
||||
const COMPACT_TEXT_PREVIEW_CHARS = 400;
|
||||
|
||||
/** Tools that mutate the filesystem — after these, a verify run is expected. */
|
||||
const MUTATOR_TOOLS = new Set(["write", "edit", "delete"]);
|
||||
|
||||
/** Consecutive identical, non-progressing tool iterations before the loop stops. */
|
||||
const MAX_NO_PROGRESS_STREAK = 4;
|
||||
|
||||
/** Self-review is bounded to this many passes per turn. */
|
||||
const MAX_REVIEW_PASSES = 1;
|
||||
|
||||
/** Whether the output string denotes a tool error. */
|
||||
function isErrorOutput(output: string): boolean {
|
||||
return output.startsWith("Error:");
|
||||
}
|
||||
|
||||
/** Extract the numeric exit code from a `bash` tool result, or null if not a failure (0). */
|
||||
export function bashExitCode(output: string): number | null {
|
||||
const match = output.match(/Exit code:\s*(\d+)/);
|
||||
if (!match) return null;
|
||||
const code = Number(match[1]);
|
||||
return Number.isInteger(code) && code >= 0 ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool result is a real failure. `Error:` prefixes cover most tools;
|
||||
* a `bash` command that exits non-zero returns an `Exit code: N` line instead.
|
||||
*/
|
||||
export function isToolFailure(toolName: string, output: string): boolean {
|
||||
if (isErrorOutput(output)) return true;
|
||||
if (toolName === "bash" || toolName === "bash_output") {
|
||||
const code = bashExitCode(output);
|
||||
if (code === null) return false; // no exit-code line → no signal
|
||||
return code !== 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort derivation of the repository's verify command from convention
|
||||
* manifests. Defaults to a safe lint+test invocation for Bun.
|
||||
*/
|
||||
export function deriveVerifyCommand(root: string): string {
|
||||
const fs = require("node:fs");
|
||||
// Bun project: prefer an explicit `check` (typecheck) script then test.
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const s = pkg?.scripts ?? {};
|
||||
const parts: string[] = [];
|
||||
if (s["check"] && typeof s["check"] === "string") parts.push(`bun run check`);
|
||||
else if (s["typecheck"] && typeof s["typecheck"] === "string") parts.push(`bun run typecheck`);
|
||||
if (s["lint"] && typeof s["lint"] === "string") parts.push(`bun run lint`);
|
||||
if (s["test"] && typeof s["test"] === "string") parts.push(`bun run test`);
|
||||
if (parts.length > 0) return parts.join(" && ");
|
||||
} catch {
|
||||
/* no package.json — fall through */
|
||||
}
|
||||
// Rust project.
|
||||
try {
|
||||
if (fs.existsSync(`${root}/Cargo.toml`)) return "cargo check && cargo test";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Java/Maven.
|
||||
try {
|
||||
if (fs.existsSync(`${root}/pom.xml`) || fs.existsSync(`${root}/build.gradle`)) {
|
||||
return "mvn test";
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "bun run check && bun test";
|
||||
}
|
||||
|
||||
/** Truncate a long tool output, preserving the head + truncation marker. */
|
||||
export function truncateToolOutput(output: string): string {
|
||||
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
||||
@@ -136,7 +217,7 @@ async function executeToolCall(
|
||||
output = `Error: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
const isError = isErrorOutput(output);
|
||||
const isError = isToolFailure(name, output);
|
||||
const truncated = truncateToolOutput(output);
|
||||
|
||||
sink.push({
|
||||
@@ -179,8 +260,75 @@ async function executeToolCallsInParallel(
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compact oversized conversation history using AI summarisation. At most once
|
||||
* per turn. Keeps the last COMPACT_KEEP_TAIL messages.
|
||||
* Split history into a recent tail (kept verbatim, bound by CHAR budget so
|
||||
* huge tool outputs don't monopolise it) and the evicted prefix to summarize.
|
||||
*/
|
||||
export function takeRecentTail(
|
||||
messages: ChatMessage[],
|
||||
tailChars = COMPACT_TAIL_CHARS,
|
||||
minTail = COMPACT_KEEP_TAIL,
|
||||
): { tail: ChatMessage[]; evicted: ChatMessage[] } {
|
||||
if (messages.length <= minTail) return { tail: [...messages], evicted: [] };
|
||||
let used = 0;
|
||||
let keep = 0;
|
||||
// Walk from the newest message backward. Always keep at least minTail; then
|
||||
// stop once the aggregated char budget is exceeded.
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const len = messages[i]!.content?.length ?? 0;
|
||||
if (keep >= minTail && used + len > tailChars) break;
|
||||
keep += 1;
|
||||
used += len;
|
||||
}
|
||||
const tail = messages.slice(messages.length - keep);
|
||||
const evicted = messages.slice(0, messages.length - keep);
|
||||
return { tail, evicted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce older history to a compact digest for the summarizer. Tool-result
|
||||
* bodies are dropped entirely (they are noise for a summary); user requests
|
||||
* get a generous preview; assistant text gets a shorter one. Caps the result
|
||||
* at COMPACT_MAX_INPUT_CHARS so the summarizer sees a small, focused input.
|
||||
*/
|
||||
export function reduceMessagesToDigest(messages: ChatMessage[]): string {
|
||||
let out = "";
|
||||
let budget = COMPACT_MAX_INPUT_CHARS;
|
||||
for (const m of messages) {
|
||||
if (budget <= 0) break;
|
||||
if (m.role === Roles.Tool) {
|
||||
const name = m.name ?? "tool";
|
||||
const line = `- tool ${name} executed\n`;
|
||||
out += line;
|
||||
budget -= line.length;
|
||||
continue;
|
||||
}
|
||||
const text = m.content?.trim() ?? "";
|
||||
if (text === "") continue; // assistant messages that only carried tool calls
|
||||
const cap = m.role === Roles.User ? COMPACT_USER_PREVIEW_CHARS : COMPACT_TEXT_PREVIEW_CHARS;
|
||||
let preview = text.replace(/\s*\n+\s*/g, " ");
|
||||
if (preview.length > cap) {
|
||||
preview = `${preview.slice(0, cap)}…[+${text.length - cap} ch]`;
|
||||
}
|
||||
const line = `- ${m.role}: ${preview}\n`;
|
||||
out += line;
|
||||
budget -= line.length;
|
||||
}
|
||||
// Hard guarantee: never exceed the summarizer input cap.
|
||||
if (out.length > COMPACT_MAX_INPUT_CHARS) return out.slice(0, COMPACT_MAX_INPUT_CHARS);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact oversized conversation history efficiently yet accurately.
|
||||
*
|
||||
* - Only the OLDER history is summarized; the recent tail is kept verbatim
|
||||
* (bounded by char budget, not message count, so tool bloat can't win the
|
||||
* budget).
|
||||
* - The evicted history is REDUCED to a small digest before the summarizer
|
||||
* sees it, so the LLM works on a focused input (cheaper + more accurate)
|
||||
* instead of a ~60k-char dump.
|
||||
* - On failure it degrades gracefully: keeps the recent tail and a marker,
|
||||
* rather than wiping context to a useless sentinel.
|
||||
*/
|
||||
export async function compactMessagesWithAi(
|
||||
messages: ChatMessage[],
|
||||
@@ -188,17 +336,35 @@ export async function compactMessagesWithAi(
|
||||
): Promise<void> {
|
||||
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
||||
|
||||
const splitIdx = messages.length - COMPACT_KEEP_TAIL;
|
||||
const evicted = messages.splice(0, splitIdx);
|
||||
const { tail, evicted } = takeRecentTail(messages);
|
||||
const digest = reduceMessagesToDigest(evicted);
|
||||
if (digest.trim() === "") {
|
||||
// Nothing worth summarizing (e.g. only stubbed tool bodies) — keep it all.
|
||||
return;
|
||||
}
|
||||
|
||||
const summaryPrompt: ChatMessage[] = [systemMessage(compactionPrompt()), ...evicted, userMessage("Please summarise our previous conversation above for context continuity.")];
|
||||
const summaryPrompt: ChatMessage[] = [
|
||||
systemMessage(compactionPrompt()),
|
||||
userMessage(
|
||||
"The conversation below is the OLDER part of a session. Recent messages are kept separately, so do not preserve them. Compress the older part into the requested summary format.\n\n---\n" + digest,
|
||||
),
|
||||
];
|
||||
|
||||
try {
|
||||
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
||||
const summaryText = message.content ?? "Previous context summarised.";
|
||||
messages.unshift(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText.trim()}`));
|
||||
const summaryText = message.content?.trim() ?? "";
|
||||
const rebuilt: ChatMessage[] = [];
|
||||
if (summaryText !== "") {
|
||||
rebuilt.push(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText}`));
|
||||
} else {
|
||||
rebuilt.push(systemMessage("[Earlier conversation summarized]"));
|
||||
}
|
||||
rebuilt.push(...tail);
|
||||
messages.splice(0, messages.length, ...rebuilt);
|
||||
} catch {
|
||||
messages.unshift(systemMessage("[Earlier conversation messages compacted to save context window]"));
|
||||
// Graceful degradation: never wipe recent state on a summarizer failure.
|
||||
const rebuilt: ChatMessage[] = [systemMessage("[Earlier context compressed; recent messages follow.]"), ...tail];
|
||||
messages.splice(0, messages.length, ...rebuilt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,18 +434,43 @@ export class AgentTurnServiceImpl {
|
||||
const abort = params.abort;
|
||||
const { in_flight } = params;
|
||||
|
||||
// Insert system prompt at index 0 with repo conventions loaded.
|
||||
const projectContext = buildProjectContext(params.workspace_roots[0] ?? ".");
|
||||
const systemPrompt = mainAgentPromptWithProjectContext(projectContext);
|
||||
// Insert system prompt at index 0 with repo conventions + verify command.
|
||||
const root = params.workspace_roots[0] ?? ".";
|
||||
const projectContext = buildProjectContext(root);
|
||||
const verifyCommand = deriveVerifyCommand(root);
|
||||
const systemPrompt = mainAgentPromptWithProjectContext(projectContext, verifyCommand);
|
||||
params.messages.unshift(systemMessage(systemPrompt));
|
||||
const originalCount = params.messages.length;
|
||||
|
||||
// Estimate request complexity from the last user message.
|
||||
const last = params.messages[params.messages.length - 1];
|
||||
const requestLen = last?.content?.length ?? 0;
|
||||
const userText = last?.content ?? "";
|
||||
|
||||
// Complexity steering: a one-shot directive so the model picks the right
|
||||
// depth instead of relying on prose memory. Pure and cheap.
|
||||
const complex = isComplexRequest(userText);
|
||||
this.push(sink, {
|
||||
kind: "system_note",
|
||||
systemKind: "info",
|
||||
message: complex ? "Complex request detected — plan before executing." : "Simple request — keep tool use minimal.",
|
||||
});
|
||||
params.messages.push(
|
||||
systemMessage(
|
||||
complex
|
||||
? "[Flow] This request is flagged as complex. Enter a short plan with `plan_enter` and track steps with `todowrite` before starting edits."
|
||||
: "[Flow] This request looks simple. If you can answer directly without tools, do so — do not spawn agents or workflows for it.",
|
||||
),
|
||||
);
|
||||
|
||||
const errors = new ErrorTracker();
|
||||
let sawToolCalls = false;
|
||||
let sawMutator = false;
|
||||
let pendingVerify = false;
|
||||
let verifyPrompted = false;
|
||||
let noProgressStreak = 0;
|
||||
let lastSignature: string | null = null;
|
||||
let reviewPasses = 0;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
|
||||
// Check abort flag.
|
||||
@@ -293,6 +484,26 @@ export class AgentTurnServiceImpl {
|
||||
break;
|
||||
}
|
||||
|
||||
// Convergence guard: bail out of a loop stuck re-issuing the same call.
|
||||
if (noProgressStreak >= MAX_NO_PROGRESS_STREAK) {
|
||||
this.push(sink, {
|
||||
kind: "system_note",
|
||||
systemKind: "warn",
|
||||
message: "Stopping: the same tool call is being repeated without any progress.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Verify-after-edit: nudge the model to run the check before concluding.
|
||||
if (pendingVerify && !verifyPrompted) {
|
||||
params.messages.push(
|
||||
systemMessage(
|
||||
`[Flow] You just modified files. Run the verify command via \`bash\` now (${verifyCommand}) and resolve any failures before concluding your turn.`,
|
||||
),
|
||||
);
|
||||
verifyPrompted = true;
|
||||
}
|
||||
|
||||
// Auto-compact oversized history before the LLM call.
|
||||
await this.autoCompactIfNeeded(params.messages);
|
||||
|
||||
@@ -343,12 +554,44 @@ export class AgentTurnServiceImpl {
|
||||
return seq;
|
||||
})();
|
||||
|
||||
let mutated = false;
|
||||
let verified = false;
|
||||
let batchSignature = "";
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i]!;
|
||||
const output = outputs[i]!;
|
||||
if (isErrorOutput(output)) errors.record(tc.function.name, output, params.messages);
|
||||
if (isToolFailure(tc.function.name, output)) errors.record(tc.function.name, output, params.messages);
|
||||
if (MUTATOR_TOOLS.has(tc.function.name)) {
|
||||
mutated = true;
|
||||
sawMutator = true;
|
||||
}
|
||||
if ((tc.function.name === "bash" || tc.function.name === "bash_output") && bashExitCode(output) === 0) {
|
||||
verified = true;
|
||||
}
|
||||
// Batch signature: concat of tool+output for no-progress detection.
|
||||
batchSignature += `${tc.function.name} ${output.length} `;
|
||||
params.messages.push(toolResultMessage(tc.id, output));
|
||||
}
|
||||
|
||||
// No-progress: byte-identical batch (same tools, same output lengths) → streak.
|
||||
if (toolCalls.length === 1 && batchSignature === lastSignature) noProgressStreak += 1;
|
||||
else noProgressStreak = 0;
|
||||
lastSignature = batchSignature;
|
||||
|
||||
if (mutated) {
|
||||
pendingVerify = true;
|
||||
verifyPrompted = false; // allow a fresh nudge after the next edit batch
|
||||
}
|
||||
if (verified) {
|
||||
pendingVerify = false; // a successful bash run satisfies the verify nudge
|
||||
verifyPrompted = false;
|
||||
}
|
||||
|
||||
// Bounded self-review pass after file mutations (at most one per turn).
|
||||
if (sawMutator && reviewPasses < MAX_REVIEW_PASSES && !abort.signal.aborted) {
|
||||
reviewPasses += 1;
|
||||
await this.reviewWork(params.messages, abort, sink);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the synthetic sys_msg before emitting to the transcript.
|
||||
@@ -357,4 +600,30 @@ export class AgentTurnServiceImpl {
|
||||
this.push(sink, { kind: "done" });
|
||||
in_flight.value = false;
|
||||
}
|
||||
|
||||
/** Run one bounded self-review of the work so far and feed critique back. */
|
||||
private async reviewWork(
|
||||
messages: ChatMessage[],
|
||||
abort: AbortController,
|
||||
sink: TurnEventSink,
|
||||
): Promise<void> {
|
||||
if (abort.signal.aborted) return;
|
||||
try {
|
||||
const result = await this.provider.chat(
|
||||
[systemMessage(reviewerPrompt())],
|
||||
undefined,
|
||||
1024,
|
||||
0.3,
|
||||
);
|
||||
const critique = result.message.content ?? "";
|
||||
if (result.usage) {
|
||||
this.push(sink, { kind: "review_usage", tokens_in: result.usage[0], tokens_out: result.usage[1] });
|
||||
}
|
||||
if (critique.trim() === "" || critique.trim().toUpperCase() === "NO ISSUES FOUND") return;
|
||||
// Feed the critique back as a system message so the next iteration resolves it.
|
||||
messages.push(systemMessage(`[Reviewer]\n${critique.trim()}`));
|
||||
} catch {
|
||||
/* review is best-effort — non-fatal */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Tests pinning the rewritten prompts to be consistent with the REAL tool
|
||||
* inventory: they must never reference the phantom `explore_codebase`, must
|
||||
* list real tool names, and must carry the concrete verify command.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
mainAgentPrompt,
|
||||
mainAgentPromptWithProjectContext,
|
||||
subagentDirective,
|
||||
compactionPrompt,
|
||||
reviewerPrompt,
|
||||
consensusSynthesizerPrompt,
|
||||
errorRecoveryNote,
|
||||
} from "./prompt.ts";
|
||||
|
||||
/** All real tool names (mirrors registry.allTools()). */
|
||||
const REAL_TOOLS = new Set([
|
||||
"read", "write", "edit", "delete",
|
||||
"grep", "glob", "semantic_search", "rebuild_index", "list_symbols",
|
||||
"bash", "bash_output", "bash_kill", "git_operator", "git_worktree", "git_cred",
|
||||
"cd", "dir_list", "dir_cache_update",
|
||||
"plan_enter", "plan_ready", "sequential_think", "todowrite", "todofinish",
|
||||
"workflow_run", "hive_mind", "note_finding", "read_findings",
|
||||
"spawn_agents", "spawn_pipeline", "parallel_delegate",
|
||||
"remember", "forget", "recall",
|
||||
"web_search", "best_practice", "commit_convention", "pong",
|
||||
]);
|
||||
|
||||
describe("prompt tool-consistency", () => {
|
||||
test("main prompt never references non-existent explore_codebase", () => {
|
||||
expect(mainAgentPrompt()).not.toContain("explore_codebase");
|
||||
});
|
||||
|
||||
test("every backticked tool token in the main prompt is a real tool", () => {
|
||||
const s = mainAgentPrompt();
|
||||
const backticks = s.match(/`([a-z_]+)`/g) ?? [];
|
||||
const tokens = backticks.map((t) => t.slice(1, -1));
|
||||
// `plan` and `verify` appear in backticks as prose (a command/word), not tools.
|
||||
const proseWords = new Set(["plan", "verify", "check"]);
|
||||
const ghosts = tokens.filter((t) => !REAL_TOOLS.has(t) && !proseWords.has(t));
|
||||
expect(ghosts).toEqual([]);
|
||||
});
|
||||
|
||||
test("main prompt anchors the key real tools", () => {
|
||||
const s = mainAgentPrompt();
|
||||
for (const tool of ["plan_enter", "todowrite", "workflow_run", "spawn_agents", "hive_mind", "recall", "remember"]) {
|
||||
expect(s).toContain(tool);
|
||||
}
|
||||
});
|
||||
|
||||
test("verify command is injected as a concrete line when provided", () => {
|
||||
const s = mainAgentPrompt("bun run check && bun test");
|
||||
expect(s).toContain("VERIFY COMMAND");
|
||||
expect(s).toContain("bun run check && bun test");
|
||||
});
|
||||
|
||||
test("without a verify command, no VERIFY COMMAND block is emitted", () => {
|
||||
expect(mainAgentPrompt()).not.toContain("VERIFY COMMAND");
|
||||
});
|
||||
|
||||
test("project-context variant appends the context block", () => {
|
||||
const s = mainAgentPromptWithProjectContext("## Rules\nno threads", "bun test");
|
||||
expect(s).toContain("## PROJECT CONTEXT");
|
||||
expect(s).toContain("no threads");
|
||||
expect(s).toContain("bun test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagentDirective", () => {
|
||||
test("includes the directive, cwd, root, and access tier", () => {
|
||||
const s = subagentDirective("make the button green", "/p", "/p/src", "read");
|
||||
expect(s).toContain("make the button green");
|
||||
expect(s).toContain("/p");
|
||||
expect(s).toContain("READ-ONLY");
|
||||
});
|
||||
|
||||
test("no-tier call defaults to full access", () => {
|
||||
const s = subagentDirective("do it", "/p", "/p");
|
||||
expect(s).toContain("full tool set");
|
||||
});
|
||||
});
|
||||
|
||||
describe("other prompts", () => {
|
||||
test("compaction asks for structured sections", () => {
|
||||
expect(compactionPrompt()).toContain("## Requests");
|
||||
expect(compactionPrompt()).toContain("## Work done");
|
||||
});
|
||||
|
||||
test("reviewer emits NO ISSUES FOUND sentinel contract", () => {
|
||||
expect(reviewerPrompt()).toContain("NO ISSUES FOUND");
|
||||
});
|
||||
|
||||
test("consensus synthesizer covers the four sections", () => {
|
||||
const s = consensusSynthesizerPrompt();
|
||||
expect(s).toContain("AGREEMENTS");
|
||||
expect(s).toContain("CONFLICTS");
|
||||
expect(s).toContain("KEY FINDINGS");
|
||||
expect(s).toContain("RECOMMENDATION");
|
||||
});
|
||||
|
||||
test("error recovery note is present", () => {
|
||||
expect(errorRecoveryNote("read", "nope")).toContain("[System note]");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,54 @@
|
||||
/** System prompts and directive templates. Mirrors `agent/prompt.rs`. */
|
||||
/**
|
||||
* System prompts and directive templates. Mirrors `agent/prompt.rs`.
|
||||
*
|
||||
* Every string here is written against the REAL built-in tool inventory
|
||||
* (see `infrastructure/tools/registry.ts`). There is intentionally no
|
||||
* reference to a hypothetical `explore_codebase`; orientation is done through
|
||||
* the actual `read` / `grep` / `glob` / `semantic_search` tools.
|
||||
*/
|
||||
|
||||
const MUTATOR_TOOLS = "`write`, `edit`, `delete`";
|
||||
|
||||
/** Shared operating rules referenced by main and sub-agent prompts. */
|
||||
const SHARED_DISCIPLINE = `TOOL DISCIPLINE:
|
||||
- Tool failures arrive prefixed with \`Error:\`. A \`bash\` command that exits non-zero returns an \`Exit code: N\` line (NOT an \`Error:\` prefix) — treat a non-zero exit the same as a failure.
|
||||
- Read before you write: never edit a file you have not read first.
|
||||
- Make the smallest correct change. Do not rewrite large sections unless the task explicitly calls for it.
|
||||
- Do not re-read files whose contents are already in your context. Prefer targeted \`grep\`/\`read\` over broad scans (\`glob\`, \`semantic_search\`) once you know where the code lives.
|
||||
- Follow the repository conventions from the PROJECT CONTEXT block when present.`;
|
||||
|
||||
/** Build the main-agent system prompt. */
|
||||
export function mainAgentPrompt(): string {
|
||||
return `You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.
|
||||
export function mainAgentPrompt(verifyCommand?: string): string {
|
||||
return `You are Zesdex, an autonomous AI coding agent. You help by doing real work against the user's repository using native function-calling tools.
|
||||
|
||||
TOKEN BUDGET — BE EFFICIENT:
|
||||
- For simple/factual questions, answer directly. Do NOT call tools.
|
||||
- For complex or unfamiliar code tasks, call \`explore_codebase\` ONCE at the start to locate relevant code, then work from that context.
|
||||
- Keep tool usage minimal: prefer \`grep\`/\`glob\`/\`read\` for targeted lookups; avoid re-reading files you already have in context.
|
||||
- Keep responses concise; do not repeat tool output verbatim.
|
||||
HOW TO WORK (choose the lightest sufficient path):
|
||||
1. Simple or factual: answer directly. Do NOT call tools.
|
||||
2. Needs code context: orient ONCE with \`read\`/\`grep\`/\`glob\` (or \`semantic_search\` for fuzzy questions), then work from that context.
|
||||
3. Multi-step or complex: enter a plan with \`plan_enter\`, track steps with \`todowrite\`, then execute.
|
||||
4. Genuinely parallel or multi-angle exploration: prefer \`workflow_run\`, \`spawn_agents\`, \`parallel_delegate\`, or \`hive_mind\`. Do NOT spawn agents for work you can do directly in a few steps.
|
||||
|
||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritise using \`workflow_run\` (to construct and execute a multi-phase YAML workflow) or \`hive_mind\` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.
|
||||
2. PLANNING & TODOs: Use \`plan_enter\` to establish high-level architectural plans and \`todowrite\` to maintain granular task checklists.
|
||||
3. REASONING: Use \`seq_think\` for deep step-by-step analysis.
|
||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyse and fix it.
|
||||
${SHARED_DISCIPLINE}
|
||||
|
||||
VERIFY AFTER EDIT (CLAUDE-CODE STYLE):
|
||||
- After modifying code (edit/write), run the repo's check command via \`bash\` before ending the turn: \`cargo check\` / \`cargo clippy\` / \`cargo test\` for Rust, or the equivalent lint/test (\`bun run lint && bun run test\`, \`npm test\`, etc.) for other stacks. Pick the project's actual verify command (see PROJECT CONTEXT / AGENTS.md when present).
|
||||
- If the check fails, fix the errors you can see and re-run; only end the turn after the check passes or you cannot resolve a failure yourself (then report it explicitly).
|
||||
- Do NOT claim code compiles or works without running a real check.
|
||||
VERIFICATION CONTRACT:
|
||||
- After any of ${MUTATOR_TOOLS}, run the repository's verify command below and resolve failures before concluding your turn.
|
||||
- Only claim code compiles or tests pass after running a real check.
|
||||
${verifyCommand ? `\nVERIFY COMMAND (run via \`bash\`):\n${verifyCommand}` : ""}
|
||||
|
||||
Respond conversationally, concisely, and helpfully.`;
|
||||
OUTPUT CONTRACT:
|
||||
- End with a concise summary of what you did and an explicit next step. Do not dump raw tool output into your reply.
|
||||
- If you cannot complete something, state exactly what is blocking you.
|
||||
- Reply in the same language the user writes in.
|
||||
|
||||
MEMORY:
|
||||
- At the start of a task, \`recall\` memory that may be relevant; use \`remember\` to store any hard-won insight or lesson worth keeping.`;
|
||||
}
|
||||
|
||||
/** Main-agent prompt with an injected `## PROJECT CONTEXT` block. Empty context → base prompt. */
|
||||
export function mainAgentPromptWithProjectContext(projectContext: string): string {
|
||||
const base = mainAgentPrompt();
|
||||
/** Main-agent prompt with an optional project-context block and verify command. */
|
||||
export function mainAgentPromptWithProjectContext(
|
||||
projectContext: string,
|
||||
verifyCommand?: string,
|
||||
): string {
|
||||
const base = mainAgentPrompt(verifyCommand);
|
||||
const context = projectContext.trim();
|
||||
if (context === "") return base;
|
||||
return `${base}
|
||||
@@ -35,29 +57,51 @@ export function mainAgentPromptWithProjectContext(projectContext: string): strin
|
||||
${context}`;
|
||||
}
|
||||
|
||||
/** Build a subagent directive prompt. */
|
||||
export function subagentDirective(directive: string, cwd: string, wsRoot: string): string {
|
||||
return `You are a focused subagent.
|
||||
/** Build a subagent directive prompt. Access tier tells the subagent its limits. */
|
||||
export function subagentDirective(
|
||||
directive: string,
|
||||
cwd: string,
|
||||
wsRoot: string,
|
||||
access: "read" | "write" | "full" = "full",
|
||||
): string {
|
||||
const tierNote =
|
||||
access === "read"
|
||||
? "You have READ-ONLY tools: you may inspect files and search, but must NOT modify anything."
|
||||
: access === "write"
|
||||
? "You have WRITE tools (file edits) but NOT shell or network execution."
|
||||
: "You have the full tool set.";
|
||||
return `You are a focused subagent working autonomously on a single directive.
|
||||
|
||||
Current directory (PWD): ${cwd}
|
||||
Workspace root: ${wsRoot}
|
||||
Access tier: ${access} — ${tierNote}
|
||||
|
||||
Your directive:
|
||||
${SHARED_DISCIPLINE}
|
||||
|
||||
Your directive (source of truth — follow it exactly):
|
||||
${directive}
|
||||
|
||||
Complete the directive autonomously using the tools available to you. Return your final answer when done.`;
|
||||
Complete the directive autonomously with the tools available to you. When done, return a concise final answer that reports what you changed (or found) and any open issues. Do not ask for permission; act within your access tier.`;
|
||||
}
|
||||
|
||||
/** Build a conversation-compaction prompt. */
|
||||
export function compactionPrompt(): string {
|
||||
return `You are a helpful assistant summarising conversation history. Provide a concise summary of the key user requests, decisions, tools executed, and modified files. Format as a clear bulleted list.`;
|
||||
return `You are condensing a long conversation to preserve context continuity without losing important detail.
|
||||
|
||||
Produce a concise markdown recap with these sections (skip any section that has no content):
|
||||
- ## Requests: what the user asked for.
|
||||
- ## Decisions: important choices made and why.
|
||||
- ## Work done: tools executed and files created or modified (with paths).
|
||||
- ## Open threads: unresolved items, pending questions, or what is still to do.
|
||||
|
||||
Be precise about file paths and current state. Do not invent details not present in the conversation.`;
|
||||
}
|
||||
|
||||
/** Directive for a lightweight context-scout subagent. */
|
||||
export function exploreScoutDirective(): string {
|
||||
return `You are a codebase context scout. Given the workspace root, quickly locate the code that is most relevant to the user's request:
|
||||
1. Run semantic_search once with the user's key terms.
|
||||
2. Read up to the 3 most relevant files (use grep for symbols if needed).
|
||||
1. Search for the relevant symbols/terms with \`grep\` or \`glob\`; use \`semantic_search\` only when the query is fuzzy.
|
||||
2. Read up to the 3 most relevant files.
|
||||
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of what you found and exactly where (file paths).
|
||||
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
||||
}
|
||||
@@ -65,4 +109,31 @@ Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
||||
/** System note injected after repeated tool errors. */
|
||||
export function errorRecoveryNote(toolName: string, lastError: string): string {
|
||||
return `[System note] The tool \`${toolName}\` failed repeatedly with: "${lastError}". Try an alternative approach (verify paths, correct arguments, use a different tool, or finish without this tool). Do NOT retry the same call.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** System prompt for the optional single-pass self-review after edits. */
|
||||
export function reviewerPrompt(): string {
|
||||
return `You are a code reviewer for an autonomous agent's work-in-progress. You are shown the conversation and the changes made so far.
|
||||
|
||||
Critique the work for:
|
||||
- CORRECTNESS: would the changes work as intended? any obvious bugs, edge cases, or missing pieces?
|
||||
- SPEC COMPLIANCE: did the agent actually do what was asked, or did it drift?
|
||||
- QUALITY: are the changes minimal, conventional, and consistent with the repository's style?
|
||||
|
||||
Return a tight list of the most important ACTIONABLE issues only, each as:
|
||||
- [PRIORITY: high|medium] <one concrete fix>, e.g. "high: handle empty input in parse()" or "medium: run the tests; they reference a removed export".
|
||||
|
||||
Do NOT praise the work. Do NOT restate the plan. If the work is already correct and complete, return exactly the single line "NO ISSUES FOUND". Be concrete enough that a next iteration can act without re-deriving context.`;
|
||||
}
|
||||
|
||||
/** System prompt for reconciling multi-node hive-mind outputs into one consensus. */
|
||||
export function consensusSynthesizerPrompt(): string {
|
||||
return `You are a consensus synthesizer for a multi-agent hive mind. Several independent nodes analysed a problem and produced the outputs below. Distill them into ONE coherent consensus report with these sections:
|
||||
- AGREEMENTS: points multiple nodes converge on.
|
||||
- CONFLICTS: contradictory conclusions, with which node(s) support each side.
|
||||
- KEY FINDINGS: the most important, actionable takeaways.
|
||||
- RECOMMENDATION: a single recommended next action, or 'no clear consensus' if the outputs are too divergent.
|
||||
|
||||
Be concise and factual. If a node errored, note it and ignore its content.
|
||||
Do not invent facts not present in the node outputs.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Tests for LlmClient.listModels() — the live /models fetch backing the TUI
|
||||
* model picker. Mocks global fetch so no network is touched.
|
||||
*/
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import { LlmClient } from "./provider.ts";
|
||||
|
||||
/** Replace global fetch once, returning the given JSON body for /models. */
|
||||
async function listWith(fetchImpl: (url: string) => Promise<Response>): Promise<string[]> {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = fetchImpl as typeof fetch;
|
||||
try {
|
||||
const client = new LlmClient("key", "some-model", "https://api.example.com/v1");
|
||||
return await client.listModels();
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
}
|
||||
|
||||
describe("LlmClient.listModels", () => {
|
||||
test("parses OpenAI-style { data: [{id}] }", async () => {
|
||||
const rows = await listWith(async (url: string) => {
|
||||
expect(url).toBe("https://api.example.com/v1/models");
|
||||
return new Response(JSON.stringify({ data: [{ id: "gpt-5" }, { id: "deepseek-v3" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
expect(rows).toEqual(["gpt-5", "deepseek-v3"]);
|
||||
});
|
||||
|
||||
test("parses { models: [string] } variant", async () => {
|
||||
const rows = await listWith(async () =>
|
||||
new Response(JSON.stringify({ models: ["a", "b"] }), { status: 200 }),
|
||||
);
|
||||
expect(rows).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("filters out missing ids", async () => {
|
||||
const rows = await listWith(async () =>
|
||||
new Response(JSON.stringify({ data: [{ id: "x" }, {}, { id: "y" }] }), { status: 200 }),
|
||||
);
|
||||
expect(rows).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
test("returns [] on non-OK or throw", async () => {
|
||||
const rows = await listWith(async () => new Response("forbidden", { status: 403 }));
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
void mock;
|
||||
@@ -123,6 +123,35 @@ export class LlmClient implements ProviderService {
|
||||
return `API request failed: ${err?.message ?? String(e)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the model ids exposed by the provider's `/models` endpoint
|
||||
* (OpenAI/Anthropic-compatible). Returns an empty array when the endpoint
|
||||
* isn't exposed or the call fails — callers fall back to static catalogs.
|
||||
*/
|
||||
async listModels(): Promise<string[]> {
|
||||
const url = `${this.baseUrl}/models`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as {
|
||||
data?: { id?: string }[] | null;
|
||||
models?: (string | { id?: string })[] | null;
|
||||
};
|
||||
const fromData = (data.data ?? []).map((m) => m?.id ?? "").filter(Boolean);
|
||||
if (fromData.length) return fromData;
|
||||
const fromModels = (data.models ?? [])
|
||||
.map((m) => (typeof m === "string" ? m : m?.id ?? ""))
|
||||
.filter(Boolean);
|
||||
return fromModels;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
tools?: ToolDef[],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { NodeOutput } from "@zesdex/workflow";
|
||||
import type { ToolCtx } from "../../agent/infrastructure/tools/mod.ts";
|
||||
import { consensusSynthesizerPrompt } from "@zesdex/agent";
|
||||
import { MAX_NODE_OUTPUT_CHARS } from "./engine.ts";
|
||||
|
||||
/**
|
||||
@@ -33,16 +34,7 @@ export async function synthesizeConsensus(
|
||||
|
||||
const systemMsg = {
|
||||
role: "system" as const,
|
||||
content: "You are a consensus synthesizer for a multi-agent hive mind. " +
|
||||
"Several independent nodes analysed a problem and produced the outputs " +
|
||||
"below. Distill them into ONE coherent consensus report with these sections:\n" +
|
||||
"- AGREEMENTS: points multiple nodes converge on.\n" +
|
||||
"- CONFLICTS: contradictory conclusions, with which node(s) support each side.\n" +
|
||||
"- KEY FINDINGS: the most important, actionable takeaways.\n" +
|
||||
"- RECOMMENDATION: a single recommended next action, or 'no clear consensus' " +
|
||||
"if the outputs are too divergent.\n" +
|
||||
"Be concise and factual. If a node errored, note it and ignore its content.\n" +
|
||||
"Do not invent facts not present in the node outputs.",
|
||||
content: consensusSynthesizerPrompt(),
|
||||
};
|
||||
|
||||
const userMsg = {
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface WiredRuntime {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
apiBase?: string;
|
||||
/** Fetch the provider's available model ids (via GET /models). */
|
||||
listModels?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +78,15 @@ export async function runSingleProcess(): Promise<WiredRuntime> {
|
||||
toolDefs(allTools()) as never,
|
||||
);
|
||||
|
||||
return { store, executor, turnService, apiKey, model, apiBase: baseUrl };
|
||||
return {
|
||||
store,
|
||||
executor,
|
||||
turnService,
|
||||
apiKey,
|
||||
model,
|
||||
apiBase: baseUrl,
|
||||
listModels: () => llmClient.listModels(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rooted, long-lived event sink that collects events into an array. */
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
toastInfo,
|
||||
markDirty,
|
||||
EditorState,
|
||||
type MenuFrame,
|
||||
} from "./state.ts";
|
||||
import { gitDiffOutput } from "./git.ts";
|
||||
import { modelProviderMenu, configMenu } from "./menu.ts";
|
||||
|
||||
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
||||
export type Action =
|
||||
@@ -46,7 +48,21 @@ export type Action =
|
||||
| { tag: "AbortTurn" }
|
||||
| { tag: "Compact" }
|
||||
| { tag: "ShowDiff" }
|
||||
| { tag: "DiffScroll"; amount: number };
|
||||
| { tag: "DiffScroll"; amount: number }
|
||||
/* Interactive menu stack */
|
||||
| { tag: "OpenMenu"; frame: MenuFrame }
|
||||
| { tag: "MenuUp" }
|
||||
| { tag: "MenuDown" }
|
||||
| { tag: "MenuPageUp" }
|
||||
| { tag: "MenuPageDown" }
|
||||
| { tag: "MenuHome" }
|
||||
| { tag: "MenuEnd" }
|
||||
| { tag: "MenuSelect" }
|
||||
| { tag: "MenuBack" }
|
||||
| { tag: "MenuBackAll" }
|
||||
| { tag: "MenuApply"; index: number }
|
||||
/** Open a named top-level menu (frame built from live state at apply time). */
|
||||
| { tag: "OpenMenuById"; menu: "model" | "config" };
|
||||
|
||||
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
|
||||
export function applyAction(state: AppStateRest, action: Action): void {
|
||||
@@ -184,9 +200,83 @@ export function applyAction(state: AppStateRest, action: Action): void {
|
||||
state.dirty = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case "OpenMenu":
|
||||
state.misc.menuStack.push(action.frame);
|
||||
state.misc.overlay = "menu";
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "MenuUp":
|
||||
moveMenu(state, -1);
|
||||
break;
|
||||
case "MenuDown":
|
||||
moveMenu(state, 1);
|
||||
break;
|
||||
case "MenuPageUp":
|
||||
moveMenu(state, -10);
|
||||
break;
|
||||
case "MenuPageDown":
|
||||
moveMenu(state, 10);
|
||||
break;
|
||||
case "MenuHome": {
|
||||
const f = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (f && f.items.length) f.selected = 0;
|
||||
state.dirty = true;
|
||||
break;
|
||||
}
|
||||
case "MenuEnd": {
|
||||
const f = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (f && f.items.length) f.selected = f.items.length - 1;
|
||||
state.dirty = true;
|
||||
break;
|
||||
}
|
||||
case "MenuSelect":
|
||||
// Push nothing here; the controller resolves a sub-menu or action. This
|
||||
// simply marks dirty so the selection is reflected.
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "MenuBack":
|
||||
state.misc.menuStack.pop();
|
||||
if (state.misc.menuStack.length === 0) state.misc.overlay = "none";
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "MenuBackAll":
|
||||
state.misc.menuStack = [];
|
||||
state.misc.overlay = "none";
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "MenuApply":
|
||||
applyMenuAt(state, action.index);
|
||||
break;
|
||||
|
||||
case "OpenMenuById": {
|
||||
const frame =
|
||||
action.menu === "model" ? modelProviderMenu(state) : configMenu(state);
|
||||
state.misc.menuStack.push(frame);
|
||||
state.misc.overlay = "menu";
|
||||
state.dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move the top menu's selection by `delta`, wrapping at the ends. */
|
||||
function moveMenu(state: AppStateRest, delta: number): void {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (!frame || frame.items.length === 0) return;
|
||||
const n = frame.items.length;
|
||||
frame.selected = (frame.selected + delta) % n;
|
||||
if (frame.selected < 0) frame.selected += n;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
function applyMenuAt(state: AppStateRest, index: number): void {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (!frame) return;
|
||||
state.misc.selectedIndex = index;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/** Periodic timer tick — drains queued events and applies streaming text. */
|
||||
function handleTick(state: AppStateRest): void {
|
||||
// Streaming updates are applied via appendToLastTranscript by the turn
|
||||
|
||||
@@ -14,8 +14,8 @@ export type Command =
|
||||
| { tag: "Clear" }
|
||||
| { tag: "ClearConfirm" }
|
||||
| { tag: "Edit"; path: string }
|
||||
| { tag: "McpAdd"; name: string; command: string }
|
||||
| { tag: "ModelList" }
|
||||
| { tag: "OpenModelMenu" }
|
||||
| { tag: "OpenConfigMenu" }
|
||||
| { tag: "Compact" }
|
||||
| { tag: "TodoOpen" }
|
||||
| { tag: "PlanOpen" }
|
||||
@@ -28,10 +28,9 @@ export function parseCommand(text: string): Command {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
||||
|
||||
const parts = trimmed.split(" ", 3);
|
||||
const parts = trimmed.split(" ", 2);
|
||||
const cmd = parts[0] ?? "";
|
||||
const arg1 = parts[1] ?? "";
|
||||
const arg2 = parts[2] ?? "";
|
||||
|
||||
switch (cmd) {
|
||||
case "/help":
|
||||
@@ -43,18 +42,15 @@ export function parseCommand(text: string): Command {
|
||||
case "/edit":
|
||||
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
||||
case "/mcp":
|
||||
// No inline sub-commands: `/mcp` alone opens the interactive server list.
|
||||
if (arg1 === "") return { tag: "McpOpen" };
|
||||
if (arg1 === "add") {
|
||||
const rest = arg2.trim();
|
||||
const space = rest.indexOf(" ");
|
||||
if (space !== -1) {
|
||||
return { tag: "McpAdd", name: rest.slice(0, space), command: rest.slice(space + 1).trim() };
|
||||
}
|
||||
return { tag: "McpAdd", name: rest, command: "" };
|
||||
}
|
||||
return { tag: "Unknown", text: cmd };
|
||||
case "/model":
|
||||
return { tag: "ModelList" };
|
||||
// No inline `/model add` / `/model ls`: `/model` is a nested picker.
|
||||
return { tag: "OpenModelMenu" };
|
||||
case "/config":
|
||||
case "/settings":
|
||||
return { tag: "OpenConfigMenu" };
|
||||
case "/compact":
|
||||
return { tag: "Compact" };
|
||||
case "/todo":
|
||||
@@ -85,10 +81,10 @@ export function applyCommand(cmd: Command): Action[] {
|
||||
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
||||
case "Edit":
|
||||
return [{ tag: "OpenEditor", path: cmd.path }];
|
||||
case "McpAdd":
|
||||
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
|
||||
case "ModelList":
|
||||
return [{ tag: "ModelList" }];
|
||||
case "OpenModelMenu":
|
||||
return [{ tag: "OpenMenuById", menu: "model" }];
|
||||
case "OpenConfigMenu":
|
||||
return [{ tag: "OpenMenuById", menu: "config" }];
|
||||
case "Compact":
|
||||
return [{ tag: "Compact" }];
|
||||
case "TodoOpen":
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import type { AppStateRest } from "./state.ts";
|
||||
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
|
||||
import { AutocompleteKind, COMMANDS, markDirty, toastInfo, overlayIsActive } from "./state.ts";
|
||||
import type { Action } from "./action.ts";
|
||||
import { applyCommand, parseCommand } from "./command.ts";
|
||||
import { resolveMenu } from "./menu.ts";
|
||||
|
||||
/** A decoded terminal key event (normalised from raw input). */
|
||||
export interface KeyEvent {
|
||||
@@ -32,6 +33,8 @@ export const KeyCode = {
|
||||
Down: "ArrowDown",
|
||||
PageUp: "PageUp",
|
||||
PageDown: "PageDown",
|
||||
Home: "Home",
|
||||
End: "End",
|
||||
Esc: "Escape",
|
||||
Tab: "Tab",
|
||||
Char: "Char",
|
||||
@@ -183,7 +186,48 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Interactive menu stack ───────────────────────────────────────────
|
||||
if (state.misc.menuStack.length > 0) {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
const n = frame?.items.length ?? 0;
|
||||
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||
if (code === KeyCode.Esc) return [{ tag: "MenuBack" }];
|
||||
if (code === KeyCode.Up) return n > 0 ? [{ tag: "MenuUp" }] : [];
|
||||
if (code === KeyCode.Down) return n > 0 ? [{ tag: "MenuDown" }] : [];
|
||||
if (code === KeyCode.PageUp) return n > 0 ? [{ tag: "MenuPageUp" }] : [];
|
||||
if (code === KeyCode.PageDown) return n > 0 ? [{ tag: "MenuPageDown" }] : [];
|
||||
if (code === KeyCode.Home) return n > 0 ? [{ tag: "MenuHome" }] : [];
|
||||
if (code === KeyCode.End) return n > 0 ? [{ tag: "MenuEnd" }] : [];
|
||||
if (code === KeyCode.Enter) {
|
||||
return enterMenu(state, frame?.id ?? "");
|
||||
}
|
||||
// vim-style j/k / g/G navigation.
|
||||
if (code === KeyCode.Char && key.value === "j") return n > 0 ? [{ tag: "MenuDown" }] : [];
|
||||
if (code === KeyCode.Char && key.value === "k") return n > 0 ? [{ tag: "MenuUp" }] : [];
|
||||
if (code === KeyCode.Char && key.value === "g") return n > 0 ? [{ tag: "MenuHome" }] : [];
|
||||
if (code === KeyCode.Char && key.value === "G") return n > 0 ? [{ tag: "MenuEnd" }] : [];
|
||||
// Backspace removes the last filter character (type-to-filter).
|
||||
if (code === KeyCode.Backspace) return menuFilterBackspace(state);
|
||||
// Printable char → type-to-filter (narrow + jump to first match).
|
||||
if (code === KeyCode.Char && key.value && key.value.trim().length > 0 && /^[\w./-]$/.test(key.value)) {
|
||||
return menuFilterAppend(state, key.value);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||
// Slash commands can run even when an overlay is open, BUT an open
|
||||
// autocomplete dropdown has first claim on Enter: it completes the command
|
||||
// token rather than firing a half-typed "/mo" as an unknown command.
|
||||
if (
|
||||
code === KeyCode.Enter &&
|
||||
state.input.buffer.startsWith("/") &&
|
||||
!state.input.autocompleteVisible
|
||||
) {
|
||||
const text = state.input.buffer;
|
||||
return applyCommand(parseCommand(text));
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case KeyCode.Char:
|
||||
if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||
@@ -215,7 +259,17 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
return [];
|
||||
case KeyCode.Enter:
|
||||
if (state.input.autocompleteVisible) {
|
||||
state.input.selectAutocomplete();
|
||||
const wasCommand = state.input.autocompleteKind === AutocompleteKind.Command;
|
||||
const selected = state.input.selectAutocomplete();
|
||||
// Completing a command via Enter should also RUN it (single Enter),
|
||||
// unless the user typed more args after the token (e.g. "/edit foo").
|
||||
if (selected && wasCommand) {
|
||||
const line = state.input.buffer.trim();
|
||||
const word = line.split(/\s+/)[0] ?? "";
|
||||
if (COMMANDS.includes(word)) {
|
||||
return applyCommand(parseCommand(line));
|
||||
}
|
||||
}
|
||||
return mark(state);
|
||||
}
|
||||
if (overlayIsActive(state.misc.overlay)) {
|
||||
@@ -223,9 +277,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
}
|
||||
{
|
||||
const text = state.input.buffer;
|
||||
if (text.startsWith("/")) {
|
||||
return applyCommand(parseCommand(text));
|
||||
}
|
||||
return [{ tag: "SubmitInput", text }];
|
||||
}
|
||||
case KeyCode.Backspace:
|
||||
@@ -249,14 +300,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
state.input.cycleAutocomplete(false);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (state.misc.overlay === "model_selector") {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(
|
||||
state.misc.selectedIndex,
|
||||
Object.keys(state.appConfig.providers).length,
|
||||
false,
|
||||
);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (key.ctrl) {
|
||||
return [{ tag: "ScrollUp" }];
|
||||
} else {
|
||||
@@ -267,14 +310,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
state.input.cycleAutocomplete(true);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (state.misc.overlay === "model_selector") {
|
||||
state.misc.selectedIndex = cycleSelectedIndex(
|
||||
state.misc.selectedIndex,
|
||||
Object.keys(state.appConfig.providers).length,
|
||||
true,
|
||||
);
|
||||
mark(state);
|
||||
return [];
|
||||
} else if (key.ctrl) {
|
||||
return [{ tag: "ScrollDown" }];
|
||||
} else {
|
||||
@@ -315,6 +350,50 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Enter on a menu item. `id` joins the menu navigation path with `:`
|
||||
* (e.g. `model:provider`, `model:model`, `settings:internet`). Returns the
|
||||
* actions to run — typically an `OpenMenu` for a sub-menu, or an apply action
|
||||
* that pops the whole stack.
|
||||
*/
|
||||
function enterMenu(state: AppStateRest, id: string): Action[] {
|
||||
// Delegate Enter to the semantic resolver (menu.ts): it returns an OpenMenu
|
||||
// for nested pickers, or applied actions that pop the stack.
|
||||
return resolveMenu(state, id) ?? [];
|
||||
}
|
||||
|
||||
/** Append a character to the type-to-filter and jump to the first match. */
|
||||
function menuFilterAppend(state: AppStateRest, ch: string): Action[] {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (!frame || frame.items.length === 0) return [];
|
||||
frame.filter = (frame.filter ?? "") + ch.toLowerCase();
|
||||
jumpFiltered(frame);
|
||||
markDirty(state);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Remove the last filter character; jump back to the first match. */
|
||||
function menuFilterBackspace(state: AppStateRest): Action[] {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (!frame || frame.items.length === 0) return [];
|
||||
const f = frame.filter ?? "";
|
||||
frame.filter = f.slice(0, -1);
|
||||
jumpFiltered(frame);
|
||||
markDirty(state);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Move selection to the first item matching the frame filter (if any). */
|
||||
function jumpFiltered(frame: { items: string[]; selected: number; filter?: string }): void {
|
||||
const f = (frame.filter ?? "").toLowerCase();
|
||||
if (!f) {
|
||||
frame.selected = 0;
|
||||
return;
|
||||
}
|
||||
const hit = frame.items.findIndex((it) => it.toLowerCase().includes(f));
|
||||
if (hit !== -1) frame.selected = hit;
|
||||
}
|
||||
|
||||
/** Local helper: whether an agent turn is in flight. */
|
||||
function turnInFlightLocal(state: AppStateRest): boolean {
|
||||
return state.turnInFlightFlag.value;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Data-driven interactive menus for the TUI.
|
||||
*
|
||||
* Replaces inline sub-commands (`/model add`, `/mcp add …`) with pickers the
|
||||
* way Claude Code / opencode do. Each menu is a flat `MenuFrame` of selectable
|
||||
* rows; choosing a row either opens a nested sub-menu (via `OpenMenu`) or
|
||||
* applies a change and pops the stack. Everything here is logic-pure and
|
||||
* unit-testable.
|
||||
*
|
||||
* Every select dialog shares one keymap (arrow/jk, Home/End, PageUp/Down,
|
||||
* Enter select, Esc back) centralised in `controller.ts`; the *shape* of each
|
||||
* menu and the *effect* of choosing a row lives here. The current value is
|
||||
* shown on every relevant row.
|
||||
*/
|
||||
import type { AppStateRest, MenuFrame } from "./state.ts";
|
||||
import type { Action } from "./action.ts";
|
||||
import { toastInfo } from "./state.ts";
|
||||
|
||||
/* ── /model tree ──────────────────────────────────────────────────── */
|
||||
|
||||
/** Build the `/model` provider menu (opencode-style provider list first). */
|
||||
export function modelProviderMenu(state: AppStateRest): MenuFrame {
|
||||
const names = Object.keys(state.appConfig.providers);
|
||||
const list = names.length ? names : ["claude", "zen", "router"];
|
||||
const current = String(state.settings.provider ?? "");
|
||||
return {
|
||||
id: "model:provider",
|
||||
title: "Providers",
|
||||
items: list,
|
||||
details: list.map((p) =>
|
||||
p === current ? ` (active)` : "",
|
||||
),
|
||||
hint: "↑/↓ or j/k · Enter open models · Esc back",
|
||||
selected: Math.max(0, list.indexOf(current)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the per-provider model sub-menu, preferring models fetched live. */
|
||||
export function modelSubMenu(state: AppStateRest, provider: string): MenuFrame | null {
|
||||
const live = state.misc.availableModels;
|
||||
const current = String(state.settings.model ?? "");
|
||||
if (live.length > 0) {
|
||||
return {
|
||||
id: `model:${provider}`,
|
||||
title: `Models — ${provider} (${live.length})`,
|
||||
items: [...live],
|
||||
details: live.map((m) => (m === current ? " (current)" : (m === state.settings.model ? " (current)" : ""))),
|
||||
hint: "Enter applies · Esc back to providers",
|
||||
selected: Math.max(0, live.indexOf(current)),
|
||||
};
|
||||
}
|
||||
// No /models endpoint (or it's still fetching) — degrade to a small static
|
||||
// catalog so the picker never blocks.
|
||||
const known = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", "deepseek-v4-flash-free", "gpt-5"];
|
||||
return {
|
||||
id: `model:${provider}`,
|
||||
title: `Models — ${provider}`,
|
||||
items: known,
|
||||
details: known.map((m) => (m === current ? " (current)" : "")),
|
||||
hint: "live /models unavailable — fallback list · Enter applies",
|
||||
selected: Math.max(0, known.indexOf(current)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── /config tree ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Build the top-level `/config` menu. */
|
||||
export function configMenu(state: AppStateRest): MenuFrame {
|
||||
const internet = String(state.settings.internet_mode ?? "Off");
|
||||
const review = state.settings.review_enabled !== false ? "on" : "off";
|
||||
const provider = String(state.settings.provider ?? "");
|
||||
return {
|
||||
id: "config:root",
|
||||
title: "Settings",
|
||||
items: ["Internet access", "Review enabled", "Provider / model"],
|
||||
details: [
|
||||
`current: ${internet}`,
|
||||
`current: ${review}`,
|
||||
`current: ${provider}`,
|
||||
],
|
||||
hint: "↑/↓ or j/k · Enter · Esc close",
|
||||
selected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the internet-mode sub-menu. */
|
||||
function internetMenu(state: AppStateRest): MenuFrame {
|
||||
const modes = ["Off", "ReadOnly", "Full"];
|
||||
const current = String(state.settings.internet_mode ?? "Off");
|
||||
return {
|
||||
id: "config:internet",
|
||||
title: "Config — internet access",
|
||||
items: modes,
|
||||
details: modes.map((m) => (m === current ? " (current)" : "")),
|
||||
hint: "Enter set · Esc back",
|
||||
selected: Math.max(0, modes.indexOf(current)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Menu resolver ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Resolve Enter inside any interactive menu. `id` encodes the navigation path
|
||||
* (e.g. `model:provider`, `model:zen`, `config:root`, `config:internet`).
|
||||
* Reads the top-most frame's selected row and returns the actions to run —
|
||||
* usually an `OpenMenu` for a nested picker, or applied actions that pop the
|
||||
* entire stack.
|
||||
*/
|
||||
export function resolveMenu(state: AppStateRest, id: string): Action[] {
|
||||
const frame = state.misc.menuStack[state.misc.menuStack.length - 1];
|
||||
if (!frame) return [];
|
||||
const item = frame.items[frame.selected];
|
||||
if (item === undefined) return [];
|
||||
const idx = frame.selected;
|
||||
|
||||
// ── model tree ────────────────────────────────────────────────────
|
||||
if (id === "model:provider") {
|
||||
const sub = modelSubMenu(state, item);
|
||||
return sub ? [{ tag: "OpenMenu", frame: sub }] : popAll();
|
||||
}
|
||||
if (id.startsWith("model:")) {
|
||||
// A model row under a provider — apply it.
|
||||
const provider = id.slice("model:".length);
|
||||
state.settings = { ...state.settings, provider, model: item };
|
||||
toastInfo(state, `Using ${provider} / ${item}`);
|
||||
return popAll();
|
||||
}
|
||||
|
||||
// ── config tree ───────────────────────────────────────────────────
|
||||
if (id === "config:root") {
|
||||
switch (idx) {
|
||||
case 0:
|
||||
return [{ tag: "OpenMenu", frame: internetMenu(state) }];
|
||||
case 1: {
|
||||
const on = state.settings.review_enabled !== false;
|
||||
state.settings = { ...state.settings, review_enabled: !on };
|
||||
toastInfo(state, `Review ${on ? "disabled" : "enabled"}`);
|
||||
return popAll();
|
||||
}
|
||||
case 2:
|
||||
return [{ tag: "OpenMenu", frame: modelProviderMenu(state) }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (id === "config:internet") {
|
||||
const modes = ["Off", "ReadOnly", "Full"];
|
||||
const chosen = modes[idx] ?? "Off";
|
||||
state.settings = { ...state.settings, internet_mode: chosen };
|
||||
toastInfo(state, `Internet mode → ${chosen}`);
|
||||
return popAll();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Pop the whole menu stack (a leaf decision applied). */
|
||||
function popAll(): Action[] {
|
||||
return [{ tag: "MenuBackAll" }];
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Smoke-render harness for the new TUI layout. Renders `ZesdexApp` through the
|
||||
* real OpenTUI test renderer across a few representative states and snapshots
|
||||
* the characters so we can eyeball the design without a live terminal.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { createTestRenderer } from "@opentui/core/testing";
|
||||
import { createRoot, flushSync } from "@opentui/react";
|
||||
import { ZesdexApp } from "./ui.tsx";
|
||||
import { applyAction } from "./action.ts";
|
||||
import {
|
||||
createTuiState,
|
||||
pushTranscript,
|
||||
makeChatMessage,
|
||||
type AppStateRest,
|
||||
} from "./state.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
|
||||
async function render(state: AppStateRest, w = 80, h = 24): Promise<string> {
|
||||
const setup = await createTestRenderer({ width: w, height: h });
|
||||
const root = createRoot(setup.renderer);
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
flushSync(() => root.render(<ZesdexApp state={state} onQuit={() => {}} />));
|
||||
await setup.renderOnce();
|
||||
await setup.flush();
|
||||
const frame = setup.captureCharFrame();
|
||||
flushSync(() => root.unmount());
|
||||
await setup.renderOnce();
|
||||
await setup.flush();
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = false;
|
||||
return frame;
|
||||
}
|
||||
|
||||
describe("ZesdexApp render smoke", () => {
|
||||
test("empty transcript shows prompt + placeholder", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
const frame = await render(s);
|
||||
expect(frame).toContain("zesdex");
|
||||
expect(frame).toContain("Ask Zesdex to help");
|
||||
expect(frame).toContain("● idle");
|
||||
});
|
||||
|
||||
test("with instructions renders the folder name label", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
await render(s);
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
|
||||
test("user + assistant markdown message renders", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
pushTranscript(s, makeChatMessage(Roles.User, "hello there"));
|
||||
pushTranscript(s, makeChatMessage(Roles.Assistant, "# Result\n\nSome **bold** and `code`."));
|
||||
const frame = await render(s);
|
||||
expect(frame).toContain("[YOU]");
|
||||
expect(frame).toContain("[AI]");
|
||||
expect(frame).toContain("Result");
|
||||
});
|
||||
|
||||
test("help overlay renders the shortcut text", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.misc.overlay = "help";
|
||||
const frame = await render(s);
|
||||
expect(frame).toContain("Keyboard Shortcuts");
|
||||
expect(frame).toContain("Ctrl+C");
|
||||
});
|
||||
|
||||
test("model selector overlay renders provider list", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.misc.overlay = "model_selector";
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
s.settings = { provider: "" }; // no active provider → none marked (active)
|
||||
const frame = await render(s, 80, 24);
|
||||
// Count how many distinct provider names actually hit the frame.
|
||||
const count = ["claude","zen","router"].filter((p) => frame.includes(p)).length;
|
||||
console.log("MODEL-DEBUG providers-in-frame:", count, frame.split("\n").filter((l)=>l.includes("▸")).map((l)=>l.trim()));
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
test("running state shows spinner + activity + token usage", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.turnInFlightFlag.value = true;
|
||||
s.misc.activity = [
|
||||
{ label: "grep", kind: "tool" },
|
||||
{ label: "read", kind: "tool", count: 3 },
|
||||
];
|
||||
s.lastUsage = { tokensIn: 12, tokensOut: 34 };
|
||||
const frame = await render(s);
|
||||
expect(frame).toContain("● running");
|
||||
expect(frame).toContain("grep");
|
||||
expect(frame).toContain("read×3");
|
||||
expect(frame).toContain("in:12");
|
||||
expect(frame).toContain("out:34");
|
||||
expect(frame).toContain("Esc abort");
|
||||
});
|
||||
|
||||
test("diff overlay renders git diff lines with colour mapping", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.misc.overlay = "diff";
|
||||
s.misc.diffContent = "diff --git a/x b/x\n@@ -1,2 +1,3 @@\n removed\n+added\n context\n";
|
||||
const frame = await render(s, 90, 24);
|
||||
expect(frame).toContain("Git Diff");
|
||||
expect(frame).toContain("diff --git");
|
||||
expect(frame).toContain("removed");
|
||||
expect(frame).toContain("+added");
|
||||
expect(frame).toContain("context");
|
||||
});
|
||||
|
||||
test("editor overlay renders file buffer with line numbers", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.misc.overlay = "editor";
|
||||
s.misc.editor = {
|
||||
path: "src/a.ts",
|
||||
content: "line one\nline two\nline three",
|
||||
cursor: 3,
|
||||
asString: () => "",
|
||||
deleteLeft: () => {},
|
||||
} as any;
|
||||
const frame = await render(s, 80, 24);
|
||||
expect(frame).toContain("src/a.ts");
|
||||
expect(frame).toContain("line one");
|
||||
expect(frame).toContain("line three");
|
||||
});
|
||||
|
||||
test("interactive /model menu renders providers with active preselected", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
s.settings = { provider: "zen" };
|
||||
applyAction(s, { tag: "OpenMenuById", menu: "model" });
|
||||
const frame = await render(s, 90, 24);
|
||||
expect(frame).toContain("Providers");
|
||||
expect(frame).toContain("claude");
|
||||
expect(frame).toContain("zen");
|
||||
expect(frame).toContain("router");
|
||||
expect(frame).toContain("type to filter");
|
||||
expect(frame).toContain("(active)");
|
||||
});
|
||||
|
||||
test("autocomplete dropdown renders command rows (not overlapping)", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.input.buffer = "/mo";
|
||||
s.input.cursor = 3;
|
||||
s.input.openAutocomplete();
|
||||
const frame = await render(s, 80, 20);
|
||||
expect(frame).toContain("commands");
|
||||
expect(frame).toContain("/model");
|
||||
expect(frame).toContain("Tab accept");
|
||||
});
|
||||
|
||||
test("interactive /config menu shows rows with current values", async () => {
|
||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
|
||||
applyAction(s, { tag: "OpenMenuById", menu: "config" });
|
||||
const frame = await render(s, 90, 24);
|
||||
expect(frame).toContain("Settings");
|
||||
expect(frame).toContain("Internet access");
|
||||
expect(frame).toContain("Review enabled");
|
||||
expect(frame).toContain("current: Off");
|
||||
expect(frame).toContain("current: zen");
|
||||
});
|
||||
});
|
||||
+122
-8
@@ -8,18 +8,35 @@
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import { newStore, userMessage } from "@zesdex/domain";
|
||||
import type { TurnEventSink } from "@zesdex/agent";
|
||||
import type { TurnEvent, TurnEventSink } from "@zesdex/agent";
|
||||
import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts";
|
||||
import { createTuiState, type AppStateRest, type TurnRunner } from "./state.ts";
|
||||
import {
|
||||
createTuiState,
|
||||
type AppStateRest,
|
||||
type TurnRunner,
|
||||
pushTranscript,
|
||||
makeChatMessage,
|
||||
appendToLastTranscript,
|
||||
toastInfo,
|
||||
toastError,
|
||||
} from "./state.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import { bootTui } from "./ui.tsx";
|
||||
|
||||
/** Build the real turn runner from the wired runtime. */
|
||||
function makeTurnRunner(runtime: WiredRuntime): TurnRunner {
|
||||
/**
|
||||
* Build the real turn runner from the wired runtime.
|
||||
*
|
||||
* Streams every `TurnEvent` into the transcript and updates live UI state
|
||||
* (status bar token usage, reasoning block, tool call markers, toasts).
|
||||
*/
|
||||
function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner {
|
||||
return async (text, onEvent) => {
|
||||
const abort = new AbortController();
|
||||
state.turnAbort = abort;
|
||||
// Live sink: forward every event to the UI immediately as it happens.
|
||||
const sink: TurnEventSink = {
|
||||
push(ev) {
|
||||
push(ev: TurnEvent) {
|
||||
handleEvent(state, ev);
|
||||
onEvent(ev);
|
||||
},
|
||||
drain() {
|
||||
@@ -41,6 +58,85 @@ function makeTurnRunner(runtime: WiredRuntime): TurnRunner {
|
||||
};
|
||||
}
|
||||
|
||||
/** Append an entry to the bounded live-activity breadcrumb. */
|
||||
function addActivity(
|
||||
state: AppStateRest,
|
||||
entry: { label: string; detail?: string; kind: "tool" | "agent" | "error" },
|
||||
): void {
|
||||
const activity = state.misc.activity;
|
||||
// Dedupe consecutive repeats of the same tool so `read read read` collapses.
|
||||
const last = activity[activity.length - 1];
|
||||
const key = `${entry.label}:${entry.kind}`;
|
||||
if (last && `${last.label}:${last.kind}` === key && last.detail === entry.detail) {
|
||||
last.count = (last.count ?? 1) + 1;
|
||||
} else {
|
||||
activity.push({ ...entry, count: 1 });
|
||||
}
|
||||
// Keep the breadcrumb tight (max 12 entries).
|
||||
while (activity.length > 12) activity.shift();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
/** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */
|
||||
function handleEvent(state: AppStateRest, ev: TurnEvent): void {
|
||||
switch (ev.kind) {
|
||||
case "stream_token":
|
||||
appendToLastTranscript(state, ev.content, false);
|
||||
break;
|
||||
case "stream_reasoning":
|
||||
appendToLastTranscript(state, ev.content, true);
|
||||
break;
|
||||
case "system_note":
|
||||
pushTranscript(
|
||||
state,
|
||||
makeChatMessage(Roles.System, `[${ev.systemKind}] ${ev.message}`),
|
||||
);
|
||||
break;
|
||||
case "tool_result":
|
||||
// A tool result is a breadcrumb, not a full transcript entry — keep the
|
||||
// transcript clean and fold the tool name into the live activity bar.
|
||||
addActivity(state, {
|
||||
label: ev.tool_name,
|
||||
detail: ev.is_error ? "error" : undefined,
|
||||
kind: ev.is_error ? "error" : "tool",
|
||||
});
|
||||
break;
|
||||
case "usage":
|
||||
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
|
||||
state.dirty = true;
|
||||
break;
|
||||
case "agent_progress":
|
||||
addActivity(state, {
|
||||
label: ev.progress.agent_name,
|
||||
detail: ev.progress.error ?? ev.progress.current_tool ?? undefined,
|
||||
kind: ev.progress.error ? "error" : "agent",
|
||||
});
|
||||
break;
|
||||
case "workflow_agent_update":
|
||||
addActivity(state, {
|
||||
label: ev.agent_name,
|
||||
detail: ev.error ?? undefined,
|
||||
kind: ev.error ? "error" : "agent",
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
addActivity(state, { label: "error", detail: ev.message, kind: "error" });
|
||||
toastError(state, `Turn error: ${ev.message}`);
|
||||
break;
|
||||
case "compacted":
|
||||
pushTranscript(state, makeChatMessage(Roles.System, "Conversation compacted."));
|
||||
break;
|
||||
case "todo_update":
|
||||
toastInfo(state, `TODO updated: ${ev.content.slice(0, 80)}`);
|
||||
break;
|
||||
case "plan_update":
|
||||
toastInfo(state, `Plan updated: ${ev.content.slice(0, 80)}`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the interactive OpenTUI with the real agent wired in. */
|
||||
export async function runTui(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
@@ -57,10 +153,28 @@ export async function runTui(): Promise<void> {
|
||||
const memoryDir = `${dataDir}/memory`;
|
||||
|
||||
const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir);
|
||||
state.turnRunner = makeTurnRunner(runtime);
|
||||
state.turnRunner = makeTurnRunner(runtime, state);
|
||||
state.misc.apiConnected = true;
|
||||
// Surface the real effective model/provider in the header.
|
||||
state.settings = { provider: runtime.model } as Record<string, unknown>;
|
||||
|
||||
// Surface the real effective model / provider / session in the header.
|
||||
const provider = (state.settings.provider as string) || "claude";
|
||||
state.settings = {
|
||||
provider,
|
||||
model: runtime.model,
|
||||
apiBase: runtime.apiBase ?? "",
|
||||
sessionId: state.sessionId,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// Prime the /models list in the background so /model shows live models
|
||||
// instead of a hardcoded catalog; the menu falls back until it lands.
|
||||
if (runtime.listModels) {
|
||||
void runtime.listModels().then((models) => {
|
||||
if (models.length > 0) {
|
||||
state.misc.availableModels = models;
|
||||
state.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await bootTui(state);
|
||||
}
|
||||
|
||||
+198
-17
@@ -36,13 +36,14 @@ export const COMMANDS: string[] = [
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/mcp",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
"/config",
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
"/diff",
|
||||
"/plan",
|
||||
];
|
||||
|
||||
/** The user's input buffer, cursor, history, and autocomplete state. */
|
||||
@@ -56,6 +57,8 @@ export class InputState {
|
||||
autocompleteVisible = false;
|
||||
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
||||
mentionStart = 0;
|
||||
/** Start index of the command token being completed (for in-place replace). */
|
||||
autocompleteTokenStart = 0;
|
||||
historyFile: string | null = null;
|
||||
|
||||
/** Hide the autocomplete dropdown and clear its state. */
|
||||
@@ -65,6 +68,7 @@ export class InputState {
|
||||
this.autocompleteIdx = 0;
|
||||
this.autocompleteKind = AutocompleteKind.Command;
|
||||
this.mentionStart = 0;
|
||||
this.autocompleteTokenStart = 0;
|
||||
}
|
||||
|
||||
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
||||
@@ -74,11 +78,28 @@ export class InputState {
|
||||
this.closeAutocomplete();
|
||||
return;
|
||||
}
|
||||
const prefix = trimmed.toLowerCase();
|
||||
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix));
|
||||
// Filter on the *command token* (the word before any space), so typing
|
||||
// "/edit foo" still matches "/edit". A trailing/embedded space must not
|
||||
// kill the dropdown.
|
||||
const word = trimmed.split(/\s+/)[0] ?? "";
|
||||
const prefix = word.toLowerCase();
|
||||
if (!prefix.startsWith("/")) {
|
||||
this.closeAutocomplete();
|
||||
return;
|
||||
}
|
||||
// Prefer exact-match-first so a fully-typed command jumps to the top.
|
||||
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)).sort(
|
||||
(a, b) => Number(a === word) - Number(b === word),
|
||||
);
|
||||
// Record where the command token starts in the *raw buffer* (not the
|
||||
// trimmed word) so selectAutocomplete can replace just that token.
|
||||
this.autocompleteTokenStart = this.buffer.indexOf("/");
|
||||
this.autocompleteKind = AutocompleteKind.Command;
|
||||
this.autocompleteIdx = 0;
|
||||
this.autocompleteVisible = this.autocompleteCandidates.length > 0;
|
||||
// A fully-typed command needs no dropdown — entering closes it so Enter
|
||||
// runs the command immediately instead of "selecting" the same string.
|
||||
const exact = this.autocompleteCandidates.includes(word);
|
||||
this.autocompleteVisible = this.autocompleteCandidates.length > 0 && !exact;
|
||||
}
|
||||
|
||||
/** Find the `@mention` token (if any) immediately before the cursor. */
|
||||
@@ -129,13 +150,26 @@ export class InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Index just past the command token (stops at a space or end of buffer). */
|
||||
private endOfToken(): number {
|
||||
const start = this.autocompleteTokenStart;
|
||||
if (start < 0 || start > this.buffer.length) return start;
|
||||
const rest = this.buffer.slice(start);
|
||||
const space = rest.search(/\s/);
|
||||
return space === -1 ? this.buffer.length : start + space;
|
||||
}
|
||||
|
||||
/** Accept the currently selected autocomplete candidate. Returns true on success. */
|
||||
selectAutocomplete(): boolean {
|
||||
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
||||
if (candidate === undefined || candidate === null) return false;
|
||||
if (this.autocompleteKind === AutocompleteKind.Command) {
|
||||
this.buffer = candidate;
|
||||
this.cursor = this.buffer.length;
|
||||
// Replace only the command token (e.g. "/mo" → "/model"), keeping any
|
||||
// text the user already typed after it (e.g. "/edit path" stays).
|
||||
const start = this.autocompleteTokenStart;
|
||||
const end = this.endOfToken();
|
||||
this.buffer = this.buffer.slice(0, start) + candidate + this.buffer.slice(end);
|
||||
this.cursor = start + candidate.length;
|
||||
} else {
|
||||
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
||||
this.closeAutocomplete();
|
||||
@@ -194,6 +228,12 @@ export class InputState {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Clear the input buffer and reset the cursor. */
|
||||
clear(): void {
|
||||
this.buffer = "";
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
/** Navigate backward through input history. */
|
||||
historyUp(): void {
|
||||
if (this.history.length === 0) return;
|
||||
@@ -248,6 +288,7 @@ export const Overlay = {
|
||||
ModelSelector: "model_selector",
|
||||
ClearConfirm: "clear_confirm",
|
||||
Diff: "diff",
|
||||
Menu: "menu",
|
||||
} as const;
|
||||
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
|
||||
|
||||
@@ -277,6 +318,49 @@ export class EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Turn activity ────────────────────────────────────────────────── */
|
||||
|
||||
/** A compact, bounded breadcrumb of agent activity during a turn. */
|
||||
export interface ActivityEntry {
|
||||
/** Short label, e.g. a tool name or agent name. */
|
||||
label: string;
|
||||
/** Optional detail (current tool, error). */
|
||||
detail?: string;
|
||||
/** Dramatic colouring: "tool" | "agent" | "error". */
|
||||
kind: "tool" | "agent" | "error";
|
||||
/** Number of consecutive repeats (collapsed into one chip). */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/* ── Interactive menu ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A single frame in the interactive menu stack. Reusable across every
|
||||
* configuration surface (models, settings, MCP, todo, …) so each is a
|
||||
* picker like Claude Code / opencode — never inline sub-commands.
|
||||
*/
|
||||
export interface MenuFrame {
|
||||
/** Stable id used by the resolver to route Enter (e.g. "model:provider"). */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Selectable option labels (one line each). */
|
||||
items: string[];
|
||||
/** Optional dim detail shown under each option. */
|
||||
details?: string[];
|
||||
/** Footer hint line (keybindings / current value). */
|
||||
hint?: string;
|
||||
selected: number;
|
||||
/** Live type-to-filter: typing narrows/highlights matching rows. */
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
export const emptyMenuFrame = (): MenuFrame => ({
|
||||
id: "",
|
||||
title: "",
|
||||
items: [],
|
||||
selected: 0,
|
||||
});
|
||||
|
||||
/* ── Misc state ───────────────────────────────────────────────────── */
|
||||
|
||||
/** The "miscellaneous" slice of app state. */
|
||||
@@ -296,6 +380,12 @@ export class MiscState {
|
||||
pendingClipboardCopy: string | null = null;
|
||||
diffContent = "";
|
||||
diffScroll = 0;
|
||||
/** Breadcrumb of the current turn's activity (tools / sub-agents). */
|
||||
activity: ActivityEntry[] = [];
|
||||
/** Stack of interactive menu frames (top = currently shown). */
|
||||
menuStack: MenuFrame[] = [];
|
||||
/** Model ids fetched live from the provider's /models endpoint. */
|
||||
availableModels: string[] = [];
|
||||
|
||||
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||
this.toasts.push(toast);
|
||||
@@ -329,10 +419,13 @@ export interface ChatMessageDisplay {
|
||||
content: string;
|
||||
reasoning: string;
|
||||
timestamp: number;
|
||||
/** Sliding-window replay cursor for content + reasoning (transient). */
|
||||
_posContent: number;
|
||||
_posReasoning: number;
|
||||
}
|
||||
|
||||
export function makeChatMessage(role: Role, content: string): ChatMessageDisplay {
|
||||
return { role, content, reasoning: "", timestamp: Date.now() };
|
||||
return { role, content, reasoning: "", timestamp: Date.now(), _posContent: 0, _posReasoning: 0 };
|
||||
}
|
||||
|
||||
/** Bounded ring of recent chat messages used to render the transcript view. */
|
||||
@@ -357,15 +450,84 @@ export class TranscriptCache {
|
||||
const msg = this.messages[this.messages.length - 1];
|
||||
if (msg && msg.role === Roles.Assistant) {
|
||||
if (isReasoning) {
|
||||
msg.reasoning += text;
|
||||
msg.reasoning = reconcileStream(msg.reasoning, text, msg, true);
|
||||
} else {
|
||||
msg.content += text;
|
||||
msg.content = reconcileStream(msg.content, text, msg, false);
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile an incoming stream chunk against the accumulated text, deduping the
|
||||
* sliding-window replays that 9router's OpenAI-compatible layer emits.
|
||||
*
|
||||
* Streaming backends disagree on chunk semantics:
|
||||
* - Native OpenAI/Anthropic send small *incremental* deltas.
|
||||
* - 9router/gateways re-send the FULL accumulated text as a repeated window:
|
||||
* each "wave" replays the already-emitted segments from the start, then one
|
||||
* fresh token. So the stream is s1,s2,…,sk then s1,s2,…,sk,s_{k+1} then
|
||||
* s1,…,s_{k+1},s_{k+2} … where s_i + … = the final text.
|
||||
*
|
||||
* Naive concatenation doubles the text (garbled output). We track a per-stream
|
||||
* replay cursor `pos` into `accumulated`: a chunk that continues at `pos` (or
|
||||
* restarts at 0, i.e. begins a fresh wave) is a replay → ignore it and advance
|
||||
* the cursor; only a chunk that matches nothing already present is brand-new
|
||||
* content, which we append at the end and reset the cursor.
|
||||
*
|
||||
* @param accumulated current merged text
|
||||
* @param chunk next chunk from the stream
|
||||
* @param msg the message being streamed (holds the replay cursor)
|
||||
* @param isReasoning whether this targets the reasoning buffer or the content
|
||||
* @returns the updated accumulated text
|
||||
*/
|
||||
export function reconcileStream(
|
||||
accumulated: string,
|
||||
chunk: string,
|
||||
msg: ChatMessageDisplay,
|
||||
isReasoning: boolean,
|
||||
): string {
|
||||
if (chunk.length === 0) return accumulated;
|
||||
const posRef = { pos: isReasoning ? msg._posReasoning : msg._posContent };
|
||||
if (accumulated.length === 0) {
|
||||
accumulated = chunk;
|
||||
posRef.pos = chunk.length;
|
||||
writePos();
|
||||
return accumulated;
|
||||
}
|
||||
// 1) A chunk that continues the current replay cursor is a replayed segment
|
||||
// (it already exists earlier in the text) → skip it, advance the cursor.
|
||||
if (
|
||||
posRef.pos > 0 &&
|
||||
posRef.pos < accumulated.length &&
|
||||
chunk.length <= accumulated.length - posRef.pos &&
|
||||
accumulated.startsWith(chunk, posRef.pos)
|
||||
) {
|
||||
posRef.pos += chunk.length;
|
||||
writePos();
|
||||
return accumulated;
|
||||
}
|
||||
// 2) A chunk that matches at the very start begins a NEW replay wave → skip
|
||||
// it, set the cursor to just past the matched prefix.
|
||||
if (accumulated.startsWith(chunk)) {
|
||||
posRef.pos = chunk.length;
|
||||
writePos();
|
||||
return accumulated;
|
||||
}
|
||||
// 3) Otherwise this is brand-new content: append it and reset the cursor so
|
||||
// the next wave starts fresh.
|
||||
accumulated += chunk;
|
||||
posRef.pos = -1;
|
||||
writePos();
|
||||
return accumulated;
|
||||
|
||||
function writePos(): void {
|
||||
if (isReasoning) msg._posReasoning = posRef.pos;
|
||||
else msg._posContent = posRef.pos;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── AppStateRest ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -403,6 +565,10 @@ export interface AppStateRest {
|
||||
turnRunner: TurnRunner | null;
|
||||
/** Abort controller for the in-flight turn (driven by AbortTurn). */
|
||||
turnAbort: AbortController | null;
|
||||
/** Token usage from the most recent turn (surfaced in the status bar). */
|
||||
lastUsage: { tokensIn: number; tokensOut: number } | null;
|
||||
/** Monotonic boot time (ms) used for the status-bar uptime + spinner. */
|
||||
bootedAt: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||
@@ -413,6 +579,7 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
Alt+D Show git diff
|
||||
|
||||
─── Navigation ───
|
||||
↑ / ↓ History browse / Overlay navigate
|
||||
@@ -422,20 +589,32 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||
|
||||
─── Overlays ───
|
||||
/help Show this help
|
||||
/settings Open settings overlay
|
||||
/model Pick provider → model (interactive)
|
||||
/config Settings menu (internet, review, provider)
|
||||
/todo Open tasks (todo) overlay
|
||||
/usage Open usage statistics
|
||||
/bash Open bash jobs overlay
|
||||
/diff Open git diff overlay
|
||||
/plan Open plan overlay
|
||||
/mcp Open MCP server management
|
||||
/model Open model selector
|
||||
/compact Compact conversation
|
||||
/clear Clear transcript
|
||||
/rewind Rewind conversation history
|
||||
|
||||
─── Menus (interactive pickers) ───
|
||||
↑/↓ · j/k Move selection
|
||||
PgUp/PgDn Page Home/End — jump
|
||||
g / G Top / Bottom
|
||||
type a-z Type-to-filter
|
||||
Enter Open / apply
|
||||
Esc Back / close
|
||||
|
||||
─── Editor Mode ───
|
||||
/edit <path> Open file for inline editing
|
||||
Ctrl+S Save changes
|
||||
Esc Dismiss editor`;
|
||||
Esc Dismiss editor
|
||||
|
||||
─── Legend ───
|
||||
● running turn is being executed
|
||||
● idle waiting for input
|
||||
in:/out: token usage of the latest turn`;
|
||||
|
||||
/** Construct fresh TUI state. */
|
||||
export function createTuiState(
|
||||
@@ -474,6 +653,8 @@ export function createTuiState(
|
||||
pendingSubmit: null,
|
||||
turnRunner: null,
|
||||
turnAbort: null,
|
||||
lastUsage: null,
|
||||
bootedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseCommand, applyCommand } from "./command.ts";
|
||||
import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts";
|
||||
import { createTuiState, pushTranscript, makeChatMessage, reconcileStream, type ChatMessageDisplay } from "./state.ts";
|
||||
import { applyAction } from "./action.ts";
|
||||
import { handleKey, decodeKey } from "./controller.ts";
|
||||
import { toControllerKey } from "./ui.tsx";
|
||||
import { modelProviderMenu, modelSubMenu, resolveMenu } from "./menu.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
|
||||
function freshState() {
|
||||
@@ -94,6 +95,38 @@ describe("handleKey", () => {
|
||||
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]);
|
||||
});
|
||||
|
||||
test("autocomplete: partial /mo + Enter completes and runs /model", () => {
|
||||
const s = freshState();
|
||||
for (const c of "/mo") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s);
|
||||
expect(s.input.autocompleteVisible).toBe(true);
|
||||
expect(s.input.autocompleteCandidates).toContain("/model");
|
||||
// A single Enter on a matching autocomplete completes the token AND runs
|
||||
// the command (no unknown-command error, no clear, no double-Enter).
|
||||
const first = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||
expect(s.input.buffer).toBe("/model");
|
||||
expect(s.input.autocompleteVisible).toBe(false);
|
||||
expect(first).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
|
||||
});
|
||||
|
||||
test("autocomplete: full /model hides dropdown so one Enter runs it", () => {
|
||||
const s = freshState();
|
||||
for (const c of "/model") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s);
|
||||
expect(s.input.autocompleteVisible).toBe(false); // exact match → no dropdown
|
||||
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||
expect(actions).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
|
||||
});
|
||||
|
||||
test("autocomplete: /edit path keeps its args on Enter (no clobber)", () => {
|
||||
const s = freshState();
|
||||
for (const c of "/edit src/a.ts") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s);
|
||||
expect(s.input.autocompleteVisible).toBe(false); // /edit matches exactly too
|
||||
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||
// Should dispatch the /edit command with path, preserving the typed args.
|
||||
const opened = actions.map((a) => a.tag);
|
||||
expect(opened).toContain("OpenEditor");
|
||||
expect(s.input.buffer).toBe("/edit src/a.ts");
|
||||
});
|
||||
|
||||
test("Ctrl+Y copies last assistant message", () => {
|
||||
const s = freshState();
|
||||
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
|
||||
@@ -112,6 +145,84 @@ describe("handleKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive menu stack (/model, /config)", () => {
|
||||
test("/model command opens a menu (OpenMenuById → model)", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
const actions = applyCommand(parseCommand("/model"));
|
||||
expect(actions).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
|
||||
for (const a of actions) applyAction(s, a);
|
||||
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
|
||||
expect(frame?.id).toBe("model:provider");
|
||||
expect(frame?.items).toEqual(["claude", "zen", "router"]);
|
||||
expect(s.misc.overlay).toBe("menu");
|
||||
});
|
||||
|
||||
test("/config command opens the settings menu", () => {
|
||||
const s = freshState();
|
||||
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
|
||||
const actions = applyCommand(parseCommand("/config"));
|
||||
applyAction(s, actions[0]!);
|
||||
const frame = s.misc.menuStack[s.misc.menuStack.length - 1];
|
||||
expect(frame?.id).toBe("config:root");
|
||||
expect(frame?.items).toContain("Internet access");
|
||||
expect(frame?.items).toContain("Provider / model");
|
||||
});
|
||||
|
||||
test("arrow/jk navigation moves selection; Home/End/extended wraps", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
applyAction(s, { tag: "OpenMenuById", menu: "model" });
|
||||
applyAction(s, { tag: "MenuDown" });
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(1);
|
||||
applyAction(s, { tag: "MenuEnd" });
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(2);
|
||||
applyAction(s, { tag: "MenuDown" }); // wraps 2 → 0
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(0);
|
||||
applyAction(s, { tag: "MenuHome" });
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(0);
|
||||
const claudeIndex = 0;
|
||||
void claudeIndex;
|
||||
});
|
||||
|
||||
test("provider → model nested flow applies and pops the stack", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
s.settings = { provider: "zen", model: "x" };
|
||||
applyAction(s, { tag: "OpenMenuById", menu: "model" });
|
||||
// The active provider (zen) is pre-selected when the menu opens.
|
||||
const stack = () => s.misc.menuStack;
|
||||
expect(stack().at(-1)?.items[0]).toBe("claude");
|
||||
expect(stack().at(-1)?.selected).toBe(1); // zen
|
||||
// Reuse a tiny helper that mirrors ui.tsx: apply every action from handleKey.
|
||||
const drive = (k: Parameters<typeof handleKey>[0]) => {
|
||||
for (const a of handleKey(k, s)) applyAction(s, a);
|
||||
};
|
||||
// Enter on the active provider → opens its model sub-menu.
|
||||
drive({ code: "Enter", ctrl: false, alt: false });
|
||||
expect(stack().length).toBe(2);
|
||||
expect(stack().at(-1)?.id).toBe("model:zen");
|
||||
expect(stack().at(-1)?.items.length).toBeGreaterThan(0);
|
||||
// Enter on a model row applies zen/model and pops the whole stack.
|
||||
drive({ code: "Enter", ctrl: false, alt: false });
|
||||
expect(s.settings.provider).toBe("zen");
|
||||
expect(s.misc.overlay as unknown as string).toBe("none");
|
||||
});
|
||||
|
||||
test("type-to-filter narrows selection to the first match", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||
applyAction(s, { tag: "OpenMenuById", menu: "model" });
|
||||
handleKey({ code: "Char", value: "r", ctrl: false, alt: false }, s);
|
||||
const frame = s.misc.menuStack.at(-1)!;
|
||||
expect(frame.filter).toBe("r");
|
||||
expect(frame.selected).toBe(2); // "router" is the r match
|
||||
// Backspace clears the filter.
|
||||
handleKey({ code: "Backspace", ctrl: false, alt: false }, s);
|
||||
expect(s.misc.menuStack.at(-1)?.filter).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeKey", () => {
|
||||
test("Enter decodes", () => {
|
||||
expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false });
|
||||
@@ -163,3 +274,137 @@ describe("toControllerKey (OpenTUI adapter)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileStream (9router sliding-window vs incremental)", () => {
|
||||
function msg(): ChatMessageDisplay {
|
||||
return makeChatMessage(Roles.Assistant, "");
|
||||
}
|
||||
function rec(
|
||||
s: string,
|
||||
chunk: string,
|
||||
m: ChatMessageDisplay,
|
||||
): string {
|
||||
return reconcileStream(s, chunk, m, false);
|
||||
}
|
||||
|
||||
test("incremental deltas append in order", () => {
|
||||
const m = msg();
|
||||
let s = rec("", "Hello", m);
|
||||
s = rec(s, "!", m);
|
||||
s = rec(s, " How", m);
|
||||
expect(s).toBe("Hello! How");
|
||||
});
|
||||
|
||||
test("real 9router wave: replayed segments never double", () => {
|
||||
// Real captured stream (story turn), each fresh token restarts a replay
|
||||
// wave from the head: "Here's a 3-sent" "ence story" " about" " a robo" …
|
||||
const m = msg();
|
||||
const segs: string[] = [
|
||||
"Here's a 3-sent",
|
||||
"ence story",
|
||||
" about",
|
||||
" a robo",
|
||||
"t:\n\nA small repai",
|
||||
"r robo",
|
||||
"t name",
|
||||
"d Bolt",
|
||||
];
|
||||
// Wave 1 (first two segments).
|
||||
let s = "";
|
||||
s = rec(s, segs[0]!, m);
|
||||
s = rec(s, segs[1]!, m);
|
||||
expect(s).toBe("Here's a 3-sentence story");
|
||||
// Wave 2 replays seg0..seg1 then new seg2.
|
||||
for (const g of segs.slice(0, 3)) s = rec(s, g, m);
|
||||
expect(s).toBe("Here's a 3-sentence story about");
|
||||
// Wave 3 replays seg0..seg2 then new seg3.
|
||||
for (const g of segs.slice(0, 4)) s = rec(s, g, m);
|
||||
expect(s).toBe("Here's a 3-sentence story about a robo");
|
||||
// Wave 4 replays 0..3 then new seg4.
|
||||
for (const g of segs.slice(0, 5)) s = rec(s, g, m);
|
||||
expect(s).toBe("Here's a 3-sentence story about a robot:\n\nA small repai");
|
||||
// Wave 5 replays 0..4 then new seg5.
|
||||
for (const g of segs.slice(0, 6)) s = rec(s, g, m);
|
||||
expect(s).toBe(
|
||||
"Here's a 3-sentence story about a robot:\n\nA small repair robo",
|
||||
);
|
||||
// Wave 6 replays 0..5 then new seg6.
|
||||
for (const g of segs.slice(0, 7)) s = rec(s, g, m);
|
||||
// Final wave replays everything, no new content.
|
||||
for (const g of segs.slice(0, 8)) s = rec(s, g, m);
|
||||
expect(s).toBe(
|
||||
"Here's a 3-sentence story about a robot:\n\nA small repair robot named Bolt",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty chunk is a no-op", () => {
|
||||
const m = msg();
|
||||
expect(rec("abc", "", m)).toBe("abc");
|
||||
});
|
||||
|
||||
test("1-char shared letter is NOT treated as a replay", () => {
|
||||
// "Hi! " ends with a space; "How…" starts with 'H' — must append fully.
|
||||
const m = msg();
|
||||
let s = rec("", "Hi! ", m);
|
||||
s = rec(s, "How can I help you today?", m);
|
||||
expect(s).toBe("Hi! How can I help you today?");
|
||||
});
|
||||
|
||||
test("'say hi' captured sequence resolves cleanly", () => {
|
||||
const m = msg();
|
||||
let s = "";
|
||||
// Real captured: "", "", "Hi! 👋 ", "", "", "Hi! 👋 ", "How can I help you today?"
|
||||
s = rec(s, "Hi! 👋 ", m);
|
||||
s = rec(s, "Hi! 👋 ", m); // replay of head
|
||||
s = rec(s, "How can I help you today?", m);
|
||||
expect(s).toBe("Hi! 👋 How can I help you today?");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("menu model lists (live vs fallback)", () => {
|
||||
test("provider menu lists configured providers", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { zen: {}, router: {}, claude: {} } };
|
||||
s.settings = { provider: "router" };
|
||||
const frame = modelProviderMenu(s);
|
||||
expect(frame.id).toBe("model:provider");
|
||||
expect(frame.items).toEqual(["zen", "router", "claude"].map(() => expect.anything()).map((_, i) => ["zen","router","claude"][i]!));
|
||||
expect(frame.selected).toBe(1); // router active
|
||||
});
|
||||
|
||||
test("model sub-menu uses live /models list when available", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = ["gpt-5", "claude-sonnet-5", "deepseek-v3"];
|
||||
s.settings = { provider: "zen", model: "claude-sonnet-5" };
|
||||
const frame = modelSubMenu(s, "zen")!;
|
||||
expect(frame.items).toEqual(["gpt-5", "claude-sonnet-5", "deepseek-v3"]);
|
||||
expect(frame.selected).toBe(1); // current model highlighted
|
||||
expect(frame.title).toContain("(3)");
|
||||
});
|
||||
|
||||
test("model sub-menu falls back to a static catalog without a live list", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = [];
|
||||
s.settings = { provider: "zen", model: "" };
|
||||
const frame = modelSubMenu(s, "zen")!;
|
||||
expect(frame.items.length).toBeGreaterThan(0);
|
||||
expect(frame.hint).toContain("fallback");
|
||||
});
|
||||
|
||||
test("resolving a live model row applies it and pops the stack", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = ["gpt-5", "claude-sonnet-5"];
|
||||
s.appConfig = { providers: { zen: {} } };
|
||||
s.settings = { provider: "zen", model: "claude-sonnet-5" };
|
||||
applyAction(s, { tag: "OpenMenu", frame: modelProviderMenu(s) });
|
||||
applyAction(s, { tag: "OpenMenu", frame: modelSubMenu(s, "zen")! });
|
||||
// The sub-menu pre-selects the CURRENT model (claude-sonnet-5, index 1).
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(1);
|
||||
// Move to index 0 (gpt-5), then resolve Enter for the model row.
|
||||
applyAction(s, { tag: "MenuHome" });
|
||||
const acts = resolveMenu(s, "model:zen");
|
||||
expect(acts).toEqual([{ tag: "MenuBackAll" }]);
|
||||
expect(s.settings.provider).toBe("zen");
|
||||
expect(s.settings.model).toBe("gpt-5");
|
||||
});
|
||||
});
|
||||
|
||||
+753
-110
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* OpenTUI React bindings for the Zesdex TUI.
|
||||
*
|
||||
* Renders `AppStateRest` as an OpenTUI layout and routes keyboard input
|
||||
* through the existing `handleKey` → `applyAction` pipeline. This replaces
|
||||
* the earlier raw-ANSI line renderer with a proper declarative OpenTUI tree.
|
||||
* Renders `AppStateRest` as a polished OpenTUI layout and routes keyboard
|
||||
* input through the existing `handleKey` → `applyAction` pipeline.
|
||||
*
|
||||
* Layout (column flex over the full terminal):
|
||||
* 1. Header bar — logo · model · session · live turn spinner
|
||||
* 2. Transcript — role-coloured chat scrollbox (markdown-lite, flex-grow)
|
||||
* 3. Activity bar — collapsed breadcrumb of the running turn's tools/agents
|
||||
* 4. Input row — `❯` prompt + buffer + cursor
|
||||
* 5. Status bar — turn state · token usage · uptime · toast · hints
|
||||
*
|
||||
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
||||
* is untouched and remains unit-tested.
|
||||
* stays logic-pure and unit-tested; this file only reads it for display.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { createCliRenderer } from "@opentui/core";
|
||||
@@ -18,7 +24,7 @@ import {
|
||||
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { AppStateRest } from "./state.ts";
|
||||
import type { AppStateRest, ChatMessageDisplay } from "./state.ts";
|
||||
import { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
import { handleKey, KeyCode } from "./controller.ts";
|
||||
@@ -66,6 +72,10 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
|
||||
case "pagedown":
|
||||
case "pgdn":
|
||||
return { code: KeyCode.PageDown, ctrl, alt };
|
||||
case "home":
|
||||
return { code: KeyCode.Home, ctrl, alt };
|
||||
case "end":
|
||||
return { code: KeyCode.End, ctrl, alt };
|
||||
case "escape":
|
||||
case "esc":
|
||||
return { code: KeyCode.Esc, ctrl, alt };
|
||||
@@ -79,15 +89,241 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Role label mapped to a colour for the transcript pane. */
|
||||
const roleColor: Record<string, string> = {
|
||||
[Roles.User]: "cyan",
|
||||
[Roles.Assistant]: "green",
|
||||
[Roles.System]: "yellow",
|
||||
[Roles.Tool]: "magenta",
|
||||
/* ── Palette ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** Zesdex brand colours — a calm, readable "deep ocean" scheme. */
|
||||
const C = {
|
||||
bg: "#0d1117", // deep off-navy transcript ground
|
||||
surface: "#161b22", // panel / secondary background
|
||||
border: "#2a3340", // dim hairline separators
|
||||
fg: "#c9d1d9", // primary foreground
|
||||
dim: "#6e7681", // muted text
|
||||
cyan: "#58a6ff", // user / accents
|
||||
green: "#3fb950", // assistant
|
||||
yellow: "#d29922", // system
|
||||
magenta: "#bc8cff", // agent / tools
|
||||
red: "#f85149", // errors
|
||||
headerA: "#0d47a1", // header gradient start (deep blue)
|
||||
headerB: "#1f3a5f", // header gradient end
|
||||
accent: "#58a6ff", // prompt / highlight
|
||||
} as const;
|
||||
|
||||
/** Short uppercase role tag shown before each message line. */
|
||||
const roleTag: Record<string, string> = {
|
||||
[Roles.User]: "YOU",
|
||||
[Roles.Assistant]: "AI",
|
||||
[Roles.System]: "SYS",
|
||||
[Roles.Tool]: "TOOL",
|
||||
};
|
||||
|
||||
const oneLine = (s: string): string => s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
/** Distinct colour per role — used for badges + body tint. */
|
||||
const roleColor: Record<string, string> = {
|
||||
[Roles.User]: C.cyan,
|
||||
[Roles.Assistant]: C.green,
|
||||
[Roles.System]: C.yellow,
|
||||
[Roles.Tool]: C.magenta,
|
||||
};
|
||||
|
||||
/** Column-align a pair of header cells across the full width. */
|
||||
function spreadCenter(left: string, right: string, width: number): string {
|
||||
const gap = Math.max(1, width - left.length - right.length);
|
||||
return left + " ".repeat(gap) + right;
|
||||
}
|
||||
|
||||
/** Pad a string to at least `n` chars, clipping if longer. */
|
||||
function pad(s: string, n: number): string {
|
||||
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
||||
}
|
||||
|
||||
/* ── Spinner ──────────────────────────────────────────────────────── */
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/** Pick a spinner glyph that animates with the tick counter. */
|
||||
function spinner(state: AppStateRest): string {
|
||||
const frame = Math.floor(state.misc.tickCount / 2) % SPINNER_FRAMES.length;
|
||||
return SPINNER_FRAMES[frame] ?? "⠋";
|
||||
}
|
||||
|
||||
/* ── Markdown-lite rendering ──────────────────────────────────────── */
|
||||
|
||||
/** One styled run of text within a wrapped line. */
|
||||
interface Run {
|
||||
text: string;
|
||||
fg?: string;
|
||||
bold?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a modest Markdown subset into styled runs, then word-wrap them to a
|
||||
* fixed width with a left indent. Keeps layout fully under our control (vs the
|
||||
* self-drawing `<markdown>` node) so streaming + indent stay stable.
|
||||
*
|
||||
* Supported: **bold**, `inline code`, #headers, - bullets, > quotes, tables
|
||||
* are untouched (rare in agent output). Code fences are rendered as
|
||||
* single-colour blocks.
|
||||
*/
|
||||
function renderMarkdown(text: string, indent: number, width: number): Run[][] {
|
||||
const rows: Run[][] = [];
|
||||
const bodyWidth = Math.max(10, width - indent);
|
||||
const wrap = new Wrap(bodyWidth);
|
||||
|
||||
for (const raw of stripFences(text).split("\n")) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
if (line.trim().length === 0) {
|
||||
wrap.break();
|
||||
continue;
|
||||
}
|
||||
if (/^#{1,6}\s+/.test(line)) {
|
||||
const seg: Run = { text: line.replace(/^#{1,6}\s+/, "").trim(), bold: true, fg: C.cyan };
|
||||
wrap.add([seg]);
|
||||
continue;
|
||||
}
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
// Bullet: prefix glyph, then inline-parse the body so **bold**/`code`
|
||||
// inside list items render too.
|
||||
wrap.add([{ text: "• ", fg: C.fg }, ...inlineRuns(line.replace(/^[-*]\s+/, ""))]);
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
wrap.add([{ text: "▍ ", fg: C.dim }, ...inlineRuns(line.replace(/^>\s?/, ""))]);
|
||||
continue;
|
||||
}
|
||||
const runs = inlineRuns(line);
|
||||
wrap.add(runs);
|
||||
}
|
||||
// Flatten wrapped rows into the output.
|
||||
for (const row of wrap.rows()) {
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Render plain text (user / system / tool bodies) with wrapping + indent. */
|
||||
function renderPlain(text: string, indent: number, width: number): Run[][] {
|
||||
const bodyWidth = Math.max(10, width - indent);
|
||||
const wrap = new Wrap(bodyWidth);
|
||||
for (const raw of text.split("\n")) {
|
||||
wrap.add([{ text: raw.replace(/\s+$/, "") }]);
|
||||
}
|
||||
return wrap.rows();
|
||||
}
|
||||
|
||||
/** A line-oriented wrapper that accumulates runs and breaks on width. */
|
||||
class Wrap {
|
||||
private out: Run[][] = [];
|
||||
private cur: Run[] = [];
|
||||
private curLen = 0;
|
||||
constructor(private width: number) {}
|
||||
add(runs: Run[]): void {
|
||||
for (const r of runs) this.pushRun(r);
|
||||
this.break();
|
||||
}
|
||||
private pushRun(r: Run): void {
|
||||
if (this.curLen + r.text.length > this.width && this.cur.length > 0) {
|
||||
this.break();
|
||||
}
|
||||
if (r.text.length === 0) return;
|
||||
this.cur.push(r);
|
||||
this.curLen += r.text.length;
|
||||
}
|
||||
break(): void {
|
||||
if (this.cur.length > 0) {
|
||||
this.out.push(this.cur);
|
||||
this.cur = [];
|
||||
this.curLen = 0;
|
||||
}
|
||||
}
|
||||
rows(): Run[][] {
|
||||
this.break();
|
||||
return this.out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokenise a line into bold / inline-code / plain runs. */
|
||||
function inlineRuns(line: string): Run[] {
|
||||
const runs: Run[] = [];
|
||||
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m.index > last) runs.push({ text: line.slice(last, m.index) });
|
||||
const tok = m[0];
|
||||
if (tok.startsWith("**")) {
|
||||
runs.push({ text: tok.slice(2, -2), bold: true, fg: C.fg });
|
||||
} else {
|
||||
runs.push({ text: tok.slice(1, -1), fg: C.magenta });
|
||||
}
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < line.length) runs.push({ text: line.slice(last) });
|
||||
return runs.length ? runs : [{ text: line }];
|
||||
}
|
||||
|
||||
/** Neutralise fenced code blocks so inline parsing stays clean (kept as plain). */
|
||||
function stripFences(text: string): string {
|
||||
return text.replace(/```[a-zA-Z0-9]*\n?/g, "");
|
||||
}
|
||||
|
||||
/* ── Transcript rendering ─────────────────────────────────────────── */
|
||||
|
||||
/** One chat message rendered in the transcript. */
|
||||
function TranscriptMessage(props: {
|
||||
msg: ChatMessageDisplay;
|
||||
width: number;
|
||||
}): ReactNode {
|
||||
const { msg, width } = props;
|
||||
const color = roleColor[msg.role] ?? C.fg;
|
||||
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
|
||||
const isAssistant = msg.role === Roles.Assistant;
|
||||
// Assistant messages legitimately have no body while a tool call is being
|
||||
// made (or mid-stream) — don't fill that with a misleading placeholder.
|
||||
const content =
|
||||
isAssistant && !msg.content.trim() ? "" : msg.content || "(empty)";
|
||||
|
||||
const rows: Run[][] =
|
||||
msg.role === Roles.Assistant && content.length > 0
|
||||
? renderMarkdown(content, 5, width)
|
||||
: renderPlain(content, 5, width);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" width="100%">
|
||||
{/* Role badge header line */}
|
||||
<text fg={color}>
|
||||
<b>{` ${pad(`[${tag}]`, 6)}`}</b>
|
||||
</text>
|
||||
{/* Body — each wrapped line is a <text> whose styled runs are <span>/<b> */}
|
||||
{rows.map((row, i) => (
|
||||
<text key={i} fg={C.fg}>
|
||||
{row.map((r, j) => {
|
||||
const text = r.text;
|
||||
if (r.bold) {
|
||||
return (
|
||||
<b key={j} fg={r.fg ?? color}>
|
||||
{text}
|
||||
</b>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={j} fg={r.fg ?? color}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</text>
|
||||
))}
|
||||
{/* Live reasoning (dimmed, kept to one line) */}
|
||||
{msg.reasoning.length > 0 && (
|
||||
<text fg={C.dim}>{` ⋯ ${oneLine(msg.reasoning)}`}</text>
|
||||
)}
|
||||
{/* Thin separator between messages */}
|
||||
<text fg={C.border}>{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
const oneLine = (s: string): string =>
|
||||
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
|
||||
|
||||
/**
|
||||
* Root OpenTUI component for the Zesdex TUI.
|
||||
@@ -101,23 +337,42 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
||||
useKeyboard((key: OpenTuiKey) => {
|
||||
if (key.eventType === "release") return;
|
||||
const zk = toControllerKey(key);
|
||||
let consumedByModal = false;
|
||||
try {
|
||||
const actions: Action[] = handleKey(zk, state);
|
||||
for (const act of actions) {
|
||||
if (act.tag === "QuitConfirm") {
|
||||
state.quit = true;
|
||||
onQuit();
|
||||
consumedByModal = true;
|
||||
continue;
|
||||
}
|
||||
// A slash-command that opens a modal (overlay / menu) "consumes" the
|
||||
// input line — clear the buffer so `/model` doesn't linger. Autocomplete
|
||||
// selection and normal submit must NOT be cleared (they own the buffer).
|
||||
const opensModal =
|
||||
act.tag === "OpenOverlay" ||
|
||||
act.tag === "OpenMenuById" ||
|
||||
act.tag === "OpenEditor" ||
|
||||
act.tag === "ShowDiff" ||
|
||||
act.tag === "ModelList";
|
||||
if (opensModal && !state.input.autocompleteVisible) consumedByModal = true;
|
||||
applyAction(state, act);
|
||||
}
|
||||
} finally {
|
||||
// Clear the input line only when a modal command consumed it this press.
|
||||
if (consumedByModal) {
|
||||
state.input.clear();
|
||||
}
|
||||
// Force React to re-read the mutated `state` on the next frame.
|
||||
setTick((t: number) => t + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const dimensions = useTerminalDimensionsHook();
|
||||
const width = dimensions.width ?? 80;
|
||||
const height = dimensions.height ?? 24;
|
||||
const running = state.turnInFlightFlag.value;
|
||||
|
||||
// Turn driver: when a turn is submitted, run it against the real agent and
|
||||
// stream every event into the transcript as it happens.
|
||||
@@ -147,137 +402,524 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
||||
pushTranscript(state, makeChatMessage(Roles.Assistant, ""));
|
||||
|
||||
try {
|
||||
await state.turnRunner(captured ?? text, (ev) => {
|
||||
switch (ev.kind) {
|
||||
case "stream_token":
|
||||
appendToLastTranscript(state, ev.content, false);
|
||||
break;
|
||||
case "stream_reasoning":
|
||||
appendToLastTranscript(state, ev.content, true);
|
||||
break;
|
||||
case "system_note":
|
||||
pushTranscript(
|
||||
state,
|
||||
makeChatMessage(Roles.System, `[${ev.systemKind}] ${ev.message}`),
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`));
|
||||
break;
|
||||
case "tool_result":
|
||||
pushTranscript(
|
||||
state,
|
||||
makeChatMessage(Roles.Tool, `⇄ ${ev.tool_name} · ${ev.is_error ? "err" : "ok"}`),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await state.turnRunner(captured ?? text, () => {
|
||||
// The turn runner already streams into state via handleEvent;
|
||||
// re-render as new tokens arrive.
|
||||
setTick((t: number) => t + 1);
|
||||
});
|
||||
} finally {
|
||||
state.turnInFlightFlag.value = false;
|
||||
state.turnAbort = null;
|
||||
state.abortFlag.value = false;
|
||||
// Re-prime the next poll so queued turns run.
|
||||
// Keep the last activity breadcrumb visible briefly, then clear it on
|
||||
// the next turn start (see the start of poll()).
|
||||
setTimeout(() => {
|
||||
state.misc.activity = [];
|
||||
turnRunning = false;
|
||||
setTick((x: number) => x + 1);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => poll(), 120);
|
||||
const timer = setInterval(() => poll(), 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [state, setTick]);
|
||||
|
||||
const model = String(state.settings.provider ?? "?");
|
||||
const header = ` zesdex · ${model} `;
|
||||
const messages = state.transcriptCache.messages;
|
||||
const scrollFromEnd = messages.length - state.scroll.offset;
|
||||
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
||||
// Tick the spinner + uptime while idle too (so ● running animates and the
|
||||
// uptime clock in the status bar advances even between key presses).
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(() => {
|
||||
state.misc.tickCount += 1;
|
||||
state.dirty = true;
|
||||
setTick((t: number) => t + 1);
|
||||
}, 120);
|
||||
return () => clearInterval(id);
|
||||
}, [running, state, setTick]);
|
||||
|
||||
const overlayActive = state.misc.overlay !== "none";
|
||||
const menuActive = state.misc.menuStack.length > 0;
|
||||
|
||||
// Modal overlays and interactive menus take the full body (below the header,
|
||||
// above the status bar), replacing the transcript + input.
|
||||
const body = menuActive ? (
|
||||
<MenuView state={state} height={height} />
|
||||
) : overlayActive ? (
|
||||
<OverlayView state={state} width={width} height={height} />
|
||||
) : (
|
||||
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
||||
<scrollbox
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
minHeight={0}
|
||||
style={{ backgroundColor: C.bg }}
|
||||
stickyScroll
|
||||
stickyStart="bottom"
|
||||
>
|
||||
<TranscriptView state={state} width={width} />
|
||||
</scrollbox>
|
||||
{/* Dropdown (compact, sized to content) + activity + input, in order. The
|
||||
input row is flexShrink:0 so the transcript scrollbox absorbs overflow
|
||||
and the text area is never hidden. */}
|
||||
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
||||
<ActivityBar state={state} running={running} />
|
||||
<InputView state={state} width={width} />
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" width={dimensions.width} height={dimensions.height}>
|
||||
<box flexDirection="column" width={width} height={height}>
|
||||
{/* Header */}
|
||||
<box width={dimensions.width} backgroundColor="blue">
|
||||
<text fg="white">{header}</text>
|
||||
</box>
|
||||
<HeaderView state={state} running={running} width={width} />
|
||||
{body}
|
||||
{/* Status bar */}
|
||||
<StatusBar state={state} running={running} width={width} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Transcript pane */}
|
||||
<scrollbox flexGrow={1} width={dimensions.width} style={{ backgroundColor: "#0b0f14" }}>
|
||||
{visible.map((m, i) => (
|
||||
<text key={`${m.timestamp}-${i}`} fg={roleColor[m.role] ?? "white"}>
|
||||
{`${(m.role ?? "?").padEnd(9)} `}
|
||||
{oneLine(m.content)}
|
||||
</text>
|
||||
))}
|
||||
{/* Reasoning block for the last assistant message (dimmed). */}
|
||||
{messages.length > 0 &&
|
||||
(() => {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.role === Roles.Assistant && last.reasoning.length > 0) {
|
||||
return (
|
||||
<text fg="gray">
|
||||
{`reasoning ${oneLine(last.reasoning)}`}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</scrollbox>
|
||||
/* ── Header ───────────────────────────────────────────────────────── */
|
||||
|
||||
{/* Overlay or input area */}
|
||||
{state.misc.overlay !== "none" ? <OverlayView state={state} /> : <InputView state={state} />}
|
||||
|
||||
{/* Status footer */}
|
||||
<text fg={state.turnInFlightFlag.value ? "green" : "gray"}>
|
||||
{state.turnInFlightFlag.value ? "● running" : "● idle"}
|
||||
function HeaderView(props: {
|
||||
state: AppStateRest;
|
||||
running: boolean;
|
||||
width: number;
|
||||
}): ReactNode {
|
||||
const { state, running, width } = props;
|
||||
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
||||
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
||||
const shortId = sessionId.length > 12 ? sessionId.slice(0, 11) : sessionId;
|
||||
const glyph = running ? spinner(state) : "◆";
|
||||
const left = ` ${glyph} zesdex · ${model} `;
|
||||
const right = sessionId ? ` session:${shortId} ` : " ";
|
||||
const header = spreadCenter(left, right, width);
|
||||
const bg = running ? C.headerA : C.headerB;
|
||||
return (
|
||||
<box backgroundColor={bg}>
|
||||
<text fg={running ? C.green : "white"}>
|
||||
<b>{header}</b>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function InputView(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const shown = state.input.buffer.slice(
|
||||
Math.max(0, state.input.cursor - 40),
|
||||
state.input.cursor + 20,
|
||||
/* ── Transcript ───────────────────────────────────────────────────── */
|
||||
|
||||
function TranscriptView(props: { state: AppStateRest; width: number }): ReactNode {
|
||||
const { state, width } = props;
|
||||
const messages = state.transcriptCache.messages;
|
||||
const scrollFromEnd = messages.length - state.scroll.offset;
|
||||
const visible = messages.slice(
|
||||
Math.max(0, scrollFromEnd - 40),
|
||||
scrollFromEnd,
|
||||
);
|
||||
const hint =
|
||||
state.input.autocompleteVisible &&
|
||||
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
||||
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
||||
: "";
|
||||
const toast =
|
||||
state.misc.toasts.length > 0
|
||||
? ` (${state.misc.toasts[state.misc.toasts.length - 1]?.message})`
|
||||
: "";
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<text fg={C.dim}>
|
||||
{`\n ─ no messages yet. Type a message below (or /help) ─`}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column" width="100%">
|
||||
<text fg="cyan">{"> "}</text>
|
||||
<text>{shown}</text>
|
||||
<text fg="gray">{hint}{toast}</text>
|
||||
<box flexDirection="column">
|
||||
{visible.map((m, i) => (
|
||||
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const name = state.misc.overlay;
|
||||
let body = "";
|
||||
if (name === "quit_confirm") body = "Quit? (y=yes, n=no) Ctrl+C again / /quit to confirm";
|
||||
else if (name === "help") body = state.helpText;
|
||||
else if (name === "diff")
|
||||
body = state.misc.diffContent
|
||||
.split("\n")
|
||||
.slice(state.misc.diffScroll, state.misc.diffScroll + 20)
|
||||
.join("\n");
|
||||
else body = `Overlay: ${name} (Esc to close)`;
|
||||
/* ── Activity bar (running turn) ──────────────────────────────────── */
|
||||
|
||||
function ActivityBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
||||
const { state, running } = props;
|
||||
const activity = state.misc.activity;
|
||||
if (!running || activity.length === 0) return null;
|
||||
const line = ` ${spinner(state)} ${activity.map((a) => label(a)).join(" · ")} `;
|
||||
return (
|
||||
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
|
||||
<text fg="yellow">{body.split("\n").map((l, i) => (<text key={i}>{l}</text>))}</text>
|
||||
<text fg="gray">{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}</text>
|
||||
<box flexDirection="column" width="100%" style={{ backgroundColor: C.surface }}>
|
||||
<text fg={C.magenta}>
|
||||
{line}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
function label(a: { label: string; kind: string; detail?: string; count?: number }): string {
|
||||
const sym = a.kind === "error" ? "✕" : a.kind === "agent" ? "⧉" : "⚙";
|
||||
const n = a.count && a.count > 1 ? `×${a.count}` : "";
|
||||
return `${sym} ${a.label}${n}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Input row ────────────────────────────────────────────────────── */
|
||||
|
||||
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
||||
const { state, width } = props;
|
||||
const buffer = state.input.buffer;
|
||||
const cursor = state.input.cursor;
|
||||
const shown = buffer.slice(
|
||||
Math.max(0, cursor - Math.max(10, width - 14)),
|
||||
cursor + 40,
|
||||
);
|
||||
const hasContent = buffer.length > 0;
|
||||
return (
|
||||
<box flexDirection="column" width="100%" flexShrink={0}>
|
||||
<text fg={C.border}>{`─`.repeat(Math.max(0, width))}</text>
|
||||
<box flexDirection="row" width="100%">
|
||||
<text fg={C.accent}>
|
||||
<b>{` ❯ `}</b>
|
||||
</text>
|
||||
{hasContent ? (
|
||||
<text fg={C.fg}>{shown}</text>
|
||||
) : (
|
||||
<text fg={C.dim}>Ask Zesdex to help…</text>
|
||||
)}
|
||||
<text fg={C.accent}>{` ▏`}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Autocomplete ─────────────────────────────────────────────────── */
|
||||
|
||||
function AutocompleteView(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const candidates = state.input.autocompleteCandidates;
|
||||
const kind = state.input.autocompleteKind;
|
||||
const title = kind === "command" ? "commands" : "files";
|
||||
const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1);
|
||||
const visible = candidates.slice(0, 10);
|
||||
// Render the whole panel as ONE multiline <text> — this fixes the OpenTUI
|
||||
// quirk where sibling <text> children overlap. The box is sized to its
|
||||
// content (flexShrink 0, explicit height) so it never squashes the input row.
|
||||
const lines = [
|
||||
` ${title}`,
|
||||
...visible.map((c, i) => (i === selected ? `▸ ${c}` : ` ${c}`)),
|
||||
` ↑/↓ navigate · Tab accept · Esc close`,
|
||||
];
|
||||
const body = lines.join("\n");
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
style={{ border: true, borderColor: C.border, backgroundColor: C.surface, padding: 1 }}
|
||||
>
|
||||
<text fg={kind === "command" ? C.accent : C.magenta} height={lines.length}>
|
||||
{body}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Overlays ─────────────────────────────────────────────────────── */
|
||||
|
||||
function OverlayView(props: {
|
||||
state: AppStateRest;
|
||||
width: number;
|
||||
height: number;
|
||||
}): ReactNode {
|
||||
const { state } = props;
|
||||
const name = state.misc.overlay;
|
||||
|
||||
switch (name) {
|
||||
case "quit_confirm":
|
||||
return (
|
||||
<ConfirmOverlay
|
||||
title="Quit Zesdex?"
|
||||
body=" Are you sure you want to quit?\n\n Any in-flight turn will be aborted."
|
||||
hint="y=yes n=no Esc=cancel"
|
||||
/>
|
||||
);
|
||||
case "clear_confirm":
|
||||
return (
|
||||
<ConfirmOverlay
|
||||
title="Clear transcript?"
|
||||
body=" This clears the visible conversation from the transcript view."
|
||||
hint="y=yes n=no Esc=cancel"
|
||||
/>
|
||||
);
|
||||
case "help":
|
||||
return <HelpOverlay state={state} />;
|
||||
case "diff":
|
||||
return <DiffOverlay state={state} />;
|
||||
case "model_selector":
|
||||
return <ModelSelectorOverlay state={state} />;
|
||||
case "editor":
|
||||
return <EditorOverlay state={state} />;
|
||||
default:
|
||||
return <PlaceholderOverlay name={name} state={state} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode {
|
||||
const { title, body, hint } = props;
|
||||
return (
|
||||
<OverlayFrame title={title}>
|
||||
<text fg={C.fg}>{body}</text>
|
||||
<text fg={C.dim}>{` ${hint}`}</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpOverlay(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
return (
|
||||
<OverlayFrame title="Keyboard Shortcuts">
|
||||
<text fg={C.fg}>{state.helpText}</text>
|
||||
<text fg={C.dim}>{" Esc or /help to close"}</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffOverlay(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const lines = state.misc.diffContent.split("\n");
|
||||
const start = state.misc.diffScroll;
|
||||
const slice = lines.slice(start, start + 18);
|
||||
return (
|
||||
<OverlayFrame title="Git Diff">
|
||||
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
|
||||
{slice.map((l, i) => {
|
||||
const fg = l.startsWith("+")
|
||||
? C.green
|
||||
: l.startsWith("-")
|
||||
? C.red
|
||||
: l.startsWith("@@")
|
||||
? C.cyan
|
||||
: C.dim;
|
||||
return (
|
||||
<text key={i} fg={fg}>
|
||||
{l.length ? l : " "}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</scrollbox>
|
||||
<text fg={C.dim}>
|
||||
{` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`}
|
||||
</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSelectorOverlay(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const names = Object.keys(state.appConfig.providers);
|
||||
const list = names.length ? names : ["claude", "zen", "router"];
|
||||
const idx = Math.min(Math.max(0, state.misc.selectedIndex), list.length - 1);
|
||||
const current = String(state.settings.provider ?? "");
|
||||
const selected = idx;
|
||||
const body = list
|
||||
.map((p, i) => {
|
||||
const isSel = i === selected;
|
||||
const active = p === current;
|
||||
const suffix = active ? " (active)" : "";
|
||||
return `${isSel ? "▸ " : " "}${pad(p, 15)}${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
return (
|
||||
<OverlayFrame title="Select Model / Provider">
|
||||
<text fg={C.fg}>
|
||||
{body}
|
||||
</text>
|
||||
<text fg={C.dim}>{" ↑/↓ select · Enter apply · Esc close"}</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorOverlay(props: { state: AppStateRest }): ReactNode {
|
||||
const { state } = props;
|
||||
const ed = state.misc.editor;
|
||||
if (!ed) return <OverlayFrame title="Editor"><text fg={C.fg}>No buffer.</text></OverlayFrame>;
|
||||
const lines = ed.content.split("\n");
|
||||
const start = Math.max(0, ed.cursor - 5);
|
||||
const slice = lines.slice(start, start + 12);
|
||||
return (
|
||||
<OverlayFrame title={`Edit ${ed.path}`}>
|
||||
{slice.map((l, i) => (
|
||||
<text key={i} fg={C.fg}>
|
||||
{`${String(start + i + 1).padStart(3)} | ${l}`}
|
||||
</text>
|
||||
))}
|
||||
<text fg={C.dim}>
|
||||
{" type to edit · Enter newline · Esc save & close"}
|
||||
</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderOverlay(props: { name: string; state: AppStateRest }): ReactNode {
|
||||
const { name, state } = props;
|
||||
const content =
|
||||
name === "todo"
|
||||
? state.misc.todoContent
|
||||
: name === "plan"
|
||||
? state.misc.planContent
|
||||
: "";
|
||||
const summary =
|
||||
name === "todo" ? "todo list" :
|
||||
name === "plan" ? "plan outline" :
|
||||
name === "usage" ? "usage statistics" :
|
||||
name === "mcp" ? "MCP server management" :
|
||||
name === "settings" ? "settings" :
|
||||
name === "bash" ? "bash jobs" :
|
||||
name === "rewind" ? "rewind history" :
|
||||
name === "learning" ? "learned patterns" :
|
||||
"overlay";
|
||||
return (
|
||||
<OverlayFrame title={`${name} — ${summary}`}>
|
||||
{content ? (
|
||||
<text fg={C.fg}>{content}</text>
|
||||
) : (
|
||||
<text fg={C.dim}>{` ${summary}: nothing yet.`}</text>
|
||||
)}
|
||||
<text fg={C.dim}>{" Esc to close"}</text>
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode {
|
||||
const { title, children } = props;
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
style={{
|
||||
border: true,
|
||||
borderColor: C.accent,
|
||||
padding: 1,
|
||||
backgroundColor: C.surface,
|
||||
}}
|
||||
title={title}
|
||||
titleAlignment="center"
|
||||
>
|
||||
{children}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Interactive menu ─────────────────────────────────────────────── */
|
||||
|
||||
/** Modal picker for the menu stack — like Claude Code / opencode selectors. */
|
||||
function MenuView(props: { state: AppStateRest; height: number }): ReactNode {
|
||||
const { state, height } = props;
|
||||
const stack = state.misc.menuStack;
|
||||
const frame = stack[stack.length - 1];
|
||||
if (!frame) return null;
|
||||
const filter = (frame.filter ?? "").toLowerCase();
|
||||
// Rows that match the live filter (empty filter = all rows).
|
||||
const rawMatched = frame.items
|
||||
.map((it, i) => ({ it, i }))
|
||||
.filter((r) => !filter || r.it.toLowerCase().includes(filter));
|
||||
// Fit the visible rows to the modal height (top rows take priority so the
|
||||
// selected row stays anchored near the top).
|
||||
const maxRows = Math.max(4, height - 8);
|
||||
const visibleStart = Math.max(0, Math.min(rawMatched.length - maxRows, frame.selected));
|
||||
const matched = rawMatched.slice(visibleStart, visibleStart + maxRows);
|
||||
const detailOf = frame.details;
|
||||
|
||||
// Breadcrumb: titles from the stack, e.g. "Model › pick a model (zen)".
|
||||
const crumb = stack.map((f) => f.title).join(" › ");
|
||||
|
||||
return (
|
||||
<OverlayFrame title={crumb}>
|
||||
{/* Quick filter bar at the top */}
|
||||
<text fg={C.dim}>{filter ? ` filter: ${frame.filter} ` : " type to filter…"}</text>
|
||||
{matched.map(({ it, i }) => {
|
||||
const isSel = i === frame.selected;
|
||||
const detail = detailOf?.[i] ?? "";
|
||||
return (
|
||||
<box key={i} flexDirection="row" width="100%">
|
||||
<text fg={isSel ? C.accent : C.fg} flexGrow={1}>
|
||||
{isSel ? <b>{`${"▸ "}${it}`}</b> : ` ${it}`}
|
||||
</text>
|
||||
{detail && (
|
||||
<text fg={C.dim} flexShrink={0}>
|
||||
{detail}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{matched.length === 0 && (
|
||||
<text fg={C.red}> no matches — type less or press Backspace.</text>
|
||||
)}
|
||||
<text fg={C.dim}>
|
||||
{` ${frame.hint ?? ""}`}
|
||||
</text>
|
||||
{frame.items.length > 10 && (
|
||||
<text fg={C.dim}>
|
||||
{` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`}
|
||||
</text>
|
||||
)}
|
||||
</OverlayFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Status bar ───────────────────────────────────────────────────── */
|
||||
|
||||
function StatusBar(props: {
|
||||
state: AppStateRest;
|
||||
running: boolean;
|
||||
width: number;
|
||||
}): ReactNode {
|
||||
const { state, running, width } = props;
|
||||
const left = running ? "● running" : "● idle";
|
||||
const usage = state.lastUsage;
|
||||
const usageStr = usage
|
||||
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
||||
: "in:– · out:–";
|
||||
|
||||
// Uptime since boot.
|
||||
const uptime = fmtUptime((Date.now() - state.bootedAt) / 1000);
|
||||
|
||||
// Autocomplete hint for the active input.
|
||||
const ac =
|
||||
state.input.autocompleteVisible &&
|
||||
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
||||
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
||||
: "";
|
||||
|
||||
// Most recent live toast (expires via MiscState).
|
||||
const toasts = state.misc.toasts;
|
||||
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
||||
|
||||
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
||||
|
||||
const statusColor = toast
|
||||
? C.yellow
|
||||
: running
|
||||
? C.green
|
||||
: C.dim;
|
||||
|
||||
const activity = running ? ` ${spinner(state)}` : "";
|
||||
|
||||
const cell = ` ${left}${activity} ${usageStr} ${uptime}${ac}${toast} ${hints} `;
|
||||
return (
|
||||
<box backgroundColor={C.surface}>
|
||||
<text fg={statusColor}>{trimTo(cell, width)}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Clip a status line to the available width (avoid overflow). */
|
||||
function trimTo(s: string, width: number): string {
|
||||
if (s.length <= width) return s;
|
||||
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
/** Format seconds as a compact human uptime (e.g. 1m23s). */
|
||||
function fmtUptime(sec: number): string {
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
||||
if (m > 0) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
||||
@@ -304,3 +946,4 @@ export async function bootTui(state: AppStateRest): Promise<void> {
|
||||
}, 60);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user