refactor: rombak jadi full CLI/TUI only — hapus semua interface server (api/ws/grpc/web/daemon) + wire TUI ke agent beneran
- Hapus total interfaces: api (REST), ws (WebSocket), grpc, web, daemon (IPC) - Hapus infra server-only yang jadi dead code: infrastructure/ipc + seluruh stack auth (domain/auth, application/auth, persistence/iam, infrastructure/auth jwt/password/oauth_loopback) - TUI (OpenTUI) jadi interface utama: wire ke runSingleProcess() real runtime, ganti mock '(no LLM configured)' dengan turnService.runTurn asli + streaming stream_token/reasoning/tool_result/system_note/error ke transcript - Tampilkan model asli di header (mis. claude-opus-5), abort turn via Ctrl+C - CLI jadi mode headless tambahan: zesdex --headless '<prompt>' one-shot turn - Bersihkan package.json (script tui/headless), tsconfig paths, docker-compose, AGENTS.md; hapus StartOAuth/login stub - verified: tsc 0 err, 70 test pass, build dist/zesdex; headless 'pong' real; TUI interactive render + real turn 'say pong in one word' -> assistant 'pong'
This commit is contained in:
1 parent
34322e492a
commit
7e6ed34f0d
68 files changed
+250
-3661
No files matched your search
@@ -40,7 +40,6 @@ export type Action =
|
||||
| { tag: "LessonAccept"; name: string }
|
||||
| { tag: "LessonReject"; name: string }
|
||||
| { tag: "LessonDelete"; name: string }
|
||||
| { tag: "StartOAuth"; provider: string }
|
||||
| { tag: "OpenEditor"; path: string }
|
||||
| { tag: "McpAdd"; name: string; command: string }
|
||||
| { tag: "ModelList" }
|
||||
@@ -154,16 +153,13 @@ export function applyAction(state: AppStateRest, action: Action): void {
|
||||
toastInfo(state, `Lesson deleted: ${action.name}`);
|
||||
break;
|
||||
|
||||
case "StartOAuth":
|
||||
toastInfo(state, `OAuth login started for ${action.provider}`);
|
||||
break;
|
||||
|
||||
case "McpAdd":
|
||||
toastInfo(state, `MCP server added: ${action.name} (${action.command})`);
|
||||
break;
|
||||
|
||||
case "AbortTurn":
|
||||
state.abortFlag.value = true;
|
||||
state.turnAbort?.abort();
|
||||
toastInfo(state, "Aborting current turn...");
|
||||
break;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export type Command =
|
||||
| { tag: "McpOpen" }
|
||||
| { tag: "Clear" }
|
||||
| { tag: "ClearConfirm" }
|
||||
| { tag: "Login"; provider: string }
|
||||
| { tag: "Edit"; path: string }
|
||||
| { tag: "McpAdd"; name: string; command: string }
|
||||
| { tag: "ModelList" }
|
||||
@@ -41,8 +40,6 @@ export function parseCommand(text: string): Command {
|
||||
return { tag: "Quit" };
|
||||
case "/clear":
|
||||
return arg1 === "" ? { tag: "ClearConfirm" } : { tag: "Clear" };
|
||||
case "/login":
|
||||
return arg1 === "" ? { tag: "Login", provider: "" } : { tag: "Login", provider: arg1 };
|
||||
case "/edit":
|
||||
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
|
||||
case "/mcp":
|
||||
@@ -86,8 +83,6 @@ export function applyCommand(cmd: Command): Action[] {
|
||||
return [{ tag: "SystemNote", kind: "clear", message: "Transcript cleared." }];
|
||||
case "ClearConfirm":
|
||||
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
|
||||
case "Login":
|
||||
return [{ tag: "StartOAuth", provider: cmd.provider }];
|
||||
case "Edit":
|
||||
return [{ tag: "OpenEditor", path: cmd.path }];
|
||||
case "McpAdd":
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex TUI — standalone entry point.
|
||||
* Mirrors the default (TUI) mode of the Rust gateway.
|
||||
*/
|
||||
import { runTui } from "./run.ts";
|
||||
|
||||
void runTui();
|
||||
+53
-10
@@ -1,22 +1,65 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Zesdex TUI — OpenTUI entry point.
|
||||
* Zesdex TUI — bootstrapper / composition root.
|
||||
*
|
||||
* Boots the OpenTUI CLI renderer and mounts `ZesdexApp`, which routes input
|
||||
* through `handleKey` → `applyAction`. Replaced the earlier raw-mode stdin
|
||||
* event loop with OpenTUI's native renderer/keyboard handling.
|
||||
* Wires the real agent runtime (via `runSingleProcess` from the CLI compose
|
||||
* root) into the OpenTUI app, and installs a `turnRunner` that executes real
|
||||
* turns against the LLM + tool executor, streaming events into the transcript.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import { createTuiState } from "./state.ts";
|
||||
import { newStore, userMessage, type TurnEventSink } from "@zesdex/domain";
|
||||
import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts";
|
||||
import { createTuiState, type AppStateRest, type TurnRunner } from "./state.ts";
|
||||
import { bootTui } from "./ui.tsx";
|
||||
|
||||
/** Run the interactive OpenTUI until the user quits. */
|
||||
/** Build the real turn runner from the wired runtime. */
|
||||
function makeTurnRunner(runtime: WiredRuntime): TurnRunner {
|
||||
return async (text, onEvent) => {
|
||||
const abort = new AbortController();
|
||||
// Live sink: forward every event to the UI immediately as it happens.
|
||||
const sink: TurnEventSink = {
|
||||
push(ev) {
|
||||
onEvent(ev);
|
||||
},
|
||||
drain() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const messages = [userMessage(text)];
|
||||
await runtime.turnService.runTurn({
|
||||
messages,
|
||||
session_dir: runtime.store.base_dir,
|
||||
workspace_roots: [process.cwd()],
|
||||
turn_events: sink,
|
||||
in_flight: { value: false },
|
||||
abort,
|
||||
api_key: runtime.apiKey,
|
||||
model: runtime.model,
|
||||
api_base: runtime.apiBase,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the interactive OpenTUI with the real agent wired in. */
|
||||
export async function runTui(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
|
||||
const sessionDir = `${dataDir}/sessions/${Date.now()}`;
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`);
|
||||
// Build the real runtime once (LLM client + tool executor + turn service).
|
||||
const runtime = await runSingleProcess();
|
||||
const store = newStore();
|
||||
void store;
|
||||
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const sessionLabel = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
|
||||
const sessionDir = `${dataDir}/sessions/${sessionLabel}`;
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
const memoryDir = `${dataDir}/memory`;
|
||||
|
||||
const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir);
|
||||
state.turnRunner = makeTurnRunner(runtime);
|
||||
state.misc.apiConnected = true;
|
||||
// Surface the real effective model/provider in the header.
|
||||
state.settings = { provider: runtime.model } as Record<string, unknown>;
|
||||
|
||||
await bootTui(state);
|
||||
}
|
||||
@@ -7,7 +7,18 @@
|
||||
* unit-testable in Bun.
|
||||
*/
|
||||
|
||||
import { Roles, type Role } from "@zesdex/domain";
|
||||
import { Roles, type Role, type TurnEvent } from "@zesdex/domain";
|
||||
|
||||
/**
|
||||
* Driver for executing a single agent turn in the TUI. Implemented by the
|
||||
* composition root (`run.ts` → `runSingleProcess`), keeping the TUI logic
|
||||
* layer free of direct wiring. Returns a promise that resolves when the turn
|
||||
* (including all tool calls) finishes or is aborted.
|
||||
*/
|
||||
export type TurnRunner = (
|
||||
text: string,
|
||||
onEvent: (ev: TurnEvent) => void,
|
||||
) => Promise<void>;
|
||||
|
||||
/* ── Input state ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -23,9 +34,6 @@ export const COMMANDS: string[] = [
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
@@ -390,6 +398,10 @@ export interface AppStateRest {
|
||||
renderWidthAtCache: number;
|
||||
mentionFiles: string[];
|
||||
pendingSubmit: string | null; // set when a turn should be spawned
|
||||
/** Real agent turn driver (wired by the composition root). */
|
||||
turnRunner: TurnRunner | null;
|
||||
/** Abort controller for the in-flight turn (driven by AbortTurn). */
|
||||
turnAbort: AbortController | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||
@@ -459,6 +471,8 @@ export function createTuiState(
|
||||
renderWidthAtCache: 0,
|
||||
mentionFiles: [],
|
||||
pendingSubmit: null,
|
||||
turnRunner: null,
|
||||
turnAbort: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+79
-15
@@ -119,27 +119,78 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
||||
|
||||
const dimensions = useTerminalDimensionsHook();
|
||||
|
||||
// Local-only "turn drain": when a turn is submitted without a live LLM,
|
||||
// simulate a short thinking delay then append a note. Real wiring happens
|
||||
// in the CLI. Replaced the old raw-loop poll.
|
||||
// Turn driver: when a turn is submitted, run it against the real agent and
|
||||
// stream every event into the transcript as it happens.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (state.pendingSubmit != null) {
|
||||
const text = state.pendingSubmit;
|
||||
let turnRunning = false;
|
||||
const poll = async () => {
|
||||
if (turnRunning) return;
|
||||
const text = state.pendingSubmit;
|
||||
if (text == null) return;
|
||||
if (state.turnRunner == null) {
|
||||
appendToLastTranscript(
|
||||
state,
|
||||
"\n(no agent runtime — run via `zesdex` on the CLI / ensure settings)",
|
||||
false,
|
||||
);
|
||||
state.pendingSubmit = null;
|
||||
state.turnInFlightFlag.value = true;
|
||||
setTimeout(() => {
|
||||
state.turnInFlightFlag.value = false;
|
||||
appendToLastTranscript(state, `(no LLM configured — processed: "${text}")`, false);
|
||||
setTick((x: number) => x + 1);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
setTick((x: number) => x + 1);
|
||||
}, 120);
|
||||
turnRunning = true;
|
||||
const captured = state.pendingSubmit;
|
||||
state.pendingSubmit = null;
|
||||
state.turnInFlightFlag.value = true;
|
||||
const abort = new AbortController();
|
||||
state.turnAbort = abort;
|
||||
|
||||
// Open an assistant message we stream into.
|
||||
pushTranscript(state, makeChatMessage(Roles.Assistant, ""));
|
||||
|
||||
try {
|
||||
await state.turnRunner(captured ?? text, (ev) => {
|
||||
switch (ev.kind) {
|
||||
case "stream_token":
|
||||
appendToLastTranscript(state, ev.content, false);
|
||||
break;
|
||||
case "stream_reasoning":
|
||||
appendToLastTranscript(state, ev.content, true);
|
||||
break;
|
||||
case "system_note":
|
||||
pushTranscript(
|
||||
state,
|
||||
makeChatMessage(Roles.System, `[${ev.systemKind}] ${ev.message}`),
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`));
|
||||
break;
|
||||
case "tool_result":
|
||||
pushTranscript(
|
||||
state,
|
||||
makeChatMessage(Roles.Tool, `⇄ ${ev.tool_name} · ${ev.is_error ? "err" : "ok"}`),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
state.turnInFlightFlag.value = false;
|
||||
state.turnAbort = null;
|
||||
state.abortFlag.value = false;
|
||||
// Re-prime the next poll so queued turns run.
|
||||
setTimeout(() => {
|
||||
turnRunning = false;
|
||||
setTick((x: number) => x + 1);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
const timer = setInterval(() => poll(), 120);
|
||||
return () => clearInterval(timer);
|
||||
}, [state, setTick]);
|
||||
|
||||
const header = ` zesdex · ${state.settings.provider ?? "?"} `;
|
||||
const model = String(state.settings.provider ?? "?");
|
||||
const header = ` zesdex · ${model} `;
|
||||
const messages = state.transcriptCache.messages;
|
||||
const scrollFromEnd = messages.length - state.scroll.offset;
|
||||
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
||||
@@ -159,6 +210,19 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
||||
{oneLine(m.content)}
|
||||
</text>
|
||||
))}
|
||||
{/* Reasoning block for the last assistant message (dimmed). */}
|
||||
{messages.length > 0 &&
|
||||
(() => {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.role === Roles.Assistant && last.reasoning.length > 0) {
|
||||
return (
|
||||
<text fg="gray">
|
||||
{`reasoning ${oneLine(last.reasoning)}`}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</scrollbox>
|
||||
|
||||
{/* Overlay or input area */}
|
||||
|
||||
Reference in new issue
Block a user