Compare commits

...
12 Commits
Author SHA1 Message Date
semantic-release-bot 29585cc630 chore(release): 1.25.0 [skip ci]
# [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))
2026-09-03 09:39:11 +00:00
asepharyana 5c65f8f6ab feat(tui): enhance LLM interaction with max_tokens and temperature overrides; add tests for parallel delegation and workflow script parsing 2026-09-03 16:38:21 +07:00
semantic-release-bot 7e2eda3bb4 chore(release): 1.24.0 [skip ci]
# [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))
2026-09-03 09:14:28 +00:00
asepharyana e6718fa118 feat(tui): implement message compaction and summarization logic for efficient conversation handling 2026-09-03 16:13:29 +07:00
asepharyana f98030f28a feat(tui): add new flow helpers for bash exit code handling and verification prompts 2026-09-03 16:13:29 +07:00
asepharyana 93e5464370 feat(tui): implement live model fetching and enhance model selection menus 2026-09-03 16:13:29 +07:00
asepharyana f5b04214cf feat(tui): enhance command execution on autocomplete and improve UI responsiveness 2026-09-03 16:13:29 +07:00
asepharyana 008f737e1f feat(tui): enhance autocomplete functionality and UI interactions 2026-09-03 16:13:29 +07:00
asepharyana 2100e7ab54 feat(tui): implement interactive menu system for model and config selection 2026-09-03 16:13:29 +07:00
asepharyana c53fdfa7c9 feat(tui): enhance model selection and improve UI components
- Added tests for handling key events in model selector, including Enter and ArrowDown functionality.
- Updated TUI layout to include a new header, activity bar, and improved status bar with spinner and uptime.
- Refactored transcript rendering to support markdown and improved message display.
- Introduced overlays for model selection, help, and editor with appropriate rendering logic.
- Implemented smoke tests for various TUI states to ensure UI integrity and functionality.
2026-09-03 16:13:29 +07:00
semantic-release-bot 5912a35bbb chore(release): 1.23.2 [skip ci]
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)

### Bug Fixes

* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
2026-09-03 05:29:53 +00:00
zesdex d86d6aeef2 fix(tui): sliding-window stream reconciler + UI polish
- Replace naive reconcileStream with stateful sliding-window replay
  merger: tracks per-message replay cursors (_posContent/_posReasoning)
  to deduplicate the segment-replay waves 9router emits. Verified
  against 970 real captured chunks (100% clean output).
- TranscriptMessage: colored [TAG] headers, 4-space indented body,
  thin separator lines between messages, dimmed reasoning.
- InputView: divider above prompt, '❯' prompt, cursor block.
- 75 tests pass, typecheck clean, biome clean.
2026-09-03 12:28:56 +07:00
28 changed files with 2894 additions and 412 deletions
+27
View File
@@ -1,3 +1,30 @@
# [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)
### Bug Fixes
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03) ## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "zesdex", "name": "zesdex",
"version": "1.23.1", "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", () => {
@@ -56,4 +73,314 @@ describe("conversationChars", () => {
conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output")); conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output"));
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);
});
});
+303 -25
View File
@@ -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 */
}
}
} }
+4 -1
View File
@@ -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>>`). */
+105
View File
@@ -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]");
});
});
+101 -30
View File
@@ -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.`;
} }
@@ -65,4 +109,31 @@ Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
/** System note injected after repeated tool errors. */ /** System note injected after repeated tool errors. */
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);
});
});
+58 -19
View File
@@ -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 = {
+19 -1
View File
@@ -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,
}; };
} }
+91 -1
View File
@@ -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
+13 -17
View File
@@ -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":
+100 -21
View File
@@ -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;
+160
View File
@@ -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" }];
}
+160
View File
@@ -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
View File
@@ -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);
} }
+183 -36
View File
@@ -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);
@@ -329,10 +419,13 @@ export interface ChatMessageDisplay {
content: string; content: string;
reasoning: string; reasoning: string;
timestamp: number; timestamp: number;
/** Sliding-window replay cursor for content + reasoning (transient). */
_posContent: number;
_posReasoning: number;
} }
export function makeChatMessage(role: Role, content: string): ChatMessageDisplay { export function makeChatMessage(role: Role, content: string): ChatMessageDisplay {
return { role, content, reasoning: "", timestamp: Date.now() }; return { role, content, reasoning: "", timestamp: Date.now(), _posContent: 0, _posReasoning: 0 };
} }
/** Bounded ring of recent chat messages used to render the transcript view. */ /** Bounded ring of recent chat messages used to render the transcript view. */
@@ -357,9 +450,9 @@ export class TranscriptCache {
const msg = this.messages[this.messages.length - 1]; const msg = this.messages[this.messages.length - 1];
if (msg && msg.role === Roles.Assistant) { if (msg && msg.role === Roles.Assistant) {
if (isReasoning) { if (isReasoning) {
msg.reasoning = reconcileStream(msg.reasoning, text); msg.reasoning = reconcileStream(msg.reasoning, text, msg, true);
} else { } else {
msg.content = reconcileStream(msg.content, text); msg.content = reconcileStream(msg.content, text, msg, false);
} }
this.dirty = true; this.dirty = true;
} }
@@ -367,34 +460,72 @@ export class TranscriptCache {
} }
/** /**
* Reconcile an incoming stream chunk against the accumulated text. * Reconcile an incoming stream chunk against the accumulated text, deduping the
* sliding-window replays that 9router's OpenAI-compatible layer emits.
* *
* Streaming backends disagree on chunk semantics: * Streaming backends disagree on chunk semantics:
* - Native OpenAI/Anthropic send small *incremental* deltas. * - Native OpenAI/Anthropic send small *incremental* deltas.
* - Some gateways/proxies (e.g. 9router's OpenAI-compatible layer) emit * - 9router/gateways re-send the FULL accumulated text as a repeated window:
* *full-text* chunks, where each chunk is the complete transcript so far. * each "wave" replays the already-emitted segments from the start, then one
* fresh token. So the stream is s1,s2,…,sk then s1,s2,…,sk,s_{k+1} then
* s1,…,s_{k+1},s_{k+2} … where s_i + … = the final text.
* *
* Blindly concatenating full-text chunks duplicates content (partial + full), * Naive concatenation doubles the text (garbled output). We track a per-stream
* which manifests as garbled messages. This helper accepts both modes: * replay cursor `pos` into `accumulated`: a chunk that continues at `pos` (or
* - a chunk that is a strict prefix of the tail → a shorter replay → ignore; * restarts at 0, i.e. begins a fresh wave) is a replay → ignore it and advance
* - a chunk that extends/replays the whole accumulated text → take it as the * the cursor; only a chunk that matches nothing already present is brand-new
* new authoritative tail (full-text mode); * content, which we append at the end and reset the cursor.
* - otherwise → treat as an incremental delta and append. *
* @param accumulated current merged text
* @param chunk next chunk from the stream
* @param msg the message being streamed (holds the replay cursor)
* @param isReasoning whether this targets the reasoning buffer or the content
* @returns the updated accumulated text
*/ */
export function reconcileStream(accumulated: string, chunk: string): string { export function reconcileStream(
accumulated: string,
chunk: string,
msg: ChatMessageDisplay,
isReasoning: boolean,
): string {
if (chunk.length === 0) return accumulated; if (chunk.length === 0) return accumulated;
if (accumulated.length === 0) return chunk; const posRef = { pos: isReasoning ? msg._posReasoning : msg._posContent };
if (accumulated.length >= chunk.length && accumulated.startsWith(chunk)) { if (accumulated.length === 0) {
// Shorter (or equal) replay of what we already hold — ignore. accumulated = chunk;
posRef.pos = chunk.length;
writePos();
return accumulated; return accumulated;
} }
if (chunk.length >= accumulated.length && chunk.startsWith(accumulated)) { // 1) A chunk that continues the current replay cursor is a replayed segment
// Full-text mode: the chunk is the whole transcript so far (matching our // (it already exists earlier in the text) → skip it, advance the cursor.
// accumulated text plus more). Adopt it wholesale. if (
return chunk; posRef.pos > 0 &&
posRef.pos < accumulated.length &&
chunk.length <= accumulated.length - posRef.pos &&
accumulated.startsWith(chunk, posRef.pos)
) {
posRef.pos += chunk.length;
writePos();
return accumulated;
}
// 2) A chunk that matches at the very start begins a NEW replay wave → skip
// it, set the cursor to just past the matched prefix.
if (accumulated.startsWith(chunk)) {
posRef.pos = chunk.length;
writePos();
return accumulated;
}
// 3) Otherwise this is brand-new content: append it and reset the cursor so
// the next wave starts fresh.
accumulated += chunk;
posRef.pos = -1;
writePos();
return accumulated;
function writePos(): void {
if (isReasoning) msg._posReasoning = posRef.pos;
else msg._posContent = posRef.pos;
} }
// Incremental delta mode: a new suffix. Append.
return accumulated + chunk;
} }
/* ── AppStateRest ─────────────────────────────────────────────────── */ /* ── AppStateRest ─────────────────────────────────────────────────── */
@@ -436,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
@@ -446,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
@@ -455,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(
@@ -508,6 +654,7 @@ export function createTuiState(
turnRunner: null, turnRunner: null,
turnAbort: null, turnAbort: null,
lastUsage: null, lastUsage: null,
bootedAt: Date.now(),
}; };
} }
+239 -23
View File
@@ -4,10 +4,11 @@
*/ */
import { describe, test, expect } from "bun:test"; import { describe, test, expect } from "bun:test";
import { parseCommand, applyCommand } from "./command.ts"; import { parseCommand, applyCommand } from "./command.ts";
import { createTuiState, pushTranscript, makeChatMessage, reconcileStream } from "./state.ts"; import { createTuiState, pushTranscript, makeChatMessage, reconcileStream, type ChatMessageDisplay } from "./state.ts";
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 });
@@ -163,32 +274,137 @@ describe("toControllerKey (OpenTUI adapter)", () => {
}); });
}); });
describe("reconcileStream (full-text vs incremental chunk handling)", () => { describe("reconcileStream (9router sliding-window vs incremental)", () => {
test("appends incremental deltas", () => { function msg(): ChatMessageDisplay {
let s = reconcileStream("", "Hello"); return makeChatMessage(Roles.Assistant, "");
s = reconcileStream(s, "!"); }
s = reconcileStream(s, " How"); function rec(
s: string,
chunk: string,
m: ChatMessageDisplay,
): string {
return reconcileStream(s, chunk, m, false);
}
test("incremental deltas append in order", () => {
const m = msg();
let s = rec("", "Hello", m);
s = rec(s, "!", m);
s = rec(s, " How", m);
expect(s).toBe("Hello! How"); expect(s).toBe("Hello! How");
}); });
test("full-text mode: adopts a growing authoritative chunk", () => {
let s = reconcileStream("", "Hello"); test("real 9router wave: replayed segments never double", () => {
// 9router/proxy resends the *whole* transcript each chunk — adopt it. // Real captured stream (story turn), each fresh token restarts a replay
s = reconcileStream(s, "Hello world"); // wave from the head: "Here's a 3-sent" "ence story" " about" " a robo" …
s = reconcileStream(s, "Hello world!"); const m = msg();
expect(s).toBe("Hello world!"); const segs: string[] = [
}); "Here's a 3-sent",
test("shorter/equal replay of the tail is ignored (no duplication)", () => { "ence story",
const tail = "Hello world"; " about",
expect(reconcileStream(tail, tail)).toBe(tail); " a robo",
expect(reconcileStream(tail, "Hello")).toBe(tail); "t:\n\nA small repai",
}); "r robo",
test("partial-then-full garbled stream resolves to the full text", () => { "t name",
let s = reconcileStream("", "Hello! 更新"); "d Bolt",
s = reconcileStream(s, "Hello! 更新 Keep going"); ];
expect(s).toBe("Hello! 更新 Keep going"); // Wave 1 (first two segments).
let s = "";
s = rec(s, segs[0]!, m);
s = rec(s, segs[1]!, m);
expect(s).toBe("Here's a 3-sentence story");
// Wave 2 replays seg0..seg1 then new seg2.
for (const g of segs.slice(0, 3)) s = rec(s, g, m);
expect(s).toBe("Here's a 3-sentence story about");
// Wave 3 replays seg0..seg2 then new seg3.
for (const g of segs.slice(0, 4)) s = rec(s, g, m);
expect(s).toBe("Here's a 3-sentence story about a robo");
// Wave 4 replays 0..3 then new seg4.
for (const g of segs.slice(0, 5)) s = rec(s, g, m);
expect(s).toBe("Here's a 3-sentence story about a robot:\n\nA small repai");
// Wave 5 replays 0..4 then new seg5.
for (const g of segs.slice(0, 6)) s = rec(s, g, m);
expect(s).toBe(
"Here's a 3-sentence story about a robot:\n\nA small repair robo",
);
// Wave 6 replays 0..5 then new seg6.
for (const g of segs.slice(0, 7)) s = rec(s, g, m);
// Final wave replays everything, no new content.
for (const g of segs.slice(0, 8)) s = rec(s, g, m);
expect(s).toBe(
"Here's a 3-sentence story about a robot:\n\nA small repair robot named Bolt",
);
}); });
test("empty chunk is a no-op", () => { test("empty chunk is a no-op", () => {
expect(reconcileStream("abc", "")).toBe("abc"); const m = msg();
expect(rec("abc", "", m)).toBe("abc");
});
test("1-char shared letter is NOT treated as a replay", () => {
// "Hi! " ends with a space; "How…" starts with 'H' — must append fully.
const m = msg();
let s = rec("", "Hi! ", m);
s = rec(s, "How can I help you today?", m);
expect(s).toBe("Hi! How can I help you today?");
});
test("'say hi' captured sequence resolves cleanly", () => {
const m = msg();
let s = "";
// Real captured: "", "", "Hi! 👋 ", "", "", "Hi! 👋 ", "How can I help you today?"
s = rec(s, "Hi! 👋 ", m);
s = rec(s, "Hi! 👋 ", m); // replay of head
s = rec(s, "How can I help you today?", m);
expect(s).toBe("Hi! 👋 How can I help you today?");
}); });
}); });
describe("menu model lists (live vs fallback)", () => {
test("provider menu lists configured providers", () => {
const s = freshState();
s.appConfig = { providers: { zen: {}, router: {}, claude: {} } };
s.settings = { provider: "router" };
const frame = modelProviderMenu(s);
expect(frame.id).toBe("model:provider");
expect(frame.items).toEqual(["zen", "router", "claude"].map(() => expect.anything()).map((_, i) => ["zen","router","claude"][i]!));
expect(frame.selected).toBe(1); // router active
});
test("model sub-menu uses live /models list when available", () => {
const s = freshState();
s.misc.availableModels = ["gpt-5", "claude-sonnet-5", "deepseek-v3"];
s.settings = { provider: "zen", model: "claude-sonnet-5" };
const frame = modelSubMenu(s, "zen")!;
expect(frame.items).toEqual(["gpt-5", "claude-sonnet-5", "deepseek-v3"]);
expect(frame.selected).toBe(1); // current model highlighted
expect(frame.title).toContain("(3)");
});
test("model sub-menu falls back to a static catalog without a live list", () => {
const s = freshState();
s.misc.availableModels = [];
s.settings = { provider: "zen", model: "" };
const frame = modelSubMenu(s, "zen")!;
expect(frame.items.length).toBeGreaterThan(0);
expect(frame.hint).toContain("fallback");
});
test("resolving a live model row applies it and pops the stack", () => {
const s = freshState();
s.misc.availableModels = ["gpt-5", "claude-sonnet-5"];
s.appConfig = { providers: { zen: {} } };
s.settings = { provider: "zen", model: "claude-sonnet-5" };
applyAction(s, { tag: "OpenMenu", frame: modelProviderMenu(s) });
applyAction(s, { tag: "OpenMenu", frame: modelSubMenu(s, "zen")! });
// The sub-menu pre-selects the CURRENT model (claude-sonnet-5, index 1).
expect(s.misc.menuStack.at(-1)?.selected).toBe(1);
// Move to index 0 (gpt-5), then resolve Enter for the model row.
applyAction(s, { tag: "MenuHome" });
const acts = resolveMenu(s, "model:zen");
expect(acts).toEqual([{ tag: "MenuBackAll" }]);
expect(s.settings.provider).toBe("zen");
expect(s.settings.model).toBe("gpt-5");
});
});
+705 -158
View File
@@ -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,191 +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;
// Each message is a labelled block: a coloured role tag line followed by the
// word-wrapped body. Plain `<text>` is used (not `<markdown>`) so live
// streaming re-renders a single clean string — no overlap artifacts, and the
// body fills the full transcript width.
return ( return (
<box flexDirection="column" width="100%"> <box backgroundColor={bg}>
<text fg={color}>{` ${tag.padEnd(4)} `}</text> <text fg={running ? C.green : "white"}>
{content.length > 0 && ( <b>{header}</b>
<text fg={color}>{wrapText(content, Math.max(20, width - 4))}</text>
)}
{msg.reasoning.length > 0 && (
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
)}
</box>
);
}
/** Bottom input row: `>` prompt + buffer on one line. */
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="row" width="100%">
<text fg="cyan">{"> "}</text>
<text fg="white">{shown}</text>
</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. */
@@ -400,3 +946,4 @@ export async function bootTui(state: AppStateRest): Promise<void> {
}, 60); }, 60);
}); });
} }