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.
This commit is contained in:
@@ -183,7 +183,42 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Model selector overlay ───────────────────────────────────────────
|
||||||
|
if (state.misc.overlay === "model_selector") {
|
||||||
|
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
|
||||||
|
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
|
||||||
|
if (code === KeyCode.Up) {
|
||||||
|
const n = Object.keys(state.appConfig.providers).length || 3;
|
||||||
|
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, false);
|
||||||
|
return mark(state);
|
||||||
|
}
|
||||||
|
if (code === KeyCode.Down) {
|
||||||
|
const n = Object.keys(state.appConfig.providers).length || 3;
|
||||||
|
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, true);
|
||||||
|
return mark(state);
|
||||||
|
}
|
||||||
|
if (code === KeyCode.Enter) {
|
||||||
|
// Apply the selected provider and dismiss.
|
||||||
|
const names = Object.keys(state.appConfig.providers);
|
||||||
|
const picked = names[state.misc.selectedIndex] ?? names[0];
|
||||||
|
if (picked) {
|
||||||
|
state.settings = { ...state.settings, provider: picked };
|
||||||
|
toastInfo(state, `Provider set to ${picked}`);
|
||||||
|
}
|
||||||
|
state.misc.overlay = "none";
|
||||||
|
state.misc.selectedIndex = 0;
|
||||||
|
return mark(state);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||||
|
// Slash commands bypass overlay guards — always process.
|
||||||
|
if (code === KeyCode.Enter && state.input.buffer.startsWith("/")) {
|
||||||
|
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" }];
|
||||||
@@ -223,9 +258,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 +281,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 +291,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 {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
-22
@@ -58,6 +58,25 @@ function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append an entry to the bounded live-activity breadcrumb. */
|
||||||
|
function addActivity(
|
||||||
|
state: AppStateRest,
|
||||||
|
entry: { label: string; detail?: string; kind: "tool" | "agent" | "error" },
|
||||||
|
): void {
|
||||||
|
const activity = state.misc.activity;
|
||||||
|
// Dedupe consecutive repeats of the same tool so `read read read` collapses.
|
||||||
|
const last = activity[activity.length - 1];
|
||||||
|
const key = `${entry.label}:${entry.kind}`;
|
||||||
|
if (last && `${last.label}:${last.kind}` === key && last.detail === entry.detail) {
|
||||||
|
last.count = (last.count ?? 1) + 1;
|
||||||
|
} else {
|
||||||
|
activity.push({ ...entry, count: 1 });
|
||||||
|
}
|
||||||
|
// Keep the breadcrumb tight (max 12 entries).
|
||||||
|
while (activity.length > 12) activity.shift();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */
|
/** 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 +93,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":
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const COMMANDS: string[] = [
|
|||||||
"/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. */
|
||||||
@@ -194,6 +196,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;
|
||||||
@@ -277,6 +285,20 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Misc state ───────────────────────────────────────────────────── */
|
/* ── Misc state ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The "miscellaneous" slice of app state. */
|
/** The "miscellaneous" slice of app state. */
|
||||||
@@ -296,6 +318,8 @@ 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[] = [];
|
||||||
|
|
||||||
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||||
this.toasts.push(toast);
|
this.toasts.push(toast);
|
||||||
@@ -477,6 +501,8 @@ export interface AppStateRest {
|
|||||||
turnAbort: AbortController | null;
|
turnAbort: AbortController | null;
|
||||||
/** Token usage from the most recent turn (surfaced in the status bar). */
|
/** Token usage from the most recent turn (surfaced in the status bar). */
|
||||||
lastUsage: { tokensIn: number; tokensOut: number } | null;
|
lastUsage: { tokensIn: number; tokensOut: number } | null;
|
||||||
|
/** Monotonic boot time (ms) used for the status-bar uptime + spinner. */
|
||||||
|
bootedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
||||||
@@ -487,6 +513,7 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
|||||||
Ctrl+Y Copy last assistant message
|
Ctrl+Y Copy last assistant message
|
||||||
Esc Abort turn / Close overlay
|
Esc Abort turn / Close overlay
|
||||||
Tab Autocomplete
|
Tab Autocomplete
|
||||||
|
Alt+D Show git diff
|
||||||
|
|
||||||
─── Navigation ───
|
─── Navigation ───
|
||||||
↑ / ↓ History browse / Overlay navigate
|
↑ / ↓ History browse / Overlay navigate
|
||||||
@@ -496,20 +523,24 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
|
|||||||
|
|
||||||
─── Overlays ───
|
─── Overlays ───
|
||||||
/help Show this help
|
/help Show this help
|
||||||
/settings Open settings overlay
|
|
||||||
/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
|
/model Open model selector
|
||||||
/compact Compact conversation
|
/compact Compact conversation
|
||||||
/clear Clear transcript
|
/clear Clear transcript
|
||||||
/rewind Rewind conversation history
|
|
||||||
|
|
||||||
─── Editor Mode ───
|
─── Editor Mode ───
|
||||||
/edit <path> Open file for inline editing
|
/edit <path> Open file for inline editing
|
||||||
Ctrl+S Save changes
|
Ctrl+S Save changes (not yet wired)
|
||||||
Esc Dismiss editor`;
|
Esc Dismiss editor
|
||||||
|
|
||||||
|
─── Legend ───
|
||||||
|
● running turn is being executed
|
||||||
|
● idle waiting for input
|
||||||
|
in:/out: token usage of the latest turn`;
|
||||||
|
|
||||||
/** Construct fresh TUI state. */
|
/** Construct fresh TUI state. */
|
||||||
export function createTuiState(
|
export function createTuiState(
|
||||||
@@ -549,6 +580,7 @@ export function createTuiState(
|
|||||||
turnRunner: null,
|
turnRunner: null,
|
||||||
turnAbort: null,
|
turnAbort: null,
|
||||||
lastUsage: null,
|
lastUsage: null,
|
||||||
|
bootedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,43 @@ describe("handleKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleKey model selector", () => {
|
||||||
|
test("Enter applies the currently selected provider and closes", () => {
|
||||||
|
const s = freshState();
|
||||||
|
s.misc.overlay = "model_selector";
|
||||||
|
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||||
|
s.settings = { provider: "claude" };
|
||||||
|
|
||||||
|
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||||
|
// The guard mutates state in place (a no-action mark), not via Action[].
|
||||||
|
expect(actions).toEqual([]);
|
||||||
|
expect(s.settings.provider).toBe("claude"); // index 0 unchanged
|
||||||
|
expect(s.misc.overlay as unknown as string).toBe("none"); // dismissed
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Down moves selection forward, wraps at the last one", () => {
|
||||||
|
const s = freshState();
|
||||||
|
s.misc.overlay = "model_selector";
|
||||||
|
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||||
|
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
||||||
|
expect(s.misc.selectedIndex).toBe(1);
|
||||||
|
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
||||||
|
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s);
|
||||||
|
expect(s.misc.selectedIndex).toBe(0); // wraps 2 → 0
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enter on a non-first selection applies that provider", () => {
|
||||||
|
const s = freshState();
|
||||||
|
s.misc.overlay = "model_selector";
|
||||||
|
s.appConfig = { providers: { claude: {}, zen: {}, router: {} } };
|
||||||
|
s.settings = { provider: "claude" };
|
||||||
|
handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); // → zen
|
||||||
|
handleKey({ code: "Enter", ctrl: false, alt: false }, s);
|
||||||
|
expect(s.settings.provider).toBe("zen");
|
||||||
|
expect(s.misc.overlay as unknown as string).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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 });
|
||||||
|
|||||||
+628
-174
@@ -5,10 +5,11 @@
|
|||||||
* input through the existing `handleKey` → `applyAction` pipeline.
|
* input through the existing `handleKey` → `applyAction` pipeline.
|
||||||
*
|
*
|
||||||
* Layout (column flex over the full terminal):
|
* Layout (column flex over the full terminal):
|
||||||
* 1. Header bar — app · model · session (blue bar)
|
* 1. Header bar — logo · model · session · live turn spinner
|
||||||
* 2. Transcript — role-coloured chat scrollbox (flex-grow)
|
* 2. Transcript — role-coloured chat scrollbox (markdown-lite, flex-grow)
|
||||||
* 3. Input row — `>` prompt + buffer (same line)
|
* 3. Activity bar — collapsed breadcrumb of the running turn's tools/agents
|
||||||
* 4. Status bar — turn state · token usage · hints
|
* 4. Input row — `❯` prompt + buffer + cursor
|
||||||
|
* 5. Status bar — turn state · token usage · uptime · toast · hints
|
||||||
*
|
*
|
||||||
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
||||||
* stays logic-pure and unit-tested; this file only reads it for display.
|
* stays logic-pure and unit-tested; this file only reads it for display.
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
|
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { AppStateRest } from "./state.ts";
|
import type { AppStateRest, ChatMessageDisplay } from "./state.ts";
|
||||||
import { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts";
|
import { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts";
|
||||||
import { Roles } from "@zesdex/domain";
|
import { Roles } from "@zesdex/domain";
|
||||||
import { handleKey, KeyCode } from "./controller.ts";
|
import { handleKey, KeyCode } from "./controller.ts";
|
||||||
@@ -84,13 +85,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 +112,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 +126,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).
|
||||||
@@ -132,6 +344,11 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
applyAction(state, act);
|
applyAction(state, act);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
// Clear input buffer after command dispatch (commands like /model, /help
|
||||||
|
// open overlays but don't clear the buffer via submit()).
|
||||||
|
if (zk.code === KeyCode.Enter && state.input.buffer.length > 0) {
|
||||||
|
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 +357,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 +396,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
|
|||||||
state.turnInFlightFlag.value = false;
|
state.turnInFlightFlag.value = false;
|
||||||
state.turnAbort = null;
|
state.turnAbort = null;
|
||||||
state.abortFlag.value = false;
|
state.abortFlag.value = false;
|
||||||
// Re-prime the next poll so queued turns run.
|
// Keep the last activity breadcrumb visible briefly, then clear it on
|
||||||
|
// the next turn start (see the start of poll()).
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
state.misc.activity = [];
|
||||||
turnRunning = false;
|
turnRunning = false;
|
||||||
setTick((x: number) => x + 1);
|
setTick((x: number) => x + 1);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -189,207 +409,440 @@ 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 visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
|
||||||
|
|
||||||
const running = state.turnInFlightFlag.value;
|
// Modal overlays take the full body (below the header, above the status bar),
|
||||||
|
// replacing the transcript + input so their content is never cramped.
|
||||||
|
const body = overlayActive ? (
|
||||||
|
<OverlayView state={state} width={width} height={height} />
|
||||||
|
) : (
|
||||||
|
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
|
||||||
|
<scrollbox
|
||||||
|
flexGrow={1}
|
||||||
|
flexShrink={1}
|
||||||
|
minHeight={0}
|
||||||
|
style={{ backgroundColor: C.bg }}
|
||||||
|
stickyScroll
|
||||||
|
stickyStart="bottom"
|
||||||
|
>
|
||||||
|
<TranscriptView state={state} width={width} />
|
||||||
|
</scrollbox>
|
||||||
|
{/* Autocomplete dropdown (shown above the input row) */}
|
||||||
|
{state.input.autocompleteVisible && <AutocompleteView state={state} width={width} />}
|
||||||
|
{/* Live activity breadcrumb of the running turn */}
|
||||||
|
<ActivityBar state={state} running={running} />
|
||||||
|
<InputView state={state} width={width} />
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width={width} height={height}>
|
<box flexDirection="column" width={width} height={height}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<box backgroundColor="blue">
|
<HeaderView state={state} running={running} width={width} />
|
||||||
<text fg="white">{header}</text>
|
{body}
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Transcript pane */}
|
|
||||||
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
|
|
||||||
{visible.map((m, i) => (
|
|
||||||
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
|
||||||
))}
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
|
|
||||||
)}
|
|
||||||
</scrollbox>
|
|
||||||
|
|
||||||
{/* Autocomplete dropdown (shown above the input row) */}
|
|
||||||
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
|
||||||
|
|
||||||
{/* Overlay or input row */}
|
|
||||||
{state.misc.overlay !== "none" ? (
|
|
||||||
<OverlayView state={state} />
|
|
||||||
) : (
|
|
||||||
<InputView state={state} width={width} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<StatusBar state={state} running={running} />
|
<StatusBar state={state} running={running} width={width} />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Word-wrap a string to a fixed width (used for plain-text messages). */
|
/* ── Header ───────────────────────────────────────────────────────── */
|
||||||
function wrapText(text: string, width: number): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const raw of text.split("\n")) {
|
|
||||||
// Never wrap an empty (or whitespace-only) line.
|
|
||||||
if (raw.length === 0) {
|
|
||||||
lines.push("");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let line = "";
|
|
||||||
for (const word of raw.split(/(\s+)/)) {
|
|
||||||
if (word === "\n") {
|
|
||||||
if (line) lines.push(line);
|
|
||||||
line = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.length + word.length > width) {
|
|
||||||
if (line) lines.push(line);
|
|
||||||
line = word; // long word spills; next iteration handles it
|
|
||||||
} else {
|
|
||||||
line += word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line) lines.push(line);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One chat message rendered in the transcript. */
|
function HeaderView(props: {
|
||||||
function TranscriptMessage(props: {
|
state: AppStateRest;
|
||||||
msg: AppStateRest["transcriptCache"]["messages"][number];
|
running: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const { msg, width } = props;
|
const { state, running, width } = props;
|
||||||
const color = roleColor[msg.role] ?? "white";
|
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
||||||
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
|
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
||||||
// Assistant messages legitimately have no body while a tool call is being
|
const shortId = sessionId.length > 12 ? sessionId.slice(0, 11) : sessionId;
|
||||||
// made (or mid-stream) — don't fill that with a misleading placeholder.
|
const glyph = running ? spinner(state) : "◆";
|
||||||
const content =
|
const left = ` ${glyph} zesdex · ${model} `;
|
||||||
msg.role === Roles.Assistant && !msg.content.trim()
|
const right = sessionId ? ` session:${shortId} ` : " ";
|
||||||
? ""
|
const header = spreadCenter(left, right, width);
|
||||||
: msg.content || "(empty)";
|
const bg = running ? C.headerA : C.headerB;
|
||||||
const bodyWidth = Math.max(20, width - 6);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width="100%">
|
<box backgroundColor={bg}>
|
||||||
{/* Role tag header line */}
|
<text fg={running ? C.green : "white"}>
|
||||||
<text fg={color}>{` [${tag}]`}</text>
|
<b>{header}</b>
|
||||||
{/* Indented, word-wrapped body */}
|
|
||||||
{content.length > 0 && (
|
|
||||||
<text fg={msg.role === Roles.Assistant ? "default" : color}>
|
|
||||||
{indent(content, 4, bodyWidth)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
{/* Live reasoning (dimmed, kept to one line) */}
|
|
||||||
{msg.reasoning.length > 0 && (
|
|
||||||
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
|
|
||||||
)}
|
|
||||||
{/* Thin separator between messages */}
|
|
||||||
<text fg="#2a3340">{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Indent every wrapped line of `text` by `pad` spaces within `width` columns. */
|
|
||||||
function indent(text: string, pad: number, width: number): string {
|
|
||||||
return wrapText(text, width)
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => (l.length ? " ".repeat(pad) + l : ""))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bottom input row: `❯` prompt + buffer on one line, with a subtle divider. */
|
|
||||||
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
|
||||||
const { state, width } = props;
|
|
||||||
const shown = state.input.buffer.slice(
|
|
||||||
Math.max(0, state.input.cursor - Math.max(10, width - 12)),
|
|
||||||
Math.max(0, state.input.cursor) + 40,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" width="100%">
|
|
||||||
<text fg="#2a3340">{" " + "─".repeat(Math.max(0, width - 4))}</text>
|
|
||||||
<box flexDirection="row" width="100%">
|
|
||||||
<text fg="cyan">{" ❯ "}</text>
|
|
||||||
<text fg="white">{shown}</text>
|
|
||||||
<text fg="gray">{"▏"}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Autocomplete / file-mention dropdown rendered as a bordered panel. */
|
|
||||||
function AutocompleteView(props: { state: AppStateRest }): ReactNode {
|
|
||||||
const { state } = props;
|
|
||||||
const candidates = state.input.autocompleteCandidates;
|
|
||||||
const kind = state.input.autocompleteKind;
|
|
||||||
const title = kind === "command" ? "commands" : "files";
|
|
||||||
return (
|
|
||||||
<box width="100%" style={{ border: true, padding: 0 }}>
|
|
||||||
<text fg="magenta">
|
|
||||||
{` ${title} (↑/↓ navigate, Tab select, Esc close)\n`}
|
|
||||||
</text>
|
</text>
|
||||||
{candidates.slice(0, 10).map((c, i) => (
|
</box>
|
||||||
<text key={c} fg={i === state.input.autocompleteIdx ? "cyan" : "white"}>
|
);
|
||||||
{i === state.input.autocompleteIdx ? "▸ " : " "}
|
}
|
||||||
{c}
|
|
||||||
{i < candidates.slice(0, 10).length - 1 ? "\n" : ""}
|
/* ── Transcript ───────────────────────────────────────────────────── */
|
||||||
</text>
|
|
||||||
|
function TranscriptView(props: { state: AppStateRest; width: number }): ReactNode {
|
||||||
|
const { state, width } = props;
|
||||||
|
const messages = state.transcriptCache.messages;
|
||||||
|
const scrollFromEnd = messages.length - state.scroll.offset;
|
||||||
|
const visible = messages.slice(
|
||||||
|
Math.max(0, scrollFromEnd - 40),
|
||||||
|
scrollFromEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<text fg={C.dim}>
|
||||||
|
{`\n ─ no messages yet. Type a message below (or /help) ─`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<box flexDirection="column">
|
||||||
|
{visible.map((m, i) => (
|
||||||
|
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
||||||
))}
|
))}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full-screen modal overlays (help, diff, editor, model selector, etc.). */
|
/* ── Activity bar (running turn) ──────────────────────────────────── */
|
||||||
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
|
||||||
const { state } = props;
|
function ActivityBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
||||||
const name = state.misc.overlay;
|
const { state, running } = props;
|
||||||
let body = "";
|
const activity = state.misc.activity;
|
||||||
if (name === "quit_confirm") body = "Quit? (y=yes, n=no) Ctrl+C again / /quit to confirm";
|
if (!running || activity.length === 0) return null;
|
||||||
else if (name === "help") body = state.helpText;
|
const line = ` ${spinner(state)} ${activity.map((a) => label(a)).join(" · ")} `;
|
||||||
else if (name === "diff")
|
|
||||||
body = state.misc.diffContent
|
|
||||||
.split("\n")
|
|
||||||
.slice(state.misc.diffScroll, state.misc.diffScroll + 20)
|
|
||||||
.join("\n");
|
|
||||||
else body = `Overlay: ${name} (Esc to close)`;
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
|
<box flexDirection="column" width="100%" style={{ backgroundColor: C.surface }}>
|
||||||
<text fg="yellow">{body}</text>
|
<text fg={C.magenta}>
|
||||||
<text fg="gray">
|
{line}
|
||||||
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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%" flexShrink={0}>
|
||||||
|
<text fg={C.accent}>
|
||||||
|
<b>{` ❯ `}</b>
|
||||||
|
</text>
|
||||||
|
{hasContent ? (
|
||||||
|
<text fg={C.fg} flexGrow={1}>{shown}</text>
|
||||||
|
) : (
|
||||||
|
<text fg={C.dim} flexGrow={1}>
|
||||||
|
Ask Zesdex to help…
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={C.accent} flexShrink={0}>{` ▏`}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom status bar: turn state · token usage · live toast/hints. */
|
/* ── Autocomplete ─────────────────────────────────────────────────── */
|
||||||
function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
|
||||||
const { state, running } = props;
|
function AutocompleteView(props: { state: AppStateRest; width: number }): ReactNode {
|
||||||
|
const { state, width } = 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);
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
style={{
|
||||||
|
border: true,
|
||||||
|
borderColor: C.border,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={C.magenta}>
|
||||||
|
<b>{` ${title}`}</b>
|
||||||
|
</text>
|
||||||
|
{visible.map((c, i) => (
|
||||||
|
<text key={c} fg={i === selected ? C.accent : C.fg}>
|
||||||
|
{i === selected ? `${"▸ "}${c}${" ".repeat(Math.max(0, width - c.length - 4))}` : ` ${c}`}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text fg={C.dim}>{` ↑/↓ navigate · Tab accept · Esc close`}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlays ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function OverlayView(props: {
|
||||||
|
state: AppStateRest;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
const name = state.misc.overlay;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "quit_confirm":
|
||||||
|
return (
|
||||||
|
<ConfirmOverlay
|
||||||
|
title="Quit Zesdex?"
|
||||||
|
body=" Are you sure you want to quit?\n\n Any in-flight turn will be aborted."
|
||||||
|
hint="y=yes n=no Esc=cancel"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "clear_confirm":
|
||||||
|
return (
|
||||||
|
<ConfirmOverlay
|
||||||
|
title="Clear transcript?"
|
||||||
|
body=" This clears the visible conversation from the transcript view."
|
||||||
|
hint="y=yes n=no Esc=cancel"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "help":
|
||||||
|
return <HelpOverlay state={state} />;
|
||||||
|
case "diff":
|
||||||
|
return <DiffOverlay state={state} />;
|
||||||
|
case "model_selector":
|
||||||
|
return <ModelSelectorOverlay state={state} />;
|
||||||
|
case "editor":
|
||||||
|
return <EditorOverlay state={state} />;
|
||||||
|
default:
|
||||||
|
return <PlaceholderOverlay name={name} state={state} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode {
|
||||||
|
const { title, body, hint } = props;
|
||||||
|
return (
|
||||||
|
<OverlayFrame title={title}>
|
||||||
|
<text fg={C.fg}>{body}</text>
|
||||||
|
<text fg={C.dim}>{` ${hint}`}</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpOverlay(props: { state: AppStateRest }): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
return (
|
||||||
|
<OverlayFrame title="Keyboard Shortcuts">
|
||||||
|
<text fg={C.fg}>{state.helpText}</text>
|
||||||
|
<text fg={C.dim}>{" Esc or /help to close"}</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffOverlay(props: { state: AppStateRest }): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
const lines = state.misc.diffContent.split("\n");
|
||||||
|
const start = state.misc.diffScroll;
|
||||||
|
const slice = lines.slice(start, start + 18);
|
||||||
|
return (
|
||||||
|
<OverlayFrame title="Git Diff">
|
||||||
|
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
|
||||||
|
{slice.map((l, i) => {
|
||||||
|
const fg = l.startsWith("+")
|
||||||
|
? C.green
|
||||||
|
: l.startsWith("-")
|
||||||
|
? C.red
|
||||||
|
: l.startsWith("@@")
|
||||||
|
? C.cyan
|
||||||
|
: C.dim;
|
||||||
|
return (
|
||||||
|
<text key={i} fg={fg}>
|
||||||
|
{l.length ? l : " "}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</scrollbox>
|
||||||
|
<text fg={C.dim}>
|
||||||
|
{` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`}
|
||||||
|
</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorOverlay(props: { state: AppStateRest }): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
const names = Object.keys(state.appConfig.providers);
|
||||||
|
const list = names.length ? names : ["claude", "zen", "router"];
|
||||||
|
const idx = Math.min(Math.max(0, state.misc.selectedIndex), list.length - 1);
|
||||||
|
const current = String(state.settings.provider ?? "");
|
||||||
|
const selected = idx;
|
||||||
|
const body = list
|
||||||
|
.map((p, i) => {
|
||||||
|
const isSel = i === selected;
|
||||||
|
const active = p === current;
|
||||||
|
const suffix = active ? " (active)" : "";
|
||||||
|
return `${isSel ? "▸ " : " "}${pad(p, 15)}${suffix}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
return (
|
||||||
|
<OverlayFrame title="Select Model / Provider">
|
||||||
|
<text fg={C.fg}>
|
||||||
|
{body}
|
||||||
|
</text>
|
||||||
|
<text fg={C.dim}>{" ↑/↓ select · Enter apply · Esc close"}</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorOverlay(props: { state: AppStateRest }): ReactNode {
|
||||||
|
const { state } = props;
|
||||||
|
const ed = state.misc.editor;
|
||||||
|
if (!ed) return <OverlayFrame title="Editor"><text fg={C.fg}>No buffer.</text></OverlayFrame>;
|
||||||
|
const lines = ed.content.split("\n");
|
||||||
|
const start = Math.max(0, ed.cursor - 5);
|
||||||
|
const slice = lines.slice(start, start + 12);
|
||||||
|
return (
|
||||||
|
<OverlayFrame title={`Edit ${ed.path}`}>
|
||||||
|
{slice.map((l, i) => (
|
||||||
|
<text key={i} fg={C.fg}>
|
||||||
|
{`${String(start + i + 1).padStart(3)} | ${l}`}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text fg={C.dim}>
|
||||||
|
{" type to edit · Enter newline · Esc save & close"}
|
||||||
|
</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderOverlay(props: { name: string; state: AppStateRest }): ReactNode {
|
||||||
|
const { name, state } = props;
|
||||||
|
const content =
|
||||||
|
name === "todo"
|
||||||
|
? state.misc.todoContent
|
||||||
|
: name === "plan"
|
||||||
|
? state.misc.planContent
|
||||||
|
: "";
|
||||||
|
const summary =
|
||||||
|
name === "todo" ? "todo list" :
|
||||||
|
name === "plan" ? "plan outline" :
|
||||||
|
name === "usage" ? "usage statistics" :
|
||||||
|
name === "mcp" ? "MCP server management" :
|
||||||
|
name === "settings" ? "settings" :
|
||||||
|
name === "bash" ? "bash jobs" :
|
||||||
|
name === "rewind" ? "rewind history" :
|
||||||
|
name === "learning" ? "learned patterns" :
|
||||||
|
"overlay";
|
||||||
|
return (
|
||||||
|
<OverlayFrame title={`${name} — ${summary}`}>
|
||||||
|
{content ? (
|
||||||
|
<text fg={C.fg}>{content}</text>
|
||||||
|
) : (
|
||||||
|
<text fg={C.dim}>{` ${summary}: nothing yet.`}</text>
|
||||||
|
)}
|
||||||
|
<text fg={C.dim}>{" Esc to close"}</text>
|
||||||
|
</OverlayFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode {
|
||||||
|
const { title, children } = props;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
flexShrink={0}
|
||||||
|
style={{
|
||||||
|
border: true,
|
||||||
|
borderColor: C.accent,
|
||||||
|
padding: 1,
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
titleAlignment="center"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status bar ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function StatusBar(props: {
|
||||||
|
state: AppStateRest;
|
||||||
|
running: boolean;
|
||||||
|
width: number;
|
||||||
|
}): ReactNode {
|
||||||
|
const { state, running, width } = props;
|
||||||
const left = running ? "● running" : "● idle";
|
const left = running ? "● running" : "● idle";
|
||||||
const usage = state.lastUsage;
|
const usage = state.lastUsage;
|
||||||
const usageStr = usage
|
const usageStr = usage
|
||||||
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
||||||
: "in:– · out:–";
|
: "in:– · out:–";
|
||||||
|
|
||||||
|
// Uptime since boot.
|
||||||
|
const uptime = fmtUptime((Date.now() - state.bootedAt) / 1000);
|
||||||
|
|
||||||
// Autocomplete hint for the active input.
|
// Autocomplete hint for the active input.
|
||||||
const ac =
|
const ac =
|
||||||
state.input.autocompleteVisible &&
|
state.input.autocompleteVisible &&
|
||||||
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
|
||||||
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Most recent live toast (expires via MiscState).
|
// Most recent live toast (expires via MiscState).
|
||||||
const toasts = state.misc.toasts;
|
const toasts = state.misc.toasts;
|
||||||
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : "";
|
||||||
|
|
||||||
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history";
|
||||||
const cell = ` ${left} ${usageStr}${ac}${toast} ${hints} `;
|
|
||||||
return <text fg={toast ? "yellow" : running ? "green" : "gray"}>{cell}</text>;
|
const statusColor = toast
|
||||||
|
? C.yellow
|
||||||
|
: running
|
||||||
|
? C.green
|
||||||
|
: C.dim;
|
||||||
|
|
||||||
|
const activity = running ? ` ${spinner(state)}` : "";
|
||||||
|
|
||||||
|
const cell = ` ${left}${activity} ${usageStr} ${uptime}${ac}${toast} ${hints} `;
|
||||||
|
return (
|
||||||
|
<box backgroundColor={C.surface}>
|
||||||
|
<text fg={statusColor}>{trimTo(cell, width)}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clip a status line to the available width (avoid overflow). */
|
||||||
|
function trimTo(s: string, width: number): string {
|
||||||
|
if (s.length <= width) return s;
|
||||||
|
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format seconds as a compact human uptime (e.g. 1m23s). */
|
||||||
|
function fmtUptime(sec: number): string {
|
||||||
|
const s = Math.floor(sec);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h > 0) return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
||||||
|
if (m > 0) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
||||||
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
/** Boot the OpenTUI renderer and mount the Zesdex app. */
|
||||||
@@ -416,3 +869,4 @@ export async function bootTui(state: AppStateRest): Promise<void> {
|
|||||||
}, 60);
|
}, 60);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user