Skip to content

Commit 12a44e9

Browse files
saintnosaintno
authored andcommitted
feat(cli): add new Ink-based TUI and gallery preview
1 parent bbf36bc commit 12a44e9

24 files changed

Lines changed: 1633 additions & 649 deletions

bun.lockb

70.4 KB
Binary file not shown.

cli/build.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ if (!fs.existsSync("./dist")) {
77

88
const result = await Bun.build({
99
entrypoints: ["./cli/bin.ts"],
10-
target: "bun",
10+
target: "node",
1111
format: "esm",
12-
minify: true,
12+
minify: false,
1313
outdir: "./dist",
14-
naming: "cli.js"
14+
naming: "cli.js",
15+
external: ["sharp", "sixel"]
1516
});
1617

1718
if (!result.success) {

cli/commands/watch.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { loadWorkflow, detectOutputNodes } from "cli/utils/fs";
55
import { coerceValue, isValidNodePath } from "cli/utils/value-parser";
66
import { extractMediaFromOutputs, buildResultOverrides, isFilePath } from "cli/runner";
77
import { createRenderer, resolveMode } from "cli/renderer/index";
8-
import type { TuiRenderer } from "cli/renderer/tui";
8+
import type { InkTuiRenderer } from "cli/renderer/ink/ink-tui-renderer";
99
import type { RunResult } from "cli/renderer/json";
1010
import type { RunConfig } from "cli/runner";
1111

@@ -23,24 +23,29 @@ const DEBOUNCE_MS = 500;
2323

2424
interface WatchState {
2525
runNumber: number;
26-
abortController: AbortController | null;
2726
debounceTimer: Timer | null;
2827
running: boolean;
2928
client: ComfyApi | null;
3029
}
3130

32-
function isTui(renderer: any): renderer is TuiRenderer {
31+
function isTui(renderer: any): renderer is InkTuiRenderer {
3332
return renderer && typeof renderer.showWatchStatus === "function";
3433
}
3534

35+
export function buildWatchClientOptions(credentials: any, listenTerminal: boolean) {
36+
return {
37+
...(credentials ? { credentials } : {}),
38+
listenTerminal
39+
};
40+
}
41+
3642
export async function watchMode(config: RunConfig, noTui = false): Promise<void> {
3743
const mode = resolveMode(config.json, config.quiet);
3844
const useTui = mode === "terminal" && !noTui;
3945
const renderer = createRenderer(mode, config.host, config.file, useTui, noTui, useTui);
4046

4147
const state: WatchState = {
4248
runNumber: 0,
43-
abortController: null,
4449
debounceTimer: null,
4550
running: false,
4651
client: null
@@ -60,7 +65,9 @@ export async function watchMode(config: RunConfig, noTui = false): Promise<void>
6065
credentials = { type: "basic", username: config.user, password: config.pass };
6166
}
6267

63-
state.client = new ComfyApi(config.host, undefined, credentials ? { credentials } : undefined);
68+
state.client = new ComfyApi(config.host, undefined, buildWatchClientOptions(credentials, useTui));
69+
70+
const detachTerminalStream = isTui(renderer) ? attachTerminalLogStream(state.client, renderer) : () => {};
6471

6572
await state.client.init(5, 2000).waitForReady();
6673
if (isTui(renderer)) {
@@ -71,13 +78,17 @@ export async function watchMode(config: RunConfig, noTui = false): Promise<void>
7178
}
7279

7380
const runOnce = async () => {
81+
state.running = true;
7482
state.runNumber++;
7583
const runNum = state.runNumber;
7684
const startTime = performance.now();
7785
let interrupted = false;
7886

7987
if (isTui(renderer)) {
8088
renderer.resetRun();
89+
renderer.startRun(runNum);
90+
} else if (!config.json) {
91+
console.log(c.dim(`[Run #${runNum}]`) + ` ${c.cyan("starting")} ${config.file}`);
8192
}
8293

8394
try {
@@ -131,11 +142,13 @@ export async function watchMode(config: RunConfig, noTui = false): Promise<void>
131142
};
132143
renderer.render(runResult);
133144
}
145+
} finally {
146+
state.running = false;
134147
}
135148

136149
if (!interrupted && !isTui(renderer) && !config.json) {
137150
console.log();
138-
console.log(c.dim(`${c.blue("watching")} ${config.file} ...`));
151+
console.log(c.dim(`${c.blue("watching")} ${config.file} for changes ...`));
139152
}
140153
};
141154

@@ -186,16 +199,15 @@ export async function watchMode(config: RunConfig, noTui = false): Promise<void>
186199
}
187200
}
188201

189-
state.running = true;
190202
await runOnce();
191-
state.running = false;
192203
}, DEBOUNCE_MS);
193204
});
194205

195206
return new Promise<void>(() => {
196207
process.on("SIGINT", () => {
197208
if (state.debounceTimer) clearTimeout(state.debounceTimer);
198209
watcher.close();
210+
detachTerminalStream();
199211
state.client!.destroy();
200212
if (isTui(renderer)) {
201213
renderer.stop();
@@ -239,11 +251,7 @@ async function executeWorkflow(client: ComfyApi, config: RunConfig, renderer: an
239251
.onProgress((info: any) => renderer.onProgress(info))
240252
.onOutput((key: any, data: any) => renderer.onOutput(String(key), data))
241253
.onFinished((_data: any) => {})
242-
.onFailed((err: Error) => {
243-
if (!err.message.includes("interrupted") && !err.message.includes("Interrupted")) {
244-
renderer.onFailed(err);
245-
}
246-
});
254+
.onFailed((_err: Error) => {});
247255

248256
const result = await Promise.race([
249257
callWrapper.run(),
@@ -258,3 +266,30 @@ async function executeWorkflow(client: ComfyApi, config: RunConfig, renderer: an
258266

259267
return result;
260268
}
269+
270+
export function attachTerminalLogStream(
271+
client: Pick<ComfyApi, "addEventListener" | "removeEventListener">,
272+
renderer: Pick<InkTuiRenderer, "addServerLog" | "showWatchStatus">
273+
): () => void {
274+
let warned = false;
275+
276+
const onTerminal = (event: Event) => {
277+
const detail = (event as CustomEvent<{ m: string; t: string } | null>).detail;
278+
if (!detail?.m) return;
279+
renderer.addServerLog(detail.m);
280+
};
281+
282+
const onSubscriptionError = () => {
283+
if (warned) return;
284+
warned = true;
285+
renderer.showWatchStatus("Live terminal logs unavailable; continuing with progress updates.");
286+
};
287+
288+
client.addEventListener("terminal", onTerminal as EventListener);
289+
client.addEventListener("terminal_subscription_error", onSubscriptionError as EventListener);
290+
291+
return () => {
292+
client.removeEventListener("terminal", onTerminal as EventListener);
293+
client.removeEventListener("terminal_subscription_error", onSubscriptionError as EventListener);
294+
};
295+
}

cli/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ async function cmdRun(args: ReturnType<typeof parseArgs>): Promise<void> {
7777
output: args.output,
7878
download: args.download,
7979
noDownload: args.noDownload,
80+
json: args.json,
81+
quiet: args.quiet,
8082
token: args.token,
8183
user: args.user,
8284
pass: args.pass,
@@ -190,6 +192,8 @@ async function cmdWatch(args: ReturnType<typeof parseArgs>): Promise<void> {
190192
output: args.output,
191193
download: args.download,
192194
noDownload: args.noDownload,
195+
json: args.json,
196+
quiet: args.quiet,
193197
token: args.token,
194198
user: args.user,
195199
pass: args.pass,

cli/renderer/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { TerminalRenderer } from "cli/renderer/terminal";
22
import { JsonRenderer } from "cli/renderer/json";
33
import { QuietRenderer } from "cli/renderer/quiet";
4-
import { TuiRenderer, isTuiAvailable } from "cli/renderer/tui";
4+
import { InkTuiRenderer, isInkAvailable } from "cli/renderer/ink/ink-tui-renderer";
55

6-
export { isTuiAvailable } from "cli/renderer/tui";
6+
export { isInkAvailable } from "cli/renderer/ink/ink-tui-renderer";
77
export type { TerminalRenderer } from "cli/renderer/terminal";
88
export type { JsonRenderer } from "cli/renderer/json";
99
export type { QuietRenderer } from "cli/renderer/quiet";
10-
export type { TuiRenderer } from "cli/renderer/tui";
10+
export type { InkTuiRenderer } from "cli/renderer/ink/ink-tui-renderer";
1111
export type { RunResult } from "cli/renderer/json";
1212

1313
export type RenderMode = "json" | "terminal" | "quiet";
@@ -25,9 +25,9 @@ export function createRenderer(
2525
persistent = false,
2626
noTui = false,
2727
useTui = false
28-
): TerminalRenderer | JsonRenderer | QuietRenderer | TuiRenderer {
29-
if (useTui && mode === "terminal" && !noTui && isTuiAvailable()) {
30-
return new TuiRenderer(host, file, persistent);
28+
): TerminalRenderer | JsonRenderer | QuietRenderer | InkTuiRenderer {
29+
if (useTui && mode === "terminal" && !noTui && isInkAvailable()) {
30+
return new InkTuiRenderer(host, file, persistent);
3131
}
3232
switch (mode) {
3333
case "json":

cli/renderer/ink/app.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React, { useSyncExternalStore, useEffect, useState } from "react";
2+
import { Box, useStdout, useApp } from "ink";
3+
import type { TuiStore } from "./state";
4+
import { LogViewer } from "./components/log-viewer";
5+
import { StatusBar } from "./components/progress-bar";
6+
import { DashboardHeader } from "./components/dashboard-header";
7+
8+
interface AppProps {
9+
store: TuiStore;
10+
persistent: boolean;
11+
}
12+
13+
export function App({ store, persistent }: AppProps) {
14+
const { stdout } = useStdout();
15+
const { exit } = useApp();
16+
const state = useSyncExternalStore(store.subscribe, store.getState);
17+
const [viewport, setViewport] = useState({
18+
cols: stdout?.columns ?? 80,
19+
rows: stdout?.rows ?? 24
20+
});
21+
22+
useEffect(() => {
23+
if (!persistent && state.status === "done") {
24+
const timer = setTimeout(() => exit(), 800);
25+
return () => clearTimeout(timer);
26+
}
27+
return undefined;
28+
}, [state.status, persistent, exit]);
29+
30+
useEffect(() => {
31+
if (!stdout) return undefined;
32+
33+
const syncViewport = () => {
34+
setViewport({
35+
cols: stdout.columns ?? 80,
36+
rows: stdout.rows ?? 24
37+
});
38+
};
39+
40+
syncViewport();
41+
stdout.on("resize", syncViewport);
42+
43+
return () => {
44+
stdout.off("resize", syncViewport);
45+
};
46+
}, [stdout]);
47+
48+
const cols = viewport.cols;
49+
const rows = viewport.rows;
50+
51+
const HEADER_LINES = 6;
52+
const STATUS_BAR_LINES = 6;
53+
const logLines = Math.max(4, rows - HEADER_LINES - STATUS_BAR_LINES);
54+
55+
return (
56+
<Box flexDirection='column' width={cols} height={rows}>
57+
<DashboardHeader
58+
connected={state.connected}
59+
status={state.status}
60+
host={state.host}
61+
watchFile={state.watchFile}
62+
runNumber={state.runNumber}
63+
promptId={state.promptId}
64+
lastRunOutcome={state.lastRunOutcome}
65+
lastRunLabel={state.lastRunLabel}
66+
inlineImagesSupported={state.inlineImagesSupported}
67+
width={cols}
68+
/>
69+
<LogViewer
70+
feed={state.feed}
71+
media={state.media}
72+
maxLines={logLines}
73+
width={cols}
74+
inlineImagesSupported={state.inlineImagesSupported}
75+
/>
76+
<StatusBar
77+
status={state.status}
78+
currentNode={state.currentNode}
79+
progressValue={state.progressValue}
80+
progressMax={state.progressMax}
81+
runNumber={state.runNumber}
82+
runDuration={state.runDuration}
83+
runStartedAt={state.runStartedAt}
84+
host={state.host}
85+
watchFile={state.watchFile}
86+
errorMessage={state.errorMessage}
87+
width={cols}
88+
/>
89+
</Box>
90+
);
91+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from "react";
2+
import { Box, Text } from "ink";
3+
4+
type DashboardHeaderProps = {
5+
connected: boolean;
6+
status: "idle" | "connecting" | "queued" | "executing" | "done" | "failed";
7+
host: string;
8+
watchFile: string;
9+
runNumber: number;
10+
promptId: string;
11+
lastRunOutcome: "idle" | "success" | "failed" | "interrupted";
12+
lastRunLabel: string;
13+
inlineImagesSupported: boolean;
14+
width: number;
15+
};
16+
17+
function toneForOutcome(outcome: DashboardHeaderProps["lastRunOutcome"]): "green" | "red" | "yellow" | "cyan" {
18+
if (outcome === "success") return "green";
19+
if (outcome === "failed") return "red";
20+
if (outcome === "interrupted") return "yellow";
21+
return "cyan";
22+
}
23+
24+
export function DashboardHeader({
25+
connected,
26+
status,
27+
host,
28+
watchFile,
29+
runNumber,
30+
promptId,
31+
lastRunOutcome,
32+
lastRunLabel,
33+
inlineImagesSupported,
34+
width
35+
}: DashboardHeaderProps) {
36+
const statusLabel =
37+
status === "executing"
38+
? "Executing"
39+
: status === "queued"
40+
? "Queued"
41+
: status === "failed"
42+
? "Failed"
43+
: status === "done"
44+
? "Completed"
45+
: status === "connecting"
46+
? "Preparing"
47+
: "Watching";
48+
49+
return (
50+
<Box
51+
flexDirection='column'
52+
width={width}
53+
borderStyle='round'
54+
borderColor={connected ? "cyan" : "yellow"}
55+
paddingX={1}
56+
>
57+
<Box justifyContent='space-between'>
58+
<Text bold color='cyan'>
59+
CFLI WATCH
60+
</Text>
61+
<Box>
62+
<Text color={connected ? "green" : "yellow"}>{connected ? "ONLINE" : "CONNECTING"}</Text>
63+
<Text dimColor> </Text>
64+
<Text color='blue'>{statusLabel}</Text>
65+
</Box>
66+
</Box>
67+
<Box justifyContent='space-between'>
68+
<Text wrap='truncate-end'>Host: {host}</Text>
69+
<Text color='cyan'>Run #{Math.max(runNumber, 1)}</Text>
70+
</Box>
71+
<Box justifyContent='space-between'>
72+
<Text wrap='truncate-end'>File: {watchFile}</Text>
73+
<Text color={inlineImagesSupported ? "green" : "yellow"}>
74+
{inlineImagesSupported ? "Image preview ready" : "Image preview fallback"}
75+
</Text>
76+
</Box>
77+
<Box justifyContent='space-between'>
78+
<Text wrap='truncate-end'>{promptId ? `Prompt: ${promptId}` : "Prompt: waiting for queue id"}</Text>
79+
<Text color={toneForOutcome(lastRunOutcome)} wrap='truncate-end'>
80+
{lastRunLabel}
81+
</Text>
82+
</Box>
83+
</Box>
84+
);
85+
}

0 commit comments

Comments
 (0)