Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29585cc630 | ||
|
|
5c65f8f6ab | ||
|
|
7e2eda3bb4 | ||
|
|
e6718fa118 | ||
|
|
f98030f28a | ||
|
|
93e5464370 | ||
|
|
f5b04214cf | ||
|
|
008f737e1f | ||
|
|
2100e7ab54 | ||
|
|
c53fdfa7c9 |
@@ -1,3 +1,23 @@
|
|||||||
|
# [1.25.0](https://github.com/asepharyana/zesdex/compare/v1.24.0...v1.25.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **tui:** enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing ([5c65f8f](https://github.com/asepharyana/zesdex/commit/5c65f8f6ab093dbfad682939a9fcae79a8a68661))
|
||||||
|
|
||||||
|
# [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)
|
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zesdex",
|
"name": "zesdex",
|
||||||
"version": "1.23.2",
|
"version": "1.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
|
|||||||
@@ -4,8 +4,25 @@ import {
|
|||||||
conversationChars,
|
conversationChars,
|
||||||
ErrorTracker,
|
ErrorTracker,
|
||||||
truncateToolOutput,
|
truncateToolOutput,
|
||||||
|
bashExitCode,
|
||||||
|
isToolFailure,
|
||||||
|
deriveVerifyCommand,
|
||||||
|
AgentTurnServiceImpl,
|
||||||
|
takeRecentTail,
|
||||||
|
reduceMessagesToDigest,
|
||||||
|
compactMessagesWithAi,
|
||||||
} from "./turn_service.ts";
|
} 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", () => {
|
describe("truncateToolOutput", () => {
|
||||||
it("short output is unchanged", () => {
|
it("short output is unchanged", () => {
|
||||||
@@ -57,3 +74,313 @@ describe("conversationChars", () => {
|
|||||||
expect(conversationChars(conv.messages)).toBe(3 + 11 + 6);
|
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[]>;
|
||||||
|
chatCalls: Array<ChatMessage[]>;
|
||||||
|
} {
|
||||||
|
const seen: Array<ChatMessage[]> = [];
|
||||||
|
const chatCalls: 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.
|
||||||
|
chatCalls.push([...messages]);
|
||||||
|
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, chatCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runTurn", () => {
|
||||||
|
it("runs a simple one-shot prompt to completion", async () => {
|
||||||
|
const { provider } = makeScriptedProvider([{ content: "here is the answer" }]);
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({}), [] as never);
|
||||||
|
const { params } = buildParams("hello");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
expect(params.in_flight.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the work-in-progress to the reviewer (not an empty slate)", async () => {
|
||||||
|
const { provider, chatCalls } = makeScriptedProvider([
|
||||||
|
{ tools: [{ name: "edit" }] },
|
||||||
|
{ content: "fixed" },
|
||||||
|
]);
|
||||||
|
(provider as unknown as { setChatReply(s: string): void }).setChatReply(
|
||||||
|
"- [PRIORITY: medium] add a null check",
|
||||||
|
);
|
||||||
|
const svc = new AgentTurnServiceImpl(provider as never, fixedExecutor({ edit: "changed" }), [] as never);
|
||||||
|
const { params } = buildParams("harden the parser");
|
||||||
|
await svc.runTurn(params);
|
||||||
|
// The REVIEWER chat call carries the digest in its user message.
|
||||||
|
const reviewerCall = chatCalls.find((c) =>
|
||||||
|
c.some((m) => (m.content ?? "").includes("Review the work-in-progress below")),
|
||||||
|
);
|
||||||
|
expect(reviewerCall).toBeTruthy();
|
||||||
|
expect(reviewerCall!.some((m) => (m.content ?? "").includes("harden the parser"))).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 ToolCall,
|
||||||
type ToolDef,
|
type ToolDef,
|
||||||
type JsonValue,
|
type JsonValue,
|
||||||
|
Roles,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
userMessage,
|
userMessage,
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
mainAgentPromptWithProjectContext,
|
mainAgentPromptWithProjectContext,
|
||||||
compactionPrompt,
|
compactionPrompt,
|
||||||
errorRecoveryNote,
|
errorRecoveryNote,
|
||||||
|
reviewerPrompt,
|
||||||
} from "@zesdex/agent";
|
} from "@zesdex/agent";
|
||||||
import type { ProviderService } from "./ports.ts";
|
import type { ProviderService } from "./ports.ts";
|
||||||
import type { ToolExecutor } from "./index.ts";
|
import type { ToolExecutor } from "./index.ts";
|
||||||
@@ -38,11 +40,92 @@ const PROJECT_CONTEXT_MAX_CHARS = 12_000;
|
|||||||
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
||||||
const COMPACT_KEEP_TAIL = 6;
|
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;
|
||||||
|
|
||||||
|
/** Per-tool-call wall-clock budget before the tool is considered hung. */
|
||||||
|
const TOOL_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
/** Whether the output string denotes a tool error. */
|
/** Whether the output string denotes a tool error. */
|
||||||
function isErrorOutput(output: string): boolean {
|
function isErrorOutput(output: string): boolean {
|
||||||
return output.startsWith("Error:");
|
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. */
|
/** Truncate a long tool output, preserving the head + truncation marker. */
|
||||||
export function truncateToolOutput(output: string): string {
|
export function truncateToolOutput(output: string): string {
|
||||||
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
||||||
@@ -125,18 +208,32 @@ async function executeToolCall(
|
|||||||
executor: ToolExecutor,
|
executor: ToolExecutor,
|
||||||
sink: TurnEventSink,
|
sink: TurnEventSink,
|
||||||
tc: ToolCall,
|
tc: ToolCall,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const name = tc.function.name;
|
const name = tc.function.name;
|
||||||
const args = sanitizeToolArguments(tc.function.arguments);
|
const args = sanitizeToolArguments(tc.function.arguments);
|
||||||
|
|
||||||
let output: string;
|
const run = executor.execute(name, args as JsonValue).catch((e) => `Error: ${(e as Error).message}`);
|
||||||
try {
|
|
||||||
output = await executor.execute(name, args as JsonValue);
|
|
||||||
} catch (e) {
|
|
||||||
output = `Error: ${(e as Error).message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isError = isErrorOutput(output);
|
// Correct async cancellation: enforce a per-tool time budget and stop
|
||||||
|
// immediately if the turn is aborted while the tool is still running.
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const output = await Promise.race([
|
||||||
|
run,
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
const onAbort = () => resolve("Error: Turn aborted by user");
|
||||||
|
timer = setTimeout(() => resolve(`Error: Tool timed out after ${TOOL_TIMEOUT_MS / 1000}s`), TOOL_TIMEOUT_MS);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
// Release the abort listener + timer once the tool settles either way.
|
||||||
|
void run.finally(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
const isError = isToolFailure(name, output);
|
||||||
const truncated = truncateToolOutput(output);
|
const truncated = truncateToolOutput(output);
|
||||||
|
|
||||||
sink.push({
|
sink.push({
|
||||||
@@ -156,6 +253,7 @@ async function executeToolCallsInParallel(
|
|||||||
executor: ToolExecutor,
|
executor: ToolExecutor,
|
||||||
sink: TurnEventSink,
|
sink: TurnEventSink,
|
||||||
toolCalls: ToolCall[],
|
toolCalls: ToolCall[],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
// Simple bounded concurrency preserving input order.
|
// Simple bounded concurrency preserving input order.
|
||||||
const results: string[] = new Array(toolCalls.length);
|
const results: string[] = new Array(toolCalls.length);
|
||||||
@@ -165,7 +263,7 @@ async function executeToolCallsInParallel(
|
|||||||
while (true) {
|
while (true) {
|
||||||
const idx = next++;
|
const idx = next++;
|
||||||
if (idx >= toolCalls.length) return;
|
if (idx >= toolCalls.length) return;
|
||||||
results[idx] = await executeToolCall(executor, sink, toolCalls[idx]!);
|
results[idx] = await executeToolCall(executor, sink, toolCalls[idx]!, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +277,75 @@ async function executeToolCallsInParallel(
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact oversized conversation history using AI summarisation. At most once
|
* Split history into a recent tail (kept verbatim, bound by CHAR budget so
|
||||||
* per turn. Keeps the last COMPACT_KEEP_TAIL messages.
|
* 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(
|
export async function compactMessagesWithAi(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
@@ -188,17 +353,35 @@ export async function compactMessagesWithAi(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
||||||
|
|
||||||
const splitIdx = messages.length - COMPACT_KEEP_TAIL;
|
const { tail, evicted } = takeRecentTail(messages);
|
||||||
const evicted = messages.splice(0, splitIdx);
|
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 {
|
try {
|
||||||
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
||||||
const summaryText = message.content ?? "Previous context summarised.";
|
const summaryText = message.content?.trim() ?? "";
|
||||||
messages.unshift(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText.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 {
|
} 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,9 +451,11 @@ export class AgentTurnServiceImpl {
|
|||||||
const abort = params.abort;
|
const abort = params.abort;
|
||||||
const { in_flight } = params;
|
const { in_flight } = params;
|
||||||
|
|
||||||
// Insert system prompt at index 0 with repo conventions loaded.
|
// Insert system prompt at index 0 with repo conventions + verify command.
|
||||||
const projectContext = buildProjectContext(params.workspace_roots[0] ?? ".");
|
const root = params.workspace_roots[0] ?? ".";
|
||||||
const systemPrompt = mainAgentPromptWithProjectContext(projectContext);
|
const projectContext = buildProjectContext(root);
|
||||||
|
const verifyCommand = deriveVerifyCommand(root);
|
||||||
|
const systemPrompt = mainAgentPromptWithProjectContext(projectContext, verifyCommand);
|
||||||
params.messages.unshift(systemMessage(systemPrompt));
|
params.messages.unshift(systemMessage(systemPrompt));
|
||||||
const originalCount = params.messages.length;
|
const originalCount = params.messages.length;
|
||||||
|
|
||||||
@@ -280,6 +465,12 @@ export class AgentTurnServiceImpl {
|
|||||||
|
|
||||||
const errors = new ErrorTracker();
|
const errors = new ErrorTracker();
|
||||||
let sawToolCalls = false;
|
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++) {
|
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
|
||||||
// Check abort flag.
|
// Check abort flag.
|
||||||
@@ -293,12 +484,33 @@ export class AgentTurnServiceImpl {
|
|||||||
break;
|
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.
|
// Auto-compact oversized history before the LLM call.
|
||||||
await this.autoCompactIfNeeded(params.messages);
|
await this.autoCompactIfNeeded(params.messages);
|
||||||
|
|
||||||
// Adaptive generation parameters.
|
// Generation parameters: a config override wins, else fall back to the
|
||||||
const maxTokens = adaptiveMaxTokens(requestLen);
|
// adaptive defaults (lower temperature once the model is doing tool work).
|
||||||
const temperature = sawToolCalls ? 0.2 : 0.7;
|
const maxTokens = params.max_tokens ?? adaptiveMaxTokens(requestLen);
|
||||||
|
const temperature = params.temperature ?? (sawToolCalls ? 0.2 : 0.7);
|
||||||
|
|
||||||
this.push(sink, { kind: "stream_start" });
|
this.push(sink, { kind: "stream_start" });
|
||||||
|
|
||||||
@@ -336,19 +548,52 @@ export class AgentTurnServiceImpl {
|
|||||||
const parallel = toolCalls.length > 1 && toolCalls.every(isParallelSafe);
|
const parallel = toolCalls.length > 1 && toolCalls.every(isParallelSafe);
|
||||||
|
|
||||||
const outputs = parallel
|
const outputs = parallel
|
||||||
? await executeToolCallsInParallel(this.toolExecutor, sink, toolCalls)
|
? await executeToolCallsInParallel(this.toolExecutor, sink, toolCalls, abort.signal)
|
||||||
: await (async () => {
|
: await (async () => {
|
||||||
const seq: string[] = [];
|
const seq: string[] = [];
|
||||||
for (const tc of toolCalls) seq.push(await executeToolCall(this.toolExecutor, sink, tc));
|
for (const tc of toolCalls) seq.push(await executeToolCall(this.toolExecutor, sink, tc, abort.signal));
|
||||||
return seq;
|
return seq;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
let mutated = false;
|
||||||
|
let verified = false;
|
||||||
|
let batchSignature = "";
|
||||||
for (let i = 0; i < toolCalls.length; i++) {
|
for (let i = 0; i < toolCalls.length; i++) {
|
||||||
const tc = toolCalls[i]!;
|
const tc = toolCalls[i]!;
|
||||||
const output = outputs[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));
|
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;
|
||||||
|
// Do NOT re-arm verifyPrompted: the verify instruction is already in
|
||||||
|
// context, so re-injecting it on every edit batch only bloats tokens.
|
||||||
|
}
|
||||||
|
if (verified) {
|
||||||
|
pendingVerify = false; // a successful bash run satisfies the verify nudge
|
||||||
|
verifyPrompted = false; // a later edit batch may nudge again
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Remove the synthetic sys_msg before emitting to the transcript.
|
||||||
@@ -357,4 +602,37 @@ export class AgentTurnServiceImpl {
|
|||||||
this.push(sink, { kind: "done" });
|
this.push(sink, { kind: "done" });
|
||||||
in_flight.value = false;
|
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;
|
||||||
|
// Reduce the work-in-progress to a focused digest so the reviewer has real
|
||||||
|
// context to critique (not an empty slate that invites hallucinated issues).
|
||||||
|
const digest = reduceMessagesToDigest(messages.slice(-80));
|
||||||
|
if (digest.trim() === "") return;
|
||||||
|
try {
|
||||||
|
const result = await this.provider.chat(
|
||||||
|
[
|
||||||
|
systemMessage(reviewerPrompt()),
|
||||||
|
userMessage(`Review the work-in-progress below and list actionable issues:\n\n---\n${digest}`),
|
||||||
|
],
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,6 @@ export function agentStatusDisplay(s: AgentStatus, error?: string): string {
|
|||||||
|
|
||||||
/** Events emitted onto the turn-event queue while an agent turn runs. */
|
/** Events emitted onto the turn-event queue while an agent turn runs. */
|
||||||
export type TurnEvent =
|
export type TurnEvent =
|
||||||
| { kind: "assistant_message"; message: ChatMessage }
|
|
||||||
| { kind: "tool_result"; tool_call_id: string; tool_name: string; output: string; is_error: boolean; path: string | null }
|
| { kind: "tool_result"; tool_call_id: string; tool_name: string; output: string; is_error: boolean; path: string | null }
|
||||||
| { kind: "system_note"; systemKind: string; message: string }
|
| { kind: "system_note"; systemKind: string; message: string }
|
||||||
| { kind: "stream_start" }
|
| { kind: "stream_start" }
|
||||||
@@ -183,6 +182,10 @@ export interface AgentTurnParams {
|
|||||||
api_key: string;
|
api_key: string;
|
||||||
model: string;
|
model: string;
|
||||||
api_base?: string;
|
api_base?: string;
|
||||||
|
/** Optional override for the LLM max_tokens ceiling (defaults to adaptive). */
|
||||||
|
max_tokens?: number;
|
||||||
|
/** Optional override for the LLM temperature (defaults to 0.7 / 0.2 tooling). */
|
||||||
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal event-sink abstraction (backs the Rust `Arc<Mutex<VecDeque>>`). */
|
/** Minimal event-sink abstraction (backs the Rust `Arc<Mutex<VecDeque>>`). */
|
||||||
|
|||||||
@@ -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. */
|
/** Build the main-agent system prompt. */
|
||||||
export function mainAgentPrompt(): string {
|
export function mainAgentPrompt(verifyCommand?: string): string {
|
||||||
return `You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.
|
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:
|
HOW TO WORK (choose the lightest sufficient path):
|
||||||
- For simple/factual questions, answer directly. Do NOT call tools.
|
1. Simple or factual: 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.
|
2. Needs code context: orient ONCE with \`read\`/\`grep\`/\`glob\` (or \`semantic_search\` for fuzzy questions), 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.
|
3. Multi-step or complex: enter a plan with \`plan_enter\`, track steps with \`todowrite\`, then execute.
|
||||||
- Keep responses concise; do not repeat tool output verbatim.
|
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:
|
${SHARED_DISCIPLINE}
|
||||||
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.
|
|
||||||
|
|
||||||
VERIFY AFTER EDIT (CLAUDE-CODE STYLE):
|
VERIFICATION CONTRACT:
|
||||||
- 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).
|
- After any of ${MUTATOR_TOOLS}, run the repository's verify command below and resolve failures before concluding your turn.
|
||||||
- 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).
|
- Only claim code compiles or tests pass after running a real check.
|
||||||
- Do NOT claim code compiles or works without 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. */
|
/** Main-agent prompt with an optional project-context block and verify command. */
|
||||||
export function mainAgentPromptWithProjectContext(projectContext: string): string {
|
export function mainAgentPromptWithProjectContext(
|
||||||
const base = mainAgentPrompt();
|
projectContext: string,
|
||||||
|
verifyCommand?: string,
|
||||||
|
): string {
|
||||||
|
const base = mainAgentPrompt(verifyCommand);
|
||||||
const context = projectContext.trim();
|
const context = projectContext.trim();
|
||||||
if (context === "") return base;
|
if (context === "") return base;
|
||||||
return `${base}
|
return `${base}
|
||||||
@@ -35,29 +57,51 @@ export function mainAgentPromptWithProjectContext(projectContext: string): strin
|
|||||||
${context}`;
|
${context}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a subagent directive prompt. */
|
/** Build a subagent directive prompt. Access tier tells the subagent its limits. */
|
||||||
export function subagentDirective(directive: string, cwd: string, wsRoot: string): string {
|
export function subagentDirective(
|
||||||
return `You are a focused subagent.
|
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}
|
Current directory (PWD): ${cwd}
|
||||||
Workspace root: ${wsRoot}
|
Workspace root: ${wsRoot}
|
||||||
|
Access tier: ${access} — ${tierNote}
|
||||||
|
|
||||||
Your directive:
|
${SHARED_DISCIPLINE}
|
||||||
|
|
||||||
|
Your directive (source of truth — follow it exactly):
|
||||||
${directive}
|
${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. */
|
/** Build a conversation-compaction prompt. */
|
||||||
export function compactionPrompt(): string {
|
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. */
|
/** Directive for a lightweight context-scout subagent. */
|
||||||
export function exploreScoutDirective(): string {
|
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:
|
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.
|
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 (use grep for symbols if needed).
|
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).
|
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.`;
|
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
||||||
}
|
}
|
||||||
@@ -66,3 +110,30 @@ Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
|||||||
export function errorRecoveryNote(toolName: string, lastError: string): string {
|
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.`;
|
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)}`;
|
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(
|
async chat(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
tools?: ToolDef[],
|
tools?: ToolDef[],
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the LLM-driven parallel-delegation splitter. The decomposition is
|
||||||
|
* decided by the AI (no hardcoded keyword guessing); these tests lock in the
|
||||||
|
* tolerant JSON parsing and the access-tier normalization.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parseDirectivesJson } from "./parallel_delegate.ts";
|
||||||
|
|
||||||
|
describe("parseDirectivesJson", () => {
|
||||||
|
test("parses a clean JSON array", () => {
|
||||||
|
const out = parseDirectivesJson(
|
||||||
|
`[{"directive":"Scan the config code","access":"read"},{"directive":"Implement the change","access":"full"}]`,
|
||||||
|
);
|
||||||
|
expect(out.length).toBe(2);
|
||||||
|
expect(out[0]).toEqual({ directive: "Scan the config code", access: "read" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts the array from markdown fences with a preamble", () => {
|
||||||
|
const out = parseDirectivesJson(
|
||||||
|
'Here are the subtasks:\n```json\n[{"directive":"A","access":"read"},{"directive":"B","access":"full"}]\n```\nDone.',
|
||||||
|
);
|
||||||
|
expect(out.map((d) => d.directive)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalises unknown access tiers to write", () => {
|
||||||
|
const out = parseDirectivesJson(`[{"directive":"do it","access":"admin"}]`);
|
||||||
|
expect(out[0]!.access).toBe("write");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops empty directives and malformed input", () => {
|
||||||
|
expect(parseDirectivesJson(`[{"directive":"","access":"write"}]`)).toEqual([]);
|
||||||
|
expect(parseDirectivesJson("not json at all")).toEqual([]);
|
||||||
|
expect(parseDirectivesJson("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,8 @@ export class ParallelDelegate implements Tool {
|
|||||||
}))
|
}))
|
||||||
.filter((d) => d.directive !== "");
|
.filter((d) => d.directive !== "");
|
||||||
} else {
|
} else {
|
||||||
directives = fallbackSplit(task, maxParallel);
|
// Let the LLM decide the decomposition instead of guessing keywords.
|
||||||
|
directives = await aiDecomposeTask(task, maxParallel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directives.length === 0) {
|
if (directives.length === 0) {
|
||||||
@@ -73,22 +74,67 @@ export class ParallelDelegate implements Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback splitting when LLM is unavailable. */
|
/**
|
||||||
function fallbackSplit(task: string, maxParallel: number): Array<{ directive: string; access: string }> {
|
* Have the LLM decompose a task into focused, non-overlapping parallel
|
||||||
const directives: Array<{ directive: string; access: string }> = [];
|
* sub-directives. This is the AI deciding how to split the work — no keyword
|
||||||
if (task.includes("backend") || task.includes("api") || task.includes("server")) {
|
* guessing. On LLM failure (no provider / error), degrades to a single honest
|
||||||
directives.push({ directive: `Implement the backend/API components for: ${task}`, access: "write" });
|
* full-task directive rather than fabricated "part N" stubs.
|
||||||
|
*/
|
||||||
|
async function aiDecomposeTask(
|
||||||
|
task: string,
|
||||||
|
maxParallel: number,
|
||||||
|
): Promise<Array<{ directive: string; access: string }>> {
|
||||||
|
try {
|
||||||
|
const { resolveConfig } = await import("../../../subagent/infrastructure/config_resolver.ts");
|
||||||
|
const { buildProviderService } = await import("../../../subagent/infrastructure/http_provider.ts");
|
||||||
|
const { baseUrl, apiKey, model } = await resolveConfig();
|
||||||
|
const svc = buildProviderService(baseUrl, apiKey, model);
|
||||||
|
|
||||||
|
const systemPrompt =
|
||||||
|
"You decompose a large task into independent, non-overlapping subtasks that can be worked on " +
|
||||||
|
"in parallel. Return a JSON array of up to the requested count of objects, each with a " +
|
||||||
|
"'directive' (a concrete, self-contained action) and an 'access' tier of exactly one of " +
|
||||||
|
"read | write | full. read = inspect/search only; write = edit files; full = edit + run shell. " +
|
||||||
|
"Prefer fewer, genuinely parallel directives over many that touch the same files. " +
|
||||||
|
"Return ONLY the JSON array, no prose.";
|
||||||
|
|
||||||
|
const { message } = await svc.chat(
|
||||||
|
[
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: `Task: ${task}\nMax subtasks: ${maxParallel}\n` },
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
1024,
|
||||||
|
0.3,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseDirectivesJson(message.content ?? "");
|
||||||
|
if (parsed.length > 0) return parsed.slice(0, maxParallel);
|
||||||
|
} catch {
|
||||||
|
/* fall through to the single-directive fallback */
|
||||||
|
}
|
||||||
|
// Honest fallback: run the whole task as one agent rather than guessing a split.
|
||||||
|
return [{ directive: task, access: "full" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an LLM JSON array of directives, tolerant of stray prose/markdown fences. */
|
||||||
|
export function parseDirectivesJson(raw: string): Array<{ directive: string; access: string }> {
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
const body = fenced ? fenced[1]! : raw;
|
||||||
|
const start = body.indexOf("[");
|
||||||
|
const end = body.lastIndexOf("]");
|
||||||
|
if (start < 0 || end <= start) return [];
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(body.slice(start, end + 1)) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr
|
||||||
|
.filter((d): d is Record<string, unknown> => typeof d === "object" && d !== null)
|
||||||
|
.map((d) => ({
|
||||||
|
directive: typeof d.directive === "string" ? d.directive.trim() : "",
|
||||||
|
access: ["read", "write", "full"].includes(String(d.access)) ? String(d.access) : "write",
|
||||||
|
}))
|
||||||
|
.filter((d) => d.directive !== "");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
if (task.includes("frontend") || task.includes("ui") || task.includes("client")) {
|
|
||||||
directives.push({ directive: `Implement the frontend/UI components for: ${task}`, access: "write" });
|
|
||||||
}
|
|
||||||
if (task.includes("test") || task.includes("unit")) {
|
|
||||||
directives.push({ directive: `Write unit tests for: ${task}`, access: "read" });
|
|
||||||
}
|
|
||||||
if (directives.length === 0) {
|
|
||||||
for (let i = 0; i < maxParallel; i++) {
|
|
||||||
directives.push({ directive: `Part ${i + 1} of parallel task: ${task}`, access: "write" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return directives.slice(0, maxParallel);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ describe("allTools registry", () => {
|
|||||||
|
|
||||||
describe("read-only tools are parallel-safe", () => {
|
describe("read-only tools are parallel-safe", () => {
|
||||||
test("parallel-safe list", () => {
|
test("parallel-safe list", () => {
|
||||||
for (const name of ["read", "grep", "glob", "semantic_search", "list_symbols", "web_search", "recall", "dir_list", "pong", "seq_think", "dir_cache_update"]) {
|
for (const name of ["read", "grep", "glob", "semantic_search", "list_symbols", "web_search", "recall", "dir_list", "pong", "seq_think"]) {
|
||||||
expect(toolIsParallelSafe(name)).toBe(true);
|
expect(toolIsParallelSafe(name)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test("mutating tools are not parallel-safe", () => {
|
test("mutating tools (incl. dir_cache_update, which writes the cache) are not parallel-safe", () => {
|
||||||
for (const name of ["edit", "write", "delete", "bash", "git_operator", "git_worktree", "remember", "forget", "todowrite", "plan_enter", "spawn_agents"]) {
|
for (const name of ["edit", "write", "delete", "bash", "git_operator", "git_worktree", "remember", "forget", "todowrite", "plan_enter", "spawn_agents", "dir_cache_update"]) {
|
||||||
expect(toolIsParallelSafe(name)).toBe(false);
|
expect(toolIsParallelSafe(name)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export function toolIsParallelSafe(name: string): boolean {
|
|||||||
"dir_list",
|
"dir_list",
|
||||||
"pong",
|
"pong",
|
||||||
"seq_think",
|
"seq_think",
|
||||||
"dir_cache_update",
|
|
||||||
].includes(name);
|
].includes(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Complexity heuristics — determine whether a request is complex enough to
|
|
||||||
* warrant hive-mind orchestration.
|
|
||||||
* Mirrors `apps/infrastructure/src/workflow/hive_mind/complexity.rs`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Heuristics to determine if a request is complex enough for hive-mind. */
|
|
||||||
export function isComplexRequest(task: string): boolean {
|
|
||||||
const complexityIndicators = [
|
|
||||||
"refactor",
|
|
||||||
"redesign",
|
|
||||||
"multiple files",
|
|
||||||
"architecture",
|
|
||||||
"migration",
|
|
||||||
"comprehensive",
|
|
||||||
"end-to-end",
|
|
||||||
"full-stack",
|
|
||||||
];
|
|
||||||
|
|
||||||
const taskLower = task.toLowerCase();
|
|
||||||
return complexityIndicators.some((indicator) => taskLower.includes(indicator));
|
|
||||||
}
|
|
||||||
@@ -7,5 +7,4 @@ export { executeWorkflowScript, executePrimitive, formatWorkflowResult, MAX_NODE
|
|||||||
export { executeWorkflow } from "./orchestrate.ts";
|
export { executeWorkflow } from "./orchestrate.ts";
|
||||||
export { executeCycle } from "./cycle.ts";
|
export { executeCycle } from "./cycle.ts";
|
||||||
export { synthesizeConsensus } from "./synthesis.ts";
|
export { synthesizeConsensus } from "./synthesis.ts";
|
||||||
export { isComplexRequest } from "./complexity.ts";
|
|
||||||
export { writeHiveMindConvergence } from "./docs.ts";
|
export { writeHiveMindConvergence } from "./docs.ts";
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the workflow YAML-subset parser, including the folded multi-line
|
||||||
|
* directive support.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parseWorkflowScript } from "./script.ts";
|
||||||
|
|
||||||
|
describe("parseWorkflowScript", () => {
|
||||||
|
test("parses a flat single-line workflow", () => {
|
||||||
|
const script = parseWorkflowScript(`
|
||||||
|
name: my-workflow
|
||||||
|
phases:
|
||||||
|
- name: research
|
||||||
|
directive: "Explore the codebase for the auth module."
|
||||||
|
- name: implement
|
||||||
|
directive: "Implement the change."
|
||||||
|
`);
|
||||||
|
expect(script.name).toBe("my-workflow");
|
||||||
|
expect(script.phases.length).toBe(2);
|
||||||
|
expect(script.phases[0]!.name).toBe("research");
|
||||||
|
expect(script.phases[0]!.directive).toBe("Explore the codebase for the auth module.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folds an indented multi-line directive into one string", () => {
|
||||||
|
const script = parseWorkflowScript(`
|
||||||
|
name: audit
|
||||||
|
phases:
|
||||||
|
- name: analyze
|
||||||
|
directive: |
|
||||||
|
Look at two things:
|
||||||
|
first, how auth is wired;
|
||||||
|
second, where secrets are stored.
|
||||||
|
`);
|
||||||
|
expect(script.phases.length).toBe(1);
|
||||||
|
const d = script.phases[0]!.directive;
|
||||||
|
expect(d).toContain("Look at two things:");
|
||||||
|
expect(d).toContain("second, where secrets are stored.");
|
||||||
|
// Line breaks are preserved as folded content, not flattened.
|
||||||
|
expect(d).toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an indented continuation does not swallow the next phase", () => {
|
||||||
|
const script = parseWorkflowScript(`
|
||||||
|
phases:
|
||||||
|
- name: a
|
||||||
|
directive: first
|
||||||
|
and more
|
||||||
|
- name: b
|
||||||
|
directive: second
|
||||||
|
`);
|
||||||
|
expect(script.phases.length).toBe(2);
|
||||||
|
expect(script.phases[0]!.directive).toContain("and more");
|
||||||
|
expect(script.phases[1]!.name).toBe("b");
|
||||||
|
expect(script.phases[1]!.directive).toBe("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty or unparseable yaml yields no phases", () => {
|
||||||
|
expect(parseWorkflowScript("").phases.length).toBe(0);
|
||||||
|
expect(parseWorkflowScript("name: x\n").phases.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,21 +31,62 @@ export function parseWorkflowScript(yaml: string): WorkflowScript {
|
|||||||
|
|
||||||
let inPhases = false;
|
let inPhases = false;
|
||||||
let currentPhase: Partial<WorkflowPhase> | null = null;
|
let currentPhase: Partial<WorkflowPhase> | null = null;
|
||||||
|
/** Set when `directive:` was opened, to fold more-indented lines into it. */
|
||||||
|
let directiveIndent: number | null = null;
|
||||||
|
|
||||||
|
const flushPhase = (): void => {
|
||||||
|
if (currentPhase && (currentPhase.name || currentPhase.directive)) {
|
||||||
|
phases.push({
|
||||||
|
name: currentPhase.name ?? "phase",
|
||||||
|
directive: currentPhase.directive ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentPhase = null;
|
||||||
|
directiveIndent = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const indentOf = (s: string): number => {
|
||||||
|
const m = s.match(/^(\s*)/);
|
||||||
|
return m ? m[1]!.length : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Strip one surrounding pair of single/double quotes, YAML-scalar style. */
|
||||||
|
const unquote = (v: string): string => {
|
||||||
|
const t = v.trim();
|
||||||
|
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
||||||
|
return t.slice(1, -1);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
for (const raw of lines) {
|
for (const raw of lines) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
|
// Fold continuation lines into the open directive (indented block text).
|
||||||
|
// A continuation is a non-blank line indented deeper than the directive
|
||||||
|
// key, that is not a new list item and not a `key:` assignment.
|
||||||
|
if (currentPhase && directiveIndent !== null) {
|
||||||
|
const indent = indentOf(raw);
|
||||||
|
const isListItem = trimmed.startsWith("- ");
|
||||||
|
const isKv = /^[\w-]+:\s/.test(trimmed);
|
||||||
|
if (indent > directiveIndent && !isListItem && !isKv) {
|
||||||
|
currentPhase.directive = `${currentPhase.directive ?? ""}\n${trimmed}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Top-level key: value
|
// Top-level key: value
|
||||||
const topMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
const topMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
||||||
if (topMatch && !raw.startsWith(" ") && !raw.startsWith("-")) {
|
if (topMatch && !raw.startsWith(" ") && !raw.startsWith("-")) {
|
||||||
const key = topMatch[1]!;
|
const key = topMatch[1]!;
|
||||||
const val = topMatch[2]!.trim();
|
const val = unquote(topMatch[2]!);
|
||||||
if (key === "name" && val !== "") {
|
if (key === "name" && val !== "") {
|
||||||
name = val;
|
name = val;
|
||||||
} else if (key === "phases") {
|
} else if (key === "phases") {
|
||||||
inPhases = true;
|
inPhases = true;
|
||||||
}
|
}
|
||||||
|
directiveIndent = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,18 +95,17 @@ export function parseWorkflowScript(yaml: string): WorkflowScript {
|
|||||||
// List item: - name: foo or - directive: bar
|
// List item: - name: foo or - directive: bar
|
||||||
const listMatch = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
|
const listMatch = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
|
||||||
if (listMatch) {
|
if (listMatch) {
|
||||||
// If there was a previous phase, push it
|
flushPhase();
|
||||||
if (currentPhase && (currentPhase.name || currentPhase.directive)) {
|
|
||||||
phases.push({
|
|
||||||
name: currentPhase.name ?? "phase",
|
|
||||||
directive: currentPhase.directive ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const key = listMatch[1]!;
|
const key = listMatch[1]!;
|
||||||
const val = listMatch[2]!.trim();
|
const val = unquote(listMatch[2]!);
|
||||||
currentPhase = { name: undefined, directive: undefined };
|
currentPhase = { name: undefined, directive: undefined };
|
||||||
if (key === "name") currentPhase.name = val;
|
if (key === "name") currentPhase.name = val;
|
||||||
if (key === "directive") currentPhase.directive = val;
|
if (key === "directive") {
|
||||||
|
currentPhase.directive = val;
|
||||||
|
directiveIndent = indentOf(raw);
|
||||||
|
} else {
|
||||||
|
directiveIndent = null;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,20 +114,19 @@ export function parseWorkflowScript(yaml: string): WorkflowScript {
|
|||||||
const kvMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
const kvMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
||||||
if (kvMatch) {
|
if (kvMatch) {
|
||||||
const key = kvMatch[1]!;
|
const key = kvMatch[1]!;
|
||||||
const val = kvMatch[2]!.trim();
|
const val = unquote(kvMatch[2]!);
|
||||||
if (key === "name") currentPhase.name = val;
|
if (key === "name") currentPhase.name = val;
|
||||||
if (key === "directive") currentPhase.directive = val;
|
if (key === "directive") {
|
||||||
|
currentPhase.directive = val;
|
||||||
|
directiveIndent = indentOf(raw);
|
||||||
|
} else {
|
||||||
|
directiveIndent = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push the last phase
|
flushPhase();
|
||||||
if (currentPhase && (currentPhase.name || currentPhase.directive)) {
|
|
||||||
phases.push({
|
|
||||||
name: currentPhase.name ?? "phase",
|
|
||||||
directive: currentPhase.directive ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, phases };
|
return { name, phases };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { NodeOutput } from "@zesdex/workflow";
|
import type { NodeOutput } from "@zesdex/workflow";
|
||||||
import type { ToolCtx } from "../../agent/infrastructure/tools/mod.ts";
|
import type { ToolCtx } from "../../agent/infrastructure/tools/mod.ts";
|
||||||
|
import { consensusSynthesizerPrompt } from "@zesdex/agent";
|
||||||
import { MAX_NODE_OUTPUT_CHARS } from "./engine.ts";
|
import { MAX_NODE_OUTPUT_CHARS } from "./engine.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,16 +34,7 @@ export async function synthesizeConsensus(
|
|||||||
|
|
||||||
const systemMsg = {
|
const systemMsg = {
|
||||||
role: "system" as const,
|
role: "system" as const,
|
||||||
content: "You are a consensus synthesizer for a multi-agent hive mind. " +
|
content: consensusSynthesizerPrompt(),
|
||||||
"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.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const userMsg = {
|
const userMsg = {
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface WiredRuntime {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
apiBase?: string;
|
apiBase?: string;
|
||||||
|
/** Fetch the provider's available model ids (via GET /models). */
|
||||||
|
listModels?: () => Promise<string[]>;
|
||||||
|
/** Optional LLM max_tokens override from settings. */
|
||||||
|
maxTokens?: number;
|
||||||
|
/** Optional LLM temperature override from settings. */
|
||||||
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +82,17 @@ export async function runSingleProcess(): Promise<WiredRuntime> {
|
|||||||
toolDefs(allTools()) as never,
|
toolDefs(allTools()) as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { store, executor, turnService, apiKey, model, apiBase: baseUrl };
|
return {
|
||||||
|
store,
|
||||||
|
executor,
|
||||||
|
turnService,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
apiBase: baseUrl,
|
||||||
|
listModels: () => llmClient.listModels(),
|
||||||
|
maxTokens: settings.max_tokens,
|
||||||
|
temperature: settings.temperature,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rooted, long-lived event sink that collects events into an array. */
|
/** Rooted, long-lived event sink that collects events into an array. */
|
||||||
@@ -111,6 +127,8 @@ export function buildTurnParams(
|
|||||||
api_key: runtime.apiKey,
|
api_key: runtime.apiKey,
|
||||||
model: runtime.model,
|
model: runtime.model,
|
||||||
api_base: runtime.apiBase,
|
api_base: runtime.apiBase,
|
||||||
|
max_tokens: runtime.maxTokens,
|
||||||
|
temperature: runtime.temperature,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import {
|
|||||||
toastInfo,
|
toastInfo,
|
||||||
markDirty,
|
markDirty,
|
||||||
EditorState,
|
EditorState,
|
||||||
|
type MenuFrame,
|
||||||
} from "./state.ts";
|
} from "./state.ts";
|
||||||
import { gitDiffOutput } from "./git.ts";
|
import { gitDiffOutput } from "./git.ts";
|
||||||
|
import { modelProviderMenu, configMenu } from "./menu.ts";
|
||||||
|
|
||||||
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -46,7 +48,21 @@ export type Action =
|
|||||||
| { tag: "AbortTurn" }
|
| { tag: "AbortTurn" }
|
||||||
| { tag: "Compact" }
|
| { tag: "Compact" }
|
||||||
| { tag: "ShowDiff" }
|
| { 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. */
|
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
|
||||||
export function applyAction(state: AppStateRest, action: Action): void {
|
export function applyAction(state: AppStateRest, action: Action): void {
|
||||||
@@ -184,9 +200,83 @@ export function applyAction(state: AppStateRest, action: Action): void {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
break;
|
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. */
|
/** Periodic timer tick — drains queued events and applies streaming text. */
|
||||||
function handleTick(state: AppStateRest): void {
|
function handleTick(state: AppStateRest): void {
|
||||||
// Streaming updates are applied via appendToLastTranscript by the turn
|
// Streaming updates are applied via appendToLastTranscript by the turn
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export type Command =
|
|||||||
| { tag: "Clear" }
|
| { tag: "Clear" }
|
||||||
| { tag: "ClearConfirm" }
|
| { tag: "ClearConfirm" }
|
||||||
| { tag: "Edit"; path: string }
|
| { tag: "Edit"; path: string }
|
||||||
| { tag: "McpAdd"; name: string; command: string }
|
| { tag: "OpenModelMenu" }
|
||||||
| { tag: "ModelList" }
|
| { tag: "OpenConfigMenu" }
|
||||||
| { tag: "Compact" }
|
| { tag: "Compact" }
|
||||||
| { tag: "TodoOpen" }
|
| { tag: "TodoOpen" }
|
||||||
| { tag: "PlanOpen" }
|
| { tag: "PlanOpen" }
|
||||||
@@ -28,10 +28,9 @@ export function parseCommand(text: string): Command {
|
|||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
|
||||||
|
|
||||||
const parts = trimmed.split(" ", 3);
|
const parts = trimmed.split(" ", 2);
|
||||||
const cmd = parts[0] ?? "";
|
const cmd = parts[0] ?? "";
|
||||||
const arg1 = parts[1] ?? "";
|
const arg1 = parts[1] ?? "";
|
||||||
const arg2 = parts[2] ?? "";
|
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "/help":
|
case "/help":
|
||||||
@@ -43,18 +42,15 @@ export function parseCommand(text: string): Command {
|
|||||||
case "/edit":
|
case "/edit":
|
||||||
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
||||||
case "/mcp":
|
case "/mcp":
|
||||||
|
// No inline sub-commands: `/mcp` alone opens the interactive server list.
|
||||||
if (arg1 === "") return { tag: "McpOpen" };
|
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 };
|
return { tag: "Unknown", text: cmd };
|
||||||
case "/model":
|
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":
|
case "/compact":
|
||||||
return { tag: "Compact" };
|
return { tag: "Compact" };
|
||||||
case "/todo":
|
case "/todo":
|
||||||
@@ -85,10 +81,10 @@ export function applyCommand(cmd: Command): Action[] {
|
|||||||
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
||||||
case "Edit":
|
case "Edit":
|
||||||
return [{ tag: "OpenEditor", path: cmd.path }];
|
return [{ tag: "OpenEditor", path: cmd.path }];
|
||||||
case "McpAdd":
|
case "OpenModelMenu":
|
||||||
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
|
return [{ tag: "OpenMenuById", menu: "model" }];
|
||||||
case "ModelList":
|
case "OpenConfigMenu":
|
||||||
return [{ tag: "ModelList" }];
|
return [{ tag: "OpenMenuById", menu: "config" }];
|
||||||
case "Compact":
|
case "Compact":
|
||||||
return [{ tag: "Compact" }];
|
return [{ tag: "Compact" }];
|
||||||
case "TodoOpen":
|
case "TodoOpen":
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
import { Roles } from "@zesdex/domain";
|
import { Roles } from "@zesdex/domain";
|
||||||
import type { AppStateRest } from "./state.ts";
|
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 type { Action } from "./action.ts";
|
||||||
import { applyCommand, parseCommand } from "./command.ts";
|
import { applyCommand, parseCommand } from "./command.ts";
|
||||||
|
import { resolveMenu } from "./menu.ts";
|
||||||
|
|
||||||
/** A decoded terminal key event (normalised from raw input). */
|
/** A decoded terminal key event (normalised from raw input). */
|
||||||
export interface KeyEvent {
|
export interface KeyEvent {
|
||||||
@@ -32,6 +33,8 @@ export const KeyCode = {
|
|||||||
Down: "ArrowDown",
|
Down: "ArrowDown",
|
||||||
PageUp: "PageUp",
|
PageUp: "PageUp",
|
||||||
PageDown: "PageDown",
|
PageDown: "PageDown",
|
||||||
|
Home: "Home",
|
||||||
|
End: "End",
|
||||||
Esc: "Escape",
|
Esc: "Escape",
|
||||||
Tab: "Tab",
|
Tab: "Tab",
|
||||||
Char: "Char",
|
Char: "Char",
|
||||||
@@ -183,7 +186,48 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
return [];
|
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 ────────────────────────────────────
|
// ── 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) {
|
switch (code) {
|
||||||
case KeyCode.Char:
|
case KeyCode.Char:
|
||||||
if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||||
@@ -215,7 +259,17 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
return [];
|
return [];
|
||||||
case KeyCode.Enter:
|
case KeyCode.Enter:
|
||||||
if (state.input.autocompleteVisible) {
|
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);
|
return mark(state);
|
||||||
}
|
}
|
||||||
if (overlayIsActive(state.misc.overlay)) {
|
if (overlayIsActive(state.misc.overlay)) {
|
||||||
@@ -223,9 +277,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const text = state.input.buffer;
|
const text = state.input.buffer;
|
||||||
if (text.startsWith("/")) {
|
|
||||||
return applyCommand(parseCommand(text));
|
|
||||||
}
|
|
||||||
return [{ tag: "SubmitInput", text }];
|
return [{ tag: "SubmitInput", text }];
|
||||||
}
|
}
|
||||||
case KeyCode.Backspace:
|
case KeyCode.Backspace:
|
||||||
@@ -249,14 +300,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
state.input.cycleAutocomplete(false);
|
state.input.cycleAutocomplete(false);
|
||||||
mark(state);
|
mark(state);
|
||||||
return [];
|
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) {
|
} else if (key.ctrl) {
|
||||||
return [{ tag: "ScrollUp" }];
|
return [{ tag: "ScrollUp" }];
|
||||||
} else {
|
} else {
|
||||||
@@ -267,14 +310,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
state.input.cycleAutocomplete(true);
|
state.input.cycleAutocomplete(true);
|
||||||
mark(state);
|
mark(state);
|
||||||
return [];
|
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) {
|
} else if (key.ctrl) {
|
||||||
return [{ tag: "ScrollDown" }];
|
return [{ tag: "ScrollDown" }];
|
||||||
} else {
|
} 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. */
|
/** Local helper: whether an agent turn is in flight. */
|
||||||
function turnInFlightLocal(state: AppStateRest): boolean {
|
function turnInFlightLocal(state: AppStateRest): boolean {
|
||||||
return state.turnInFlightFlag.value;
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+50
-22
@@ -54,10 +54,31 @@ function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner
|
|||||||
api_key: runtime.apiKey,
|
api_key: runtime.apiKey,
|
||||||
model: runtime.model,
|
model: runtime.model,
|
||||||
api_base: runtime.apiBase,
|
api_base: runtime.apiBase,
|
||||||
|
max_tokens: runtime.maxTokens,
|
||||||
|
temperature: runtime.temperature,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */
|
||||||
function handleEvent(state: AppStateRest, ev: TurnEvent): void {
|
function handleEvent(state: AppStateRest, ev: TurnEvent): void {
|
||||||
switch (ev.kind) {
|
switch (ev.kind) {
|
||||||
@@ -74,38 +95,34 @@ function handleEvent(state: AppStateRest, ev: TurnEvent): void {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
pushTranscript(
|
// A tool result is a breadcrumb, not a full transcript entry — keep the
|
||||||
state,
|
// transcript clean and fold the tool name into the live activity bar.
|
||||||
makeChatMessage(
|
addActivity(state, {
|
||||||
Roles.Tool,
|
label: ev.tool_name,
|
||||||
`→ ${ev.tool_name}${ev.is_error ? " (error)" : ""}`,
|
detail: ev.is_error ? "error" : undefined,
|
||||||
),
|
kind: ev.is_error ? "error" : "tool",
|
||||||
);
|
});
|
||||||
break;
|
break;
|
||||||
case "usage":
|
case "usage":
|
||||||
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
|
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
break;
|
break;
|
||||||
case "agent_progress":
|
case "agent_progress":
|
||||||
pushTranscript(
|
addActivity(state, {
|
||||||
state,
|
label: ev.progress.agent_name,
|
||||||
makeChatMessage(
|
detail: ev.progress.error ?? ev.progress.current_tool ?? undefined,
|
||||||
Roles.Tool,
|
kind: ev.progress.error ? "error" : "agent",
|
||||||
`[${ev.progress.agent_name}] ${ev.progress.status}${ev.progress.error ? ` — ${ev.progress.error}` : ev.progress.current_tool ? ` · ${ev.progress.current_tool}` : ""}`,
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "workflow_agent_update":
|
case "workflow_agent_update":
|
||||||
pushTranscript(
|
addActivity(state, {
|
||||||
state,
|
label: ev.agent_name,
|
||||||
makeChatMessage(
|
detail: ev.error ?? undefined,
|
||||||
Roles.Tool,
|
kind: ev.error ? "error" : "agent",
|
||||||
`[${ev.agent_name}] ${ev.status}${ev.error ? ` — ${ev.error}` : ""}`,
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`));
|
addActivity(state, { label: "error", detail: ev.message, kind: "error" });
|
||||||
toastError(state, `Turn error: ${ev.message}`);
|
toastError(state, `Turn error: ${ev.message}`);
|
||||||
break;
|
break;
|
||||||
case "compacted":
|
case "compacted":
|
||||||
@@ -150,5 +167,16 @@ export async function runTui(): Promise<void> {
|
|||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
} as Record<string, unknown>;
|
} 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);
|
await bootTui(state);
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-14
@@ -36,13 +36,14 @@ export const COMMANDS: string[] = [
|
|||||||
"/quit",
|
"/quit",
|
||||||
"/clear",
|
"/clear",
|
||||||
"/edit",
|
"/edit",
|
||||||
"/mcp add",
|
"/mcp",
|
||||||
"/model",
|
"/model",
|
||||||
"/model ls",
|
"/config",
|
||||||
"/model add",
|
|
||||||
"/todo",
|
"/todo",
|
||||||
"/usage",
|
"/usage",
|
||||||
"/compact",
|
"/compact",
|
||||||
|
"/diff",
|
||||||
|
"/plan",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The user's input buffer, cursor, history, and autocomplete state. */
|
/** The user's input buffer, cursor, history, and autocomplete state. */
|
||||||
@@ -56,6 +57,8 @@ export class InputState {
|
|||||||
autocompleteVisible = false;
|
autocompleteVisible = false;
|
||||||
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
||||||
mentionStart = 0;
|
mentionStart = 0;
|
||||||
|
/** Start index of the command token being completed (for in-place replace). */
|
||||||
|
autocompleteTokenStart = 0;
|
||||||
historyFile: string | null = null;
|
historyFile: string | null = null;
|
||||||
|
|
||||||
/** Hide the autocomplete dropdown and clear its state. */
|
/** Hide the autocomplete dropdown and clear its state. */
|
||||||
@@ -65,6 +68,7 @@ export class InputState {
|
|||||||
this.autocompleteIdx = 0;
|
this.autocompleteIdx = 0;
|
||||||
this.autocompleteKind = AutocompleteKind.Command;
|
this.autocompleteKind = AutocompleteKind.Command;
|
||||||
this.mentionStart = 0;
|
this.mentionStart = 0;
|
||||||
|
this.autocompleteTokenStart = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
||||||
@@ -74,11 +78,28 @@ export class InputState {
|
|||||||
this.closeAutocomplete();
|
this.closeAutocomplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prefix = trimmed.toLowerCase();
|
// Filter on the *command token* (the word before any space), so typing
|
||||||
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix));
|
// "/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.autocompleteKind = AutocompleteKind.Command;
|
||||||
this.autocompleteIdx = 0;
|
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. */
|
/** 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. */
|
/** Accept the currently selected autocomplete candidate. Returns true on success. */
|
||||||
selectAutocomplete(): boolean {
|
selectAutocomplete(): boolean {
|
||||||
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
||||||
if (candidate === undefined || candidate === null) return false;
|
if (candidate === undefined || candidate === null) return false;
|
||||||
if (this.autocompleteKind === AutocompleteKind.Command) {
|
if (this.autocompleteKind === AutocompleteKind.Command) {
|
||||||
this.buffer = candidate;
|
// Replace only the command token (e.g. "/mo" → "/model"), keeping any
|
||||||
this.cursor = this.buffer.length;
|
// 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 {
|
} else {
|
||||||
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
||||||
this.closeAutocomplete();
|
this.closeAutocomplete();
|
||||||
@@ -194,6 +228,12 @@ export class InputState {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear the input buffer and reset the cursor. */
|
||||||
|
clear(): void {
|
||||||
|
this.buffer = "";
|
||||||
|
this.cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Navigate backward through input history. */
|
/** Navigate backward through input history. */
|
||||||
historyUp(): void {
|
historyUp(): void {
|
||||||
if (this.history.length === 0) return;
|
if (this.history.length === 0) return;
|
||||||
@@ -248,6 +288,7 @@ export const Overlay = {
|
|||||||
ModelSelector: "model_selector",
|
ModelSelector: "model_selector",
|
||||||
ClearConfirm: "clear_confirm",
|
ClearConfirm: "clear_confirm",
|
||||||
Diff: "diff",
|
Diff: "diff",
|
||||||
|
Menu: "menu",
|
||||||
} as const;
|
} as const;
|
||||||
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
|
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 ───────────────────────────────────────────────────── */
|
/* ── Misc state ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The "miscellaneous" slice of app state. */
|
/** The "miscellaneous" slice of app state. */
|
||||||
@@ -296,6 +380,12 @@ export class MiscState {
|
|||||||
pendingClipboardCopy: string | null = null;
|
pendingClipboardCopy: string | null = null;
|
||||||
diffContent = "";
|
diffContent = "";
|
||||||
diffScroll = 0;
|
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 {
|
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||||
this.toasts.push(toast);
|
this.toasts.push(toast);
|
||||||
@@ -477,6 +567,8 @@ export interface AppStateRest {
|
|||||||
turnAbort: AbortController | null;
|
turnAbort: AbortController | null;
|
||||||
/** Token usage from the most recent turn (surfaced in the status bar). */
|
/** Token usage from the most recent turn (surfaced in the status bar). */
|
||||||
lastUsage: { tokensIn: number; tokensOut: number } | null;
|
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
|
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||||
@@ -487,6 +579,7 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
|||||||
Ctrl+Y Copy last assistant message
|
Ctrl+Y Copy last assistant message
|
||||||
Esc Abort turn / Close overlay
|
Esc Abort turn / Close overlay
|
||||||
Tab Autocomplete
|
Tab Autocomplete
|
||||||
|
Alt+D Show git diff
|
||||||
|
|
||||||
─── Navigation ───
|
─── Navigation ───
|
||||||
↑ / ↓ History browse / Overlay navigate
|
↑ / ↓ History browse / Overlay navigate
|
||||||
@@ -496,20 +589,32 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
|||||||
|
|
||||||
─── Overlays ───
|
─── Overlays ───
|
||||||
/help Show this help
|
/help Show this help
|
||||||
/settings Open settings overlay
|
/model Pick provider → model (interactive)
|
||||||
|
/config Settings menu (internet, review, provider)
|
||||||
/todo Open tasks (todo) overlay
|
/todo Open tasks (todo) overlay
|
||||||
/usage Open usage statistics
|
/usage Open usage statistics
|
||||||
/bash Open bash jobs overlay
|
/diff Open git diff overlay
|
||||||
|
/plan Open plan overlay
|
||||||
/mcp Open MCP server management
|
/mcp Open MCP server management
|
||||||
/model Open model selector
|
|
||||||
/compact Compact conversation
|
/compact Compact conversation
|
||||||
/clear Clear transcript
|
/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 ───
|
─── Editor Mode ───
|
||||||
/edit <path> Open file for inline editing
|
/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. */
|
/** Construct fresh TUI state. */
|
||||||
export function createTuiState(
|
export function createTuiState(
|
||||||
@@ -549,6 +654,7 @@ export function createTuiState(
|
|||||||
turnRunner: null,
|
turnRunner: null,
|
||||||
turnAbort: null,
|
turnAbort: null,
|
||||||
lastUsage: null,
|
lastUsage: null,
|
||||||
|
bootedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createTuiState, pushTranscript, makeChatMessage, reconcileStream, type
|
|||||||
import { applyAction } from "./action.ts";
|
import { applyAction } from "./action.ts";
|
||||||
import { handleKey, decodeKey } from "./controller.ts";
|
import { handleKey, decodeKey } from "./controller.ts";
|
||||||
import { toControllerKey } from "./ui.tsx";
|
import { toControllerKey } from "./ui.tsx";
|
||||||
|
import { modelProviderMenu, modelSubMenu, resolveMenu } from "./menu.ts";
|
||||||
import { Roles } from "@zesdex/domain";
|
import { Roles } from "@zesdex/domain";
|
||||||
|
|
||||||
function freshState() {
|
function freshState() {
|
||||||
@@ -94,6 +95,38 @@ describe("handleKey", () => {
|
|||||||
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]);
|
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", () => {
|
test("Ctrl+Y copies last assistant message", () => {
|
||||||
const s = freshState();
|
const s = freshState();
|
||||||
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
|
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", () => {
|
describe("decodeKey", () => {
|
||||||
test("Enter decodes", () => {
|
test("Enter decodes", () => {
|
||||||
expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false });
|
expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false });
|
||||||
@@ -249,3 +360,51 @@ describe("reconcileStream (9router sliding-window vs incremental)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+705
-174
@@ -5,10 +5,11 @@
|
|||||||
* input through the existing `handleKey` → `applyAction` pipeline.
|
* input through the existing `handleKey` → `applyAction` pipeline.
|
||||||
*
|
*
|
||||||
* Layout (column flex over the full terminal):
|
* Layout (column flex over the full terminal):
|
||||||
* 1. Header bar — app · model · session (blue bar)
|
* 1. Header bar — logo · model · session · live turn spinner
|
||||||
* 2. Transcript — role-coloured chat scrollbox (flex-grow)
|
* 2. Transcript — role-coloured chat scrollbox (markdown-lite, flex-grow)
|
||||||
* 3. Input row — `>` prompt + buffer (same line)
|
* 3. Activity bar — collapsed breadcrumb of the running turn's tools/agents
|
||||||
* 4. Status bar — turn state · token usage · hints
|
* 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`)
|
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
||||||
* stays logic-pure and unit-tested; this file only reads it for display.
|
* stays logic-pure and unit-tested; this file only reads it for display.
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
|
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
|
||||||
import type { ReactNode } from "react";
|
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 { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts";
|
||||||
import { Roles } from "@zesdex/domain";
|
import { Roles } from "@zesdex/domain";
|
||||||
import { handleKey, KeyCode } from "./controller.ts";
|
import { handleKey, KeyCode } from "./controller.ts";
|
||||||
@@ -71,6 +72,10 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
|
|||||||
case "pagedown":
|
case "pagedown":
|
||||||
case "pgdn":
|
case "pgdn":
|
||||||
return { code: KeyCode.PageDown, ctrl, alt };
|
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 "escape":
|
||||||
case "esc":
|
case "esc":
|
||||||
return { code: KeyCode.Esc, ctrl, alt };
|
return { code: KeyCode.Esc, ctrl, alt };
|
||||||
@@ -84,13 +89,24 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Role label mapped to a colour for the transcript pane. */
|
/* ── Palette ──────────────────────────────────────────────────────── */
|
||||||
const roleColor: Record<string, string> = {
|
|
||||||
[Roles.User]: "cyan",
|
/** Zesdex brand colours — a calm, readable "deep ocean" scheme. */
|
||||||
[Roles.Assistant]: "green",
|
const C = {
|
||||||
[Roles.System]: "yellow",
|
bg: "#0d1117", // deep off-navy transcript ground
|
||||||
[Roles.Tool]: "magenta",
|
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. */
|
/** Short uppercase role tag shown before each message line. */
|
||||||
const roleTag: Record<string, string> = {
|
const roleTag: Record<string, string> = {
|
||||||
@@ -100,8 +116,13 @@ const roleTag: Record<string, string> = {
|
|||||||
[Roles.Tool]: "TOOL",
|
[Roles.Tool]: "TOOL",
|
||||||
};
|
};
|
||||||
|
|
||||||
const oneLine = (s: string): string =>
|
/** Distinct colour per role — used for badges + body tint. */
|
||||||
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
|
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. */
|
/** Column-align a pair of header cells across the full width. */
|
||||||
function spreadCenter(left: string, right: string, width: number): string {
|
function spreadCenter(left: string, right: string, width: number): string {
|
||||||
@@ -109,6 +130,201 @@ function spreadCenter(left: string, right: string, width: number): string {
|
|||||||
return left + " ".repeat(gap) + right;
|
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.
|
* Root OpenTUI component for the Zesdex TUI.
|
||||||
* @param state The mutable AppStateRest (mutated by applyAction).
|
* @param state The mutable AppStateRest (mutated by applyAction).
|
||||||
@@ -121,17 +337,33 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
useKeyboard((key: OpenTuiKey) => {
|
useKeyboard((key: OpenTuiKey) => {
|
||||||
if (key.eventType === "release") return;
|
if (key.eventType === "release") return;
|
||||||
const zk = toControllerKey(key);
|
const zk = toControllerKey(key);
|
||||||
|
let consumedByModal = false;
|
||||||
try {
|
try {
|
||||||
const actions: Action[] = handleKey(zk, state);
|
const actions: Action[] = handleKey(zk, state);
|
||||||
for (const act of actions) {
|
for (const act of actions) {
|
||||||
if (act.tag === "QuitConfirm") {
|
if (act.tag === "QuitConfirm") {
|
||||||
state.quit = true;
|
state.quit = true;
|
||||||
onQuit();
|
onQuit();
|
||||||
|
consumedByModal = true;
|
||||||
continue;
|
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);
|
applyAction(state, act);
|
||||||
}
|
}
|
||||||
} finally {
|
} 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.
|
// Force React to re-read the mutated `state` on the next frame.
|
||||||
setTick((t: number) => t + 1);
|
setTick((t: number) => t + 1);
|
||||||
}
|
}
|
||||||
@@ -140,6 +372,7 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
const dimensions = useTerminalDimensionsHook();
|
const dimensions = useTerminalDimensionsHook();
|
||||||
const width = dimensions.width ?? 80;
|
const width = dimensions.width ?? 80;
|
||||||
const height = dimensions.height ?? 24;
|
const height = dimensions.height ?? 24;
|
||||||
|
const running = state.turnInFlightFlag.value;
|
||||||
|
|
||||||
// Turn driver: when a turn is submitted, run it against the real agent and
|
// Turn driver: when a turn is submitted, run it against the real agent and
|
||||||
// stream every event into the transcript as it happens.
|
// stream every event into the transcript as it happens.
|
||||||
@@ -178,8 +411,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
state.turnInFlightFlag.value = false;
|
state.turnInFlightFlag.value = false;
|
||||||
state.turnAbort = null;
|
state.turnAbort = null;
|
||||||
state.abortFlag.value = false;
|
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(() => {
|
setTimeout(() => {
|
||||||
|
state.misc.activity = [];
|
||||||
turnRunning = false;
|
turnRunning = false;
|
||||||
setTick((x: number) => x + 1);
|
setTick((x: number) => x + 1);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -189,207 +424,502 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [state, setTick]);
|
}, [state, setTick]);
|
||||||
|
|
||||||
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
// Tick the spinner + uptime while idle too (so ● running animates and the
|
||||||
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
// uptime clock in the status bar advances even between key presses).
|
||||||
const headerLeft = ` zesdex · ${model} `;
|
useEffect(() => {
|
||||||
const headerRight = sessionId ? ` session:${sessionId} ` : " ";
|
if (!running) return;
|
||||||
const header = spreadCenter(headerLeft, headerRight, width);
|
const id = setInterval(() => {
|
||||||
|
state.misc.tickCount += 1;
|
||||||
|
state.dirty = true;
|
||||||
|
setTick((t: number) => t + 1);
|
||||||
|
}, 120);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [running, state, setTick]);
|
||||||
|
|
||||||
const messages = state.transcriptCache.messages;
|
const overlayActive = state.misc.overlay !== "none";
|
||||||
const scrollFromEnd = messages.length - state.scroll.offset;
|
const menuActive = state.misc.menuStack.length > 0;
|
||||||
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
|
||||||
|
|
||||||
const running = state.turnInFlightFlag.value;
|
// 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 (
|
return (
|
||||||
<box flexDirection="column" width={width} height={height}>
|
<box flexDirection="column" width={width} height={height}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<box backgroundColor="blue">
|
<HeaderView state={state} running={running} width={width} />
|
||||||
<text fg="white">{header}</text>
|
{body}
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Transcript pane */}
|
|
||||||
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
|
|
||||||
{visible.map((m, i) => (
|
|
||||||
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
|
||||||
))}
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
|
|
||||||
)}
|
|
||||||
</scrollbox>
|
|
||||||
|
|
||||||
{/* Autocomplete dropdown (shown above the input row) */}
|
|
||||||
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
|
||||||
|
|
||||||
{/* Overlay or input row */}
|
|
||||||
{state.misc.overlay !== "none" ? (
|
|
||||||
<OverlayView state={state} />
|
|
||||||
) : (
|
|
||||||
<InputView state={state} width={width} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<StatusBar state={state} running={running} />
|
<StatusBar state={state} running={running} width={width} />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Word-wrap a string to a fixed width (used for plain-text messages). */
|
/* ── Header ───────────────────────────────────────────────────────── */
|
||||||
function wrapText(text: string, width: number): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const raw of text.split("\n")) {
|
|
||||||
// Never wrap an empty (or whitespace-only) line.
|
|
||||||
if (raw.length === 0) {
|
|
||||||
lines.push("");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let line = "";
|
|
||||||
for (const word of raw.split(/(\s+)/)) {
|
|
||||||
if (word === "\n") {
|
|
||||||
if (line) lines.push(line);
|
|
||||||
line = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.length + word.length > width) {
|
|
||||||
if (line) lines.push(line);
|
|
||||||
line = word; // long word spills; next iteration handles it
|
|
||||||
} else {
|
|
||||||
line += word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line) lines.push(line);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One chat message rendered in the transcript. */
|
function HeaderView(props: {
|
||||||
function TranscriptMessage(props: {
|
state: AppStateRest;
|
||||||
msg: AppStateRest["transcriptCache"]["messages"][number];
|
running: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const { msg, width } = props;
|
const { state, running, width } = props;
|
||||||
const color = roleColor[msg.role] ?? "white";
|
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
||||||
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
|
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
||||||
// Assistant messages legitimately have no body while a tool call is being
|
const shortId = sessionId.length > 12 ? sessionId.slice(0, 11) : sessionId;
|
||||||
// made (or mid-stream) — don't fill that with a misleading placeholder.
|
const glyph = running ? spinner(state) : "◆";
|
||||||
const content =
|
const left = ` ${glyph} zesdex · ${model} `;
|
||||||
msg.role === Roles.Assistant && !msg.content.trim()
|
const right = sessionId ? ` session:${shortId} ` : " ";
|
||||||
? ""
|
const header = spreadCenter(left, right, width);
|
||||||
: msg.content || "(empty)";
|
const bg = running ? C.headerA : C.headerB;
|
||||||
const bodyWidth = Math.max(20, width - 6);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width="100%">
|
<box backgroundColor={bg}>
|
||||||
{/* Role tag header line */}
|
<text fg={running ? C.green : "white"}>
|
||||||
<text fg={color}>{` [${tag}]`}</text>
|
<b>{header}</b>
|
||||||
{/* Indented, word-wrapped body */}
|
|
||||||
{content.length > 0 && (
|
|
||||||
<text fg={msg.role === Roles.Assistant ? "default" : color}>
|
|
||||||
{indent(content, 4, bodyWidth)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
{/* Live reasoning (dimmed, kept to one line) */}
|
|
||||||
{msg.reasoning.length > 0 && (
|
|
||||||
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
|
|
||||||
)}
|
|
||||||
{/* Thin separator between messages */}
|
|
||||||
<text fg="#2a3340">{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Indent every wrapped line of `text` by `pad` spaces within `width` columns. */
|
|
||||||
function indent(text: string, pad: number, width: number): string {
|
|
||||||
return wrapText(text, width)
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => (l.length ? " ".repeat(pad) + l : ""))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bottom input row: `❯` prompt + buffer on one line, with a subtle divider. */
|
|
||||||
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
|
||||||
const { state, width } = props;
|
|
||||||
const shown = state.input.buffer.slice(
|
|
||||||
Math.max(0, state.input.cursor - Math.max(10, width - 12)),
|
|
||||||
Math.max(0, state.input.cursor) + 40,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" width="100%">
|
|
||||||
<text fg="#2a3340">{" " + "─".repeat(Math.max(0, width - 4))}</text>
|
|
||||||
<box flexDirection="row" width="100%">
|
|
||||||
<text fg="cyan">{" ❯ "}</text>
|
|
||||||
<text fg="white">{shown}</text>
|
|
||||||
<text fg="gray">{"▏"}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Autocomplete / file-mention dropdown rendered as a bordered panel. */
|
|
||||||
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";
|
|
||||||
return (
|
|
||||||
<box width="100%" style={{ border: true, padding: 0 }}>
|
|
||||||
<text fg="magenta">
|
|
||||||
{` ${title} (↑/↓ navigate, Tab select, Esc close)\n`}
|
|
||||||
</text>
|
</text>
|
||||||
{candidates.slice(0, 10).map((c, i) => (
|
</box>
|
||||||
<text key={c} fg={i === state.input.autocompleteIdx ? "cyan" : "white"}>
|
);
|
||||||
{i === state.input.autocompleteIdx ? "▸ " : " "}
|
}
|
||||||
{c}
|
|
||||||
{i < candidates.slice(0, 10).length - 1 ? "\n" : ""}
|
/* ── Transcript ───────────────────────────────────────────────────── */
|
||||||
</text>
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<text fg={C.dim}>
|
||||||
|
{`\n ─ no messages yet. Type a message below (or /help) ─`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<box flexDirection="column">
|
||||||
|
{visible.map((m, i) => (
|
||||||
|
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
||||||
))}
|
))}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full-screen modal overlays (help, diff, editor, model selector, etc.). */
|
/* ── Activity bar (running turn) ──────────────────────────────────── */
|
||||||
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
|
||||||
const { state } = props;
|
function ActivityBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
||||||
const name = state.misc.overlay;
|
const { state, running } = props;
|
||||||
let body = "";
|
const activity = state.misc.activity;
|
||||||
if (name === "quit_confirm") body = "Quit? (y=yes, n=no) Ctrl+C again / /quit to confirm";
|
if (!running || activity.length === 0) return null;
|
||||||
else if (name === "help") body = state.helpText;
|
const line = ` ${spinner(state)} ${activity.map((a) => label(a)).join(" · ")} `;
|
||||||
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)`;
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
|
<box flexDirection="column" width="100%" style={{ backgroundColor: C.surface }}>
|
||||||
<text fg="yellow">{body}</text>
|
<text fg={C.magenta}>
|
||||||
<text fg="gray">
|
{line}
|
||||||
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
|
</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>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom status bar: turn state · token usage · live toast/hints. */
|
/* ── Overlays ─────────────────────────────────────────────────────── */
|
||||||
function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
|
||||||
const { state, running } = props;
|
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 left = running ? "● running" : "● idle";
|
||||||
const usage = state.lastUsage;
|
const usage = state.lastUsage;
|
||||||
const usageStr = usage
|
const usageStr = usage
|
||||||
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
||||||
: "in:– · out:–";
|
: "in:– · out:–";
|
||||||
|
|
||||||
|
// Uptime since boot.
|
||||||
|
const uptime = fmtUptime((Date.now() - state.bootedAt) / 1000);
|
||||||
|
|
||||||
// Autocomplete hint for the active input.
|
// Autocomplete hint for the active input.
|
||||||
const ac =
|
const ac =
|
||||||
state.input.autocompleteVisible &&
|
state.input.autocompleteVisible &&
|
||||||
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
||||||
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Most recent live toast (expires via MiscState).
|
// Most recent live toast (expires via MiscState).
|
||||||
const toasts = state.misc.toasts;
|
const toasts = state.misc.toasts;
|
||||||
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
||||||
|
|
||||||
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
||||||
const cell = ` ${left} ${usageStr}${ac}${toast} ${hints} `;
|
|
||||||
return <text fg={toast ? "yellow" : running ? "green" : "gray"}>{cell}</text>;
|
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. */
|
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
||||||
@@ -416,3 +946,4 @@ export async function bootTui(state: AppStateRest): Promise<void> {
|
|||||||
}, 60);
|
}, 60);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user