Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5912a35bbb | ||
|
|
d86d6aeef2 | ||
|
|
30cc579773 | ||
|
|
a54f1644f5 |
@@ -1,3 +1,17 @@
|
|||||||
|
## [1.23.2](https://github.com/asepharyana/zesdex/compare/v1.23.1...v1.23.2) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** sliding-window stream reconciler + UI polish ([d86d6ae](https://github.com/asepharyana/zesdex/commit/d86d6aeef2dcd4924ced14f21502a84f7e916a26))
|
||||||
|
|
||||||
|
## [1.23.1](https://github.com/asepharyana/zesdex/compare/v1.23.0...v1.23.1) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **tui:** repair layout overflow + streaming dup; polish UI/state ([a54f164](https://github.com/asepharyana/zesdex/commit/a54f1644f5943399568194b4f129185a9d841f1d))
|
||||||
|
|
||||||
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
# [1.23.0](https://github.com/asepharyana/zesdex/compare/v1.22.0...v1.23.0) (2026-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "zesdex",
|
"name": "zesdex",
|
||||||
"version": "1.23.0",
|
"version": "1.23.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
|
|||||||
@@ -8,18 +8,35 @@
|
|||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { newStore, userMessage } from "@zesdex/domain";
|
import { newStore, userMessage } from "@zesdex/domain";
|
||||||
import type { TurnEventSink } from "@zesdex/agent";
|
import type { TurnEvent, TurnEventSink } from "@zesdex/agent";
|
||||||
import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts";
|
import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts";
|
||||||
import { createTuiState, type AppStateRest, type TurnRunner } from "./state.ts";
|
import {
|
||||||
|
createTuiState,
|
||||||
|
type AppStateRest,
|
||||||
|
type TurnRunner,
|
||||||
|
pushTranscript,
|
||||||
|
makeChatMessage,
|
||||||
|
appendToLastTranscript,
|
||||||
|
toastInfo,
|
||||||
|
toastError,
|
||||||
|
} from "./state.ts";
|
||||||
|
import { Roles } from "@zesdex/domain";
|
||||||
import { bootTui } from "./ui.tsx";
|
import { bootTui } from "./ui.tsx";
|
||||||
|
|
||||||
/** Build the real turn runner from the wired runtime. */
|
/**
|
||||||
function makeTurnRunner(runtime: WiredRuntime): TurnRunner {
|
* Build the real turn runner from the wired runtime.
|
||||||
|
*
|
||||||
|
* Streams every `TurnEvent` into the transcript and updates live UI state
|
||||||
|
* (status bar token usage, reasoning block, tool call markers, toasts).
|
||||||
|
*/
|
||||||
|
function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner {
|
||||||
return async (text, onEvent) => {
|
return async (text, onEvent) => {
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
state.turnAbort = abort;
|
||||||
// Live sink: forward every event to the UI immediately as it happens.
|
// Live sink: forward every event to the UI immediately as it happens.
|
||||||
const sink: TurnEventSink = {
|
const sink: TurnEventSink = {
|
||||||
push(ev) {
|
push(ev: TurnEvent) {
|
||||||
|
handleEvent(state, ev);
|
||||||
onEvent(ev);
|
onEvent(ev);
|
||||||
},
|
},
|
||||||
drain() {
|
drain() {
|
||||||
@@ -41,6 +58,70 @@ function makeTurnRunner(runtime: WiredRuntime): TurnRunner {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */
|
||||||
|
function handleEvent(state: AppStateRest, ev: TurnEvent): void {
|
||||||
|
switch (ev.kind) {
|
||||||
|
case "stream_token":
|
||||||
|
appendToLastTranscript(state, ev.content, false);
|
||||||
|
break;
|
||||||
|
case "stream_reasoning":
|
||||||
|
appendToLastTranscript(state, ev.content, true);
|
||||||
|
break;
|
||||||
|
case "system_note":
|
||||||
|
pushTranscript(
|
||||||
|
state,
|
||||||
|
makeChatMessage(Roles.System, `[${ev.systemKind}] ${ev.message}`),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "tool_result":
|
||||||
|
pushTranscript(
|
||||||
|
state,
|
||||||
|
makeChatMessage(
|
||||||
|
Roles.Tool,
|
||||||
|
`→ ${ev.tool_name}${ev.is_error ? " (error)" : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "usage":
|
||||||
|
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
|
||||||
|
state.dirty = true;
|
||||||
|
break;
|
||||||
|
case "agent_progress":
|
||||||
|
pushTranscript(
|
||||||
|
state,
|
||||||
|
makeChatMessage(
|
||||||
|
Roles.Tool,
|
||||||
|
`[${ev.progress.agent_name}] ${ev.progress.status}${ev.progress.error ? ` — ${ev.progress.error}` : ev.progress.current_tool ? ` · ${ev.progress.current_tool}` : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "workflow_agent_update":
|
||||||
|
pushTranscript(
|
||||||
|
state,
|
||||||
|
makeChatMessage(
|
||||||
|
Roles.Tool,
|
||||||
|
`[${ev.agent_name}] ${ev.status}${ev.error ? ` — ${ev.error}` : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`));
|
||||||
|
toastError(state, `Turn error: ${ev.message}`);
|
||||||
|
break;
|
||||||
|
case "compacted":
|
||||||
|
pushTranscript(state, makeChatMessage(Roles.System, "Conversation compacted."));
|
||||||
|
break;
|
||||||
|
case "todo_update":
|
||||||
|
toastInfo(state, `TODO updated: ${ev.content.slice(0, 80)}`);
|
||||||
|
break;
|
||||||
|
case "plan_update":
|
||||||
|
toastInfo(state, `Plan updated: ${ev.content.slice(0, 80)}`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Run the interactive OpenTUI with the real agent wired in. */
|
/** Run the interactive OpenTUI with the real agent wired in. */
|
||||||
export async function runTui(): Promise<void> {
|
export async function runTui(): Promise<void> {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
@@ -57,10 +138,17 @@ export async function runTui(): Promise<void> {
|
|||||||
const memoryDir = `${dataDir}/memory`;
|
const memoryDir = `${dataDir}/memory`;
|
||||||
|
|
||||||
const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir);
|
const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir);
|
||||||
state.turnRunner = makeTurnRunner(runtime);
|
state.turnRunner = makeTurnRunner(runtime, state);
|
||||||
state.misc.apiConnected = true;
|
state.misc.apiConnected = true;
|
||||||
// Surface the real effective model/provider in the header.
|
|
||||||
state.settings = { provider: runtime.model } as Record<string, unknown>;
|
// Surface the real effective model / provider / session in the header.
|
||||||
|
const provider = (state.settings.provider as string) || "claude";
|
||||||
|
state.settings = {
|
||||||
|
provider,
|
||||||
|
model: runtime.model,
|
||||||
|
apiBase: runtime.apiBase ?? "",
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
await bootTui(state);
|
await bootTui(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,10 +329,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,15 +360,84 @@ 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 += text;
|
msg.reasoning = reconcileStream(msg.reasoning, text, msg, true);
|
||||||
} else {
|
} else {
|
||||||
msg.content += text;
|
msg.content = reconcileStream(msg.content, text, msg, false);
|
||||||
}
|
}
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile an incoming stream chunk against the accumulated text, deduping the
|
||||||
|
* sliding-window replays that 9router's OpenAI-compatible layer emits.
|
||||||
|
*
|
||||||
|
* Streaming backends disagree on chunk semantics:
|
||||||
|
* - Native OpenAI/Anthropic send small *incremental* deltas.
|
||||||
|
* - 9router/gateways re-send the FULL accumulated text as a repeated window:
|
||||||
|
* each "wave" replays the already-emitted segments from the start, then one
|
||||||
|
* fresh token. So the stream is s1,s2,…,sk then s1,s2,…,sk,s_{k+1} then
|
||||||
|
* s1,…,s_{k+1},s_{k+2} … where s_i + … = the final text.
|
||||||
|
*
|
||||||
|
* Naive concatenation doubles the text (garbled output). We track a per-stream
|
||||||
|
* replay cursor `pos` into `accumulated`: a chunk that continues at `pos` (or
|
||||||
|
* restarts at 0, i.e. begins a fresh wave) is a replay → ignore it and advance
|
||||||
|
* the cursor; only a chunk that matches nothing already present is brand-new
|
||||||
|
* content, which we append at the end and reset the cursor.
|
||||||
|
*
|
||||||
|
* @param accumulated current merged text
|
||||||
|
* @param chunk next chunk from the stream
|
||||||
|
* @param msg the message being streamed (holds the replay cursor)
|
||||||
|
* @param isReasoning whether this targets the reasoning buffer or the content
|
||||||
|
* @returns the updated accumulated text
|
||||||
|
*/
|
||||||
|
export function reconcileStream(
|
||||||
|
accumulated: string,
|
||||||
|
chunk: string,
|
||||||
|
msg: ChatMessageDisplay,
|
||||||
|
isReasoning: boolean,
|
||||||
|
): string {
|
||||||
|
if (chunk.length === 0) return accumulated;
|
||||||
|
const posRef = { pos: isReasoning ? msg._posReasoning : msg._posContent };
|
||||||
|
if (accumulated.length === 0) {
|
||||||
|
accumulated = chunk;
|
||||||
|
posRef.pos = chunk.length;
|
||||||
|
writePos();
|
||||||
|
return accumulated;
|
||||||
|
}
|
||||||
|
// 1) A chunk that continues the current replay cursor is a replayed segment
|
||||||
|
// (it already exists earlier in the text) → skip it, advance the cursor.
|
||||||
|
if (
|
||||||
|
posRef.pos > 0 &&
|
||||||
|
posRef.pos < accumulated.length &&
|
||||||
|
chunk.length <= accumulated.length - posRef.pos &&
|
||||||
|
accumulated.startsWith(chunk, posRef.pos)
|
||||||
|
) {
|
||||||
|
posRef.pos += chunk.length;
|
||||||
|
writePos();
|
||||||
|
return accumulated;
|
||||||
|
}
|
||||||
|
// 2) A chunk that matches at the very start begins a NEW replay wave → skip
|
||||||
|
// it, set the cursor to just past the matched prefix.
|
||||||
|
if (accumulated.startsWith(chunk)) {
|
||||||
|
posRef.pos = chunk.length;
|
||||||
|
writePos();
|
||||||
|
return accumulated;
|
||||||
|
}
|
||||||
|
// 3) Otherwise this is brand-new content: append it and reset the cursor so
|
||||||
|
// the next wave starts fresh.
|
||||||
|
accumulated += chunk;
|
||||||
|
posRef.pos = -1;
|
||||||
|
writePos();
|
||||||
|
return accumulated;
|
||||||
|
|
||||||
|
function writePos(): void {
|
||||||
|
if (isReasoning) msg._posReasoning = posRef.pos;
|
||||||
|
else msg._posContent = posRef.pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── AppStateRest ─────────────────────────────────────────────────── */
|
/* ── AppStateRest ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,6 +475,8 @@ export interface AppStateRest {
|
|||||||
turnRunner: TurnRunner | null;
|
turnRunner: TurnRunner | null;
|
||||||
/** Abort controller for the in-flight turn (driven by AbortTurn). */
|
/** Abort controller for the in-flight turn (driven by AbortTurn). */
|
||||||
turnAbort: AbortController | null;
|
turnAbort: AbortController | null;
|
||||||
|
/** Token usage from the most recent turn (surfaced in the status bar). */
|
||||||
|
lastUsage: { tokensIn: number; tokensOut: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||||
@@ -474,6 +548,7 @@ export function createTuiState(
|
|||||||
pendingSubmit: null,
|
pendingSubmit: null,
|
||||||
turnRunner: null,
|
turnRunner: null,
|
||||||
turnAbort: null,
|
turnAbort: null,
|
||||||
|
lastUsage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
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 } 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";
|
||||||
@@ -163,3 +163,89 @@ describe("toControllerKey (OpenTUI adapter)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reconcileStream (9router sliding-window vs incremental)", () => {
|
||||||
|
function msg(): ChatMessageDisplay {
|
||||||
|
return makeChatMessage(Roles.Assistant, "");
|
||||||
|
}
|
||||||
|
function rec(
|
||||||
|
s: string,
|
||||||
|
chunk: string,
|
||||||
|
m: ChatMessageDisplay,
|
||||||
|
): string {
|
||||||
|
return reconcileStream(s, chunk, m, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("incremental deltas append in order", () => {
|
||||||
|
const m = msg();
|
||||||
|
let s = rec("", "Hello", m);
|
||||||
|
s = rec(s, "!", m);
|
||||||
|
s = rec(s, " How", m);
|
||||||
|
expect(s).toBe("Hello! How");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real 9router wave: replayed segments never double", () => {
|
||||||
|
// Real captured stream (story turn), each fresh token restarts a replay
|
||||||
|
// wave from the head: "Here's a 3-sent" "ence story" " about" " a robo" …
|
||||||
|
const m = msg();
|
||||||
|
const segs: string[] = [
|
||||||
|
"Here's a 3-sent",
|
||||||
|
"ence story",
|
||||||
|
" about",
|
||||||
|
" a robo",
|
||||||
|
"t:\n\nA small repai",
|
||||||
|
"r robo",
|
||||||
|
"t name",
|
||||||
|
"d Bolt",
|
||||||
|
];
|
||||||
|
// Wave 1 (first two segments).
|
||||||
|
let s = "";
|
||||||
|
s = rec(s, segs[0]!, m);
|
||||||
|
s = rec(s, segs[1]!, m);
|
||||||
|
expect(s).toBe("Here's a 3-sentence story");
|
||||||
|
// Wave 2 replays seg0..seg1 then new seg2.
|
||||||
|
for (const g of segs.slice(0, 3)) s = rec(s, g, m);
|
||||||
|
expect(s).toBe("Here's a 3-sentence story about");
|
||||||
|
// Wave 3 replays seg0..seg2 then new seg3.
|
||||||
|
for (const g of segs.slice(0, 4)) s = rec(s, g, m);
|
||||||
|
expect(s).toBe("Here's a 3-sentence story about a robo");
|
||||||
|
// Wave 4 replays 0..3 then new seg4.
|
||||||
|
for (const g of segs.slice(0, 5)) s = rec(s, g, m);
|
||||||
|
expect(s).toBe("Here's a 3-sentence story about a robot:\n\nA small repai");
|
||||||
|
// Wave 5 replays 0..4 then new seg5.
|
||||||
|
for (const g of segs.slice(0, 6)) s = rec(s, g, m);
|
||||||
|
expect(s).toBe(
|
||||||
|
"Here's a 3-sentence story about a robot:\n\nA small repair robo",
|
||||||
|
);
|
||||||
|
// Wave 6 replays 0..5 then new seg6.
|
||||||
|
for (const g of segs.slice(0, 7)) s = rec(s, g, m);
|
||||||
|
// Final wave replays everything, no new content.
|
||||||
|
for (const g of segs.slice(0, 8)) s = rec(s, g, m);
|
||||||
|
expect(s).toBe(
|
||||||
|
"Here's a 3-sentence story about a robot:\n\nA small repair robot named Bolt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty chunk is a no-op", () => {
|
||||||
|
const m = msg();
|
||||||
|
expect(rec("abc", "", m)).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1-char shared letter is NOT treated as a replay", () => {
|
||||||
|
// "Hi! " ends with a space; "How…" starts with 'H' — must append fully.
|
||||||
|
const m = msg();
|
||||||
|
let s = rec("", "Hi! ", m);
|
||||||
|
s = rec(s, "How can I help you today?", m);
|
||||||
|
expect(s).toBe("Hi! How can I help you today?");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("'say hi' captured sequence resolves cleanly", () => {
|
||||||
|
const m = msg();
|
||||||
|
let s = "";
|
||||||
|
// Real captured: "", "", "Hi! 👋 ", "", "", "Hi! 👋 ", "How can I help you today?"
|
||||||
|
s = rec(s, "Hi! 👋 ", m);
|
||||||
|
s = rec(s, "Hi! 👋 ", m); // replay of head
|
||||||
|
s = rec(s, "How can I help you today?", m);
|
||||||
|
expect(s).toBe("Hi! 👋 How can I help you today?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
+192
-80
@@ -1,12 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* OpenTUI React bindings for the Zesdex TUI.
|
* OpenTUI React bindings for the Zesdex TUI.
|
||||||
*
|
*
|
||||||
* Renders `AppStateRest` as an OpenTUI layout and routes keyboard input
|
* Renders `AppStateRest` as a polished OpenTUI layout and routes keyboard
|
||||||
* through the existing `handleKey` → `applyAction` pipeline. This replaces
|
* input through the existing `handleKey` → `applyAction` pipeline.
|
||||||
* the earlier raw-ANSI line renderer with a proper declarative OpenTUI tree.
|
*
|
||||||
|
* Layout (column flex over the full terminal):
|
||||||
|
* 1. Header bar — app · model · session (blue bar)
|
||||||
|
* 2. Transcript — role-coloured chat scrollbox (flex-grow)
|
||||||
|
* 3. Input row — `>` prompt + buffer (same line)
|
||||||
|
* 4. Status bar — turn state · token usage · hints
|
||||||
*
|
*
|
||||||
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
||||||
* is untouched and remains unit-tested.
|
* stays logic-pure and unit-tested; this file only reads it for display.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createCliRenderer } from "@opentui/core";
|
import { createCliRenderer } from "@opentui/core";
|
||||||
@@ -87,7 +92,22 @@ const roleColor: Record<string, string> = {
|
|||||||
[Roles.Tool]: "magenta",
|
[Roles.Tool]: "magenta",
|
||||||
};
|
};
|
||||||
|
|
||||||
const oneLine = (s: string): string => s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
/** Short uppercase role tag shown before each message line. */
|
||||||
|
const roleTag: Record<string, string> = {
|
||||||
|
[Roles.User]: "YOU",
|
||||||
|
[Roles.Assistant]: "AI",
|
||||||
|
[Roles.System]: "SYS",
|
||||||
|
[Roles.Tool]: "TOOL",
|
||||||
|
};
|
||||||
|
|
||||||
|
const oneLine = (s: string): string =>
|
||||||
|
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
|
||||||
|
|
||||||
|
/** Column-align a pair of header cells across the full width. */
|
||||||
|
function spreadCenter(left: string, right: string, width: number): string {
|
||||||
|
const gap = Math.max(1, width - left.length - right.length);
|
||||||
|
return left + " ".repeat(gap) + right;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root OpenTUI component for the Zesdex TUI.
|
* Root OpenTUI component for the Zesdex TUI.
|
||||||
@@ -118,6 +138,8 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dimensions = useTerminalDimensionsHook();
|
const dimensions = useTerminalDimensionsHook();
|
||||||
|
const width = dimensions.width ?? 80;
|
||||||
|
const height = dimensions.height ?? 24;
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -147,32 +169,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
pushTranscript(state, makeChatMessage(Roles.Assistant, ""));
|
pushTranscript(state, makeChatMessage(Roles.Assistant, ""));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await state.turnRunner(captured ?? text, (ev) => {
|
await state.turnRunner(captured ?? text, () => {
|
||||||
switch (ev.kind) {
|
// The turn runner already streams into state via handleEvent;
|
||||||
case "stream_token":
|
// re-render as new tokens arrive.
|
||||||
appendToLastTranscript(state, ev.content, false);
|
setTick((t: number) => t + 1);
|
||||||
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 {
|
} finally {
|
||||||
state.turnInFlightFlag.value = false;
|
state.turnInFlightFlag.value = false;
|
||||||
@@ -185,81 +185,169 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const timer = setInterval(() => poll(), 120);
|
const timer = setInterval(() => poll(), 100);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [state, setTick]);
|
}, [state, setTick]);
|
||||||
|
|
||||||
const model = String(state.settings.provider ?? "?");
|
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
||||||
const header = ` zesdex · ${model} `;
|
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
||||||
|
const headerLeft = ` zesdex · ${model} `;
|
||||||
|
const headerRight = sessionId ? ` session:${sessionId} ` : " ";
|
||||||
|
const header = spreadCenter(headerLeft, headerRight, width);
|
||||||
|
|
||||||
const messages = state.transcriptCache.messages;
|
const messages = state.transcriptCache.messages;
|
||||||
const scrollFromEnd = messages.length - state.scroll.offset;
|
const scrollFromEnd = messages.length - state.scroll.offset;
|
||||||
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
||||||
|
|
||||||
|
const running = state.turnInFlightFlag.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width={dimensions.width} height={dimensions.height}>
|
<box flexDirection="column" width={width} height={height}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<box width={dimensions.width} backgroundColor="blue">
|
<box backgroundColor="blue">
|
||||||
<text fg="white">{header}</text>
|
<text fg="white">{header}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Transcript pane */}
|
{/* Transcript pane */}
|
||||||
<scrollbox flexGrow={1} width={dimensions.width} style={{ backgroundColor: "#0b0f14" }}>
|
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
|
||||||
{visible.map((m, i) => (
|
{visible.map((m, i) => (
|
||||||
<text key={`${m.timestamp}-${i}`} fg={roleColor[m.role] ?? "white"}>
|
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
||||||
{`${(m.role ?? "?").padEnd(9)} `}
|
|
||||||
{oneLine(m.content)}
|
|
||||||
</text>
|
|
||||||
))}
|
))}
|
||||||
{/* Reasoning block for the last assistant message (dimmed). */}
|
{messages.length === 0 && (
|
||||||
{messages.length > 0 &&
|
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
|
||||||
(() => {
|
)}
|
||||||
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>
|
</scrollbox>
|
||||||
|
|
||||||
{/* Overlay or input area */}
|
{/* Autocomplete dropdown (shown above the input row) */}
|
||||||
{state.misc.overlay !== "none" ? <OverlayView state={state} /> : <InputView state={state} />}
|
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
||||||
|
|
||||||
{/* Status footer */}
|
{/* Overlay or input row */}
|
||||||
<text fg={state.turnInFlightFlag.value ? "green" : "gray"}>
|
{state.misc.overlay !== "none" ? (
|
||||||
{state.turnInFlightFlag.value ? "● running" : "● idle"}
|
<OverlayView state={state} />
|
||||||
</text>
|
) : (
|
||||||
|
<InputView state={state} width={width} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<StatusBar state={state} running={running} />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputView(props: { state: AppStateRest }): ReactNode {
|
/** Word-wrap a string to a fixed width (used for plain-text messages). */
|
||||||
const { state } = props;
|
function wrapText(text: string, width: number): string {
|
||||||
const shown = state.input.buffer.slice(
|
const lines: string[] = [];
|
||||||
Math.max(0, state.input.cursor - 40),
|
for (const raw of text.split("\n")) {
|
||||||
state.input.cursor + 20,
|
// Never wrap an empty (or whitespace-only) line.
|
||||||
);
|
if (raw.length === 0) {
|
||||||
const hint =
|
lines.push("");
|
||||||
state.input.autocompleteVisible &&
|
continue;
|
||||||
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
}
|
||||||
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
let line = "";
|
||||||
: "";
|
for (const word of raw.split(/(\s+)/)) {
|
||||||
const toast =
|
if (word === "\n") {
|
||||||
state.misc.toasts.length > 0
|
if (line) lines.push(line);
|
||||||
? ` (${state.misc.toasts[state.misc.toasts.length - 1]?.message})`
|
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 TranscriptMessage(props: {
|
||||||
|
msg: AppStateRest["transcriptCache"]["messages"][number];
|
||||||
|
width: number;
|
||||||
|
}): ReactNode {
|
||||||
|
const { msg, width } = props;
|
||||||
|
const color = roleColor[msg.role] ?? "white";
|
||||||
|
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
|
||||||
|
// 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 =
|
||||||
|
msg.role === Roles.Assistant && !msg.content.trim()
|
||||||
|
? ""
|
||||||
|
: msg.content || "(empty)";
|
||||||
|
const bodyWidth = Math.max(20, width - 6);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width="100%">
|
<box flexDirection="column" width="100%">
|
||||||
<text fg="cyan">{"> "}</text>
|
{/* Role tag header line */}
|
||||||
<text>{shown}</text>
|
<text fg={color}>{` [${tag}]`}</text>
|
||||||
<text fg="gray">{hint}{toast}</text>
|
{/* Indented, word-wrapped body */}
|
||||||
|
{content.length > 0 && (
|
||||||
|
<text fg={msg.role === Roles.Assistant ? "default" : color}>
|
||||||
|
{indent(content, 4, bodyWidth)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{/* Live reasoning (dimmed, kept to one line) */}
|
||||||
|
{msg.reasoning.length > 0 && (
|
||||||
|
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
|
||||||
|
)}
|
||||||
|
{/* Thin separator between messages */}
|
||||||
|
<text fg="#2a3340">{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Indent every wrapped line of `text` by `pad` spaces within `width` columns. */
|
||||||
|
function indent(text: string, pad: number, width: number): string {
|
||||||
|
return wrapText(text, width)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => (l.length ? " ".repeat(pad) + l : ""))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom input row: `❯` prompt + buffer on one line, with a subtle divider. */
|
||||||
|
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
||||||
|
const { state, width } = props;
|
||||||
|
const shown = state.input.buffer.slice(
|
||||||
|
Math.max(0, state.input.cursor - Math.max(10, width - 12)),
|
||||||
|
Math.max(0, state.input.cursor) + 40,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" width="100%">
|
||||||
|
<text fg="#2a3340">{" " + "─".repeat(Math.max(0, width - 4))}</text>
|
||||||
|
<box flexDirection="row" width="100%">
|
||||||
|
<text fg="cyan">{" ❯ "}</text>
|
||||||
|
<text fg="white">{shown}</text>
|
||||||
|
<text fg="gray">{"▏"}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Autocomplete / file-mention dropdown rendered as a bordered panel. */
|
||||||
|
function AutocompleteView(props: { state: AppStateRest }): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
const candidates = state.input.autocompleteCandidates;
|
||||||
|
const kind = state.input.autocompleteKind;
|
||||||
|
const title = kind === "command" ? "commands" : "files";
|
||||||
|
return (
|
||||||
|
<box width="100%" style={{ border: true, padding: 0 }}>
|
||||||
|
<text fg="magenta">
|
||||||
|
{` ${title} (↑/↓ navigate, Tab select, Esc close)\n`}
|
||||||
|
</text>
|
||||||
|
{candidates.slice(0, 10).map((c, i) => (
|
||||||
|
<text key={c} fg={i === state.input.autocompleteIdx ? "cyan" : "white"}>
|
||||||
|
{i === state.input.autocompleteIdx ? "▸ " : " "}
|
||||||
|
{c}
|
||||||
|
{i < candidates.slice(0, 10).length - 1 ? "\n" : ""}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-screen modal overlays (help, diff, editor, model selector, etc.). */
|
||||||
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
||||||
const { state } = props;
|
const { state } = props;
|
||||||
const name = state.misc.overlay;
|
const name = state.misc.overlay;
|
||||||
@@ -274,12 +362,36 @@ function OverlayView(props: { state: AppStateRest }): ReactNode {
|
|||||||
else body = `Overlay: ${name} (Esc to close)`;
|
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={{ border: true, padding: 1 }}>
|
||||||
<text fg="yellow">{body.split("\n").map((l, i) => (<text key={i}>{l}</text>))}</text>
|
<text fg="yellow">{body}</text>
|
||||||
<text fg="gray">{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}</text>
|
<text fg="gray">
|
||||||
|
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bottom status bar: turn state · token usage · live toast/hints. */
|
||||||
|
function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
||||||
|
const { state, running } = props;
|
||||||
|
const left = running ? "● running" : "● idle";
|
||||||
|
const usage = state.lastUsage;
|
||||||
|
const usageStr = usage
|
||||||
|
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
||||||
|
: "in:– · out:–";
|
||||||
|
// Autocomplete hint for the active input.
|
||||||
|
const ac =
|
||||||
|
state.input.autocompleteVisible &&
|
||||||
|
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
||||||
|
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
||||||
|
: "";
|
||||||
|
// Most recent live toast (expires via MiscState).
|
||||||
|
const toasts = state.misc.toasts;
|
||||||
|
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
||||||
|
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
||||||
|
const cell = ` ${left} ${usageStr}${ac}${toast} ${hints} `;
|
||||||
|
return <text fg={toast ? "yellow" : running ? "green" : "gray"}>{cell}</text>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
||||||
export async function bootTui(state: AppStateRest): Promise<void> {
|
export async function bootTui(state: AppStateRest): Promise<void> {
|
||||||
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
|
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
|
||||||
|
|||||||
Reference in New Issue
Block a user