@chatbridge/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/create-cli.d.ts +2 -0
- package/dist/create-cli.js +36 -1
- package/dist/tui/chat-model.d.ts +25 -0
- package/dist/tui/chat-model.js +39 -0
- package/dist/tui/chat-view.d.ts +31 -0
- package/dist/tui/chat-view.js +152 -0
- package/dist/tui/run-interactive.d.ts +35 -0
- package/dist/tui/run-interactive.js +89 -0
- package/dist/tui/runtime-check.d.ts +5 -0
- package/dist/tui/runtime-check.js +12 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
# @chatbridge/cli
|
|
2
2
|
|
|
3
3
|
The CLI for [chatbridge](https://github.com/7milch/chatbridge-cli): drive browser-only web chat AI services from the command line.
|
|
4
|
+
|
|
5
|
+
## Modes
|
|
6
|
+
|
|
7
|
+
- `chatbridge -p "<prompt>"` — one-shot, response on stdout. Node >= 20 or Bun.
|
|
8
|
+
- `chatbridge` — interactive chat in the terminal. Bun >= 1.3 or Node >= 26.4.
|
|
9
|
+
- `chatbridge auth login|logout|status` — manage the saved browser auth state.
|
package/dist/create-cli.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface CreateCliOptions {
|
|
|
8
8
|
configDir?: string;
|
|
9
9
|
/** Test-only: overrides the config/auth-store base directory. */
|
|
10
10
|
baseDir?: string;
|
|
11
|
+
/** Test-only: overrides "stdin and stdout are a TTY". */
|
|
12
|
+
isTerminal?: boolean;
|
|
11
13
|
}
|
|
12
14
|
export declare function createCli(opts: CreateCliOptions): {
|
|
13
15
|
run: (argv: string[]) => Promise<number>;
|
package/dist/create-cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import { parseArgs } from "node:util";
|
|
|
2
2
|
import { ChatBridgeError, ProviderLoadError, createAuthStore, runLogin, runOneShot, } from "@chatbridge/core";
|
|
3
3
|
import { configPath, loadConfig } from "./config.js";
|
|
4
4
|
import { resolveProvider } from "./resolve-provider.js";
|
|
5
|
+
import { supportsInteractive } from "./tui/runtime-check.js";
|
|
5
6
|
/** Single source of truth for `ChatBridgeError.code` → process exit code. */
|
|
6
7
|
const EXIT_CODES = {
|
|
7
8
|
INVALID_ARGUMENT: 1,
|
|
@@ -11,6 +12,7 @@ const EXIT_CODES = {
|
|
|
11
12
|
RESPONSE_TIMEOUT: 4,
|
|
12
13
|
PROVIDER_LOAD: 5,
|
|
13
14
|
INVALID_PROVIDER: 5,
|
|
15
|
+
INVALID_STATE: 1,
|
|
14
16
|
};
|
|
15
17
|
const DEFAULT_TIMEOUT_SEC = 120;
|
|
16
18
|
/** Validates --timeout before any browser is launched. */
|
|
@@ -36,11 +38,13 @@ export function createCli(opts) {
|
|
|
36
38
|
const providerFlag = opts.provider ? "" : " [--provider <name|path>]";
|
|
37
39
|
return [
|
|
38
40
|
"Usage:",
|
|
41
|
+
` ${opts.name}${providerFlag} [--headful] [--timeout <sec>]`,
|
|
39
42
|
` ${opts.name} -p <prompt>${providerFlag} [--headful] [--timeout <sec>]`,
|
|
40
43
|
` ${opts.name} auth login${providerFlag}`,
|
|
41
44
|
` ${opts.name} auth logout${providerFlag}`,
|
|
42
45
|
` ${opts.name} auth status${providerFlag}`,
|
|
43
46
|
"",
|
|
47
|
+
"Without -p, an interactive chat opens (needs a terminal and Bun >= 1.3 or Node >= 26.4).",
|
|
44
48
|
"One-shot mode prints the AI response to stdout.",
|
|
45
49
|
...(opts.provider
|
|
46
50
|
? []
|
|
@@ -129,6 +133,37 @@ export function createCli(opts) {
|
|
|
129
133
|
: `No auth state for "${provider.name}"`);
|
|
130
134
|
return 0;
|
|
131
135
|
}
|
|
136
|
+
if (cmd === undefined && values.prompt === undefined) {
|
|
137
|
+
const isTerminal = opts.isTerminal ??
|
|
138
|
+
(process.stdin.isTTY === true && process.stdout.isTTY === true);
|
|
139
|
+
if (!isTerminal) {
|
|
140
|
+
throw new ChatBridgeError("INVALID_ARGUMENT", "interactive mode needs a terminal; use -p <prompt> for one-shot");
|
|
141
|
+
}
|
|
142
|
+
if (!supportsInteractive({
|
|
143
|
+
bun: process.versions.bun,
|
|
144
|
+
node: process.versions.node,
|
|
145
|
+
})) {
|
|
146
|
+
throw new ChatBridgeError("INVALID_ARGUMENT", "interactive mode needs Bun >= 1.3 or Node >= 26.4; use -p <prompt> on this runtime");
|
|
147
|
+
}
|
|
148
|
+
const timeoutMs = parseTimeoutMs(values.timeout);
|
|
149
|
+
const provider = await getProvider(values.provider);
|
|
150
|
+
const authStore = createAuthStore({
|
|
151
|
+
configDir,
|
|
152
|
+
providerName: provider.name,
|
|
153
|
+
baseDir: opts.baseDir,
|
|
154
|
+
});
|
|
155
|
+
// Loaded lazily so one-shot and auth never evaluate @opentui/core.
|
|
156
|
+
const { runInteractive } = await import("./tui/run-interactive.js");
|
|
157
|
+
const result = await runInteractive({
|
|
158
|
+
title: opts.name,
|
|
159
|
+
provider,
|
|
160
|
+
authStore,
|
|
161
|
+
headless: !values.headful,
|
|
162
|
+
timeoutMs,
|
|
163
|
+
onProgress: progress,
|
|
164
|
+
});
|
|
165
|
+
return result.fatal === undefined ? 0 : reportError(result.fatal);
|
|
166
|
+
}
|
|
132
167
|
if (typeof values.prompt === "string") {
|
|
133
168
|
const timeoutMs = parseTimeoutMs(values.timeout);
|
|
134
169
|
const provider = await getProvider(values.provider);
|
|
@@ -150,7 +185,7 @@ export function createCli(opts) {
|
|
|
150
185
|
return 0;
|
|
151
186
|
}
|
|
152
187
|
console.log(help());
|
|
153
|
-
return
|
|
188
|
+
return 1;
|
|
154
189
|
}
|
|
155
190
|
catch (err) {
|
|
156
191
|
return reportError(err);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** What the model needs from a ChatSession; lets tests inject a fake. */
|
|
2
|
+
export interface ChatSessionLike {
|
|
3
|
+
send(prompt: string): Promise<string>;
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export type Role = "user" | "assistant" | "error";
|
|
7
|
+
export interface Message {
|
|
8
|
+
role: Role;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
export type Status = "idle" | "busy";
|
|
12
|
+
/** Conversation state for the interactive UI. No OpenTUI dependency. */
|
|
13
|
+
export declare class ChatModel {
|
|
14
|
+
private readonly session;
|
|
15
|
+
readonly messages: Message[];
|
|
16
|
+
status: Status;
|
|
17
|
+
/** Set when submit hit an unrecoverable error; the app must exit. */
|
|
18
|
+
fatal: unknown;
|
|
19
|
+
/** Called after every state change. */
|
|
20
|
+
onChange: () => void;
|
|
21
|
+
constructor(session: ChatSessionLike);
|
|
22
|
+
/** Sends one turn. Blank input, input while busy, and input after a
|
|
23
|
+
* fatal error are ignored. */
|
|
24
|
+
submit(text: string): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ResponseTimeoutError } from "@chatbridge/core";
|
|
2
|
+
/** Conversation state for the interactive UI. No OpenTUI dependency. */
|
|
3
|
+
export class ChatModel {
|
|
4
|
+
session;
|
|
5
|
+
messages = [];
|
|
6
|
+
status = "idle";
|
|
7
|
+
/** Set when submit hit an unrecoverable error; the app must exit. */
|
|
8
|
+
fatal = undefined;
|
|
9
|
+
/** Called after every state change. */
|
|
10
|
+
onChange = () => { };
|
|
11
|
+
constructor(session) {
|
|
12
|
+
this.session = session;
|
|
13
|
+
}
|
|
14
|
+
/** Sends one turn. Blank input, input while busy, and input after a
|
|
15
|
+
* fatal error are ignored. */
|
|
16
|
+
async submit(text) {
|
|
17
|
+
const prompt = text.trim();
|
|
18
|
+
if (!prompt || this.status === "busy" || this.fatal !== undefined)
|
|
19
|
+
return;
|
|
20
|
+
this.messages.push({ role: "user", text: prompt });
|
|
21
|
+
this.status = "busy";
|
|
22
|
+
this.onChange();
|
|
23
|
+
try {
|
|
24
|
+
const reply = await this.session.send(prompt);
|
|
25
|
+
this.messages.push({ role: "assistant", text: reply });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
this.messages.push({ role: "error", text: message });
|
|
30
|
+
// A timeout leaves the browser usable; anything else ends the session.
|
|
31
|
+
if (!(err instanceof ResponseTimeoutError))
|
|
32
|
+
this.fatal = err;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
this.status = "idle";
|
|
36
|
+
this.onChange();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type CliRenderer } from "@opentui/core";
|
|
2
|
+
import type { ChatModel } from "./chat-model.js";
|
|
3
|
+
export declare const GUIDE = "Enter send \u00B7 Shift+Enter (or Ctrl+J) newline \u00B7 Ctrl+C quit";
|
|
4
|
+
export interface ChatViewOptions {
|
|
5
|
+
title: string;
|
|
6
|
+
providerName: string;
|
|
7
|
+
}
|
|
8
|
+
/** Builds the OpenTUI tree for one ChatModel and mirrors its state.
|
|
9
|
+
* Layout: header / scrolling history / 4-line textarea / status line. */
|
|
10
|
+
export declare class ChatView {
|
|
11
|
+
private readonly renderer;
|
|
12
|
+
private readonly model;
|
|
13
|
+
private readonly history;
|
|
14
|
+
private readonly input;
|
|
15
|
+
private readonly status;
|
|
16
|
+
private rendered;
|
|
17
|
+
private spinner;
|
|
18
|
+
private frame;
|
|
19
|
+
private destroyed;
|
|
20
|
+
private statusPinned;
|
|
21
|
+
constructor(renderer: CliRenderer, model: ChatModel, opts: ChatViewOptions);
|
|
22
|
+
/** Appends messages not yet drawn and syncs the status line. */
|
|
23
|
+
update(): void;
|
|
24
|
+
/** Pins a message on the status line (e.g. "Closing browser...") so the
|
|
25
|
+
* user sees that teardown started. Later model changes leave it alone. */
|
|
26
|
+
setStatus(text: string): void;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
private messageBox;
|
|
29
|
+
private startSpinner;
|
|
30
|
+
private stopSpinner;
|
|
31
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { BoxRenderable, ScrollBoxRenderable, TextRenderable, TextareaRenderable, } from "@opentui/core";
|
|
2
|
+
export const GUIDE = "Enter send · Shift+Enter (or Ctrl+J) newline · Ctrl+C quit";
|
|
3
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
5
|
+
const LABELS = {
|
|
6
|
+
user: "You",
|
|
7
|
+
assistant: "Assistant",
|
|
8
|
+
error: "Error",
|
|
9
|
+
};
|
|
10
|
+
/** Builds the OpenTUI tree for one ChatModel and mirrors its state.
|
|
11
|
+
* Layout: header / scrolling history / 4-line textarea / status line. */
|
|
12
|
+
export class ChatView {
|
|
13
|
+
renderer;
|
|
14
|
+
model;
|
|
15
|
+
history;
|
|
16
|
+
input;
|
|
17
|
+
status;
|
|
18
|
+
rendered = 0;
|
|
19
|
+
spinner;
|
|
20
|
+
frame = 0;
|
|
21
|
+
destroyed = false;
|
|
22
|
+
statusPinned = false;
|
|
23
|
+
constructor(renderer, model, opts) {
|
|
24
|
+
this.renderer = renderer;
|
|
25
|
+
this.model = model;
|
|
26
|
+
const root = new BoxRenderable(renderer, {
|
|
27
|
+
id: "root",
|
|
28
|
+
flexDirection: "column",
|
|
29
|
+
width: "100%",
|
|
30
|
+
height: "100%",
|
|
31
|
+
});
|
|
32
|
+
root.add(new TextRenderable(renderer, {
|
|
33
|
+
id: "header",
|
|
34
|
+
content: `${opts.title} · ${opts.providerName}`,
|
|
35
|
+
marginBottom: 1,
|
|
36
|
+
}));
|
|
37
|
+
this.history = new ScrollBoxRenderable(renderer, {
|
|
38
|
+
id: "history",
|
|
39
|
+
flexGrow: 1,
|
|
40
|
+
stickyScroll: true,
|
|
41
|
+
stickyStart: "bottom",
|
|
42
|
+
});
|
|
43
|
+
root.add(this.history);
|
|
44
|
+
const inputBox = new BoxRenderable(renderer, {
|
|
45
|
+
id: "input-box",
|
|
46
|
+
border: true,
|
|
47
|
+
height: 6,
|
|
48
|
+
});
|
|
49
|
+
this.input = new TextareaRenderable(renderer, {
|
|
50
|
+
id: "input",
|
|
51
|
+
height: 4,
|
|
52
|
+
placeholder: "Type a message",
|
|
53
|
+
keyBindings: [
|
|
54
|
+
{ name: "return", action: "submit" },
|
|
55
|
+
{ name: "kpenter", action: "submit" },
|
|
56
|
+
// Shift+Enter needs the kitty keyboard protocol. Ctrl+J arrives as
|
|
57
|
+
// a linefeed byte on legacy terminals and as ctrl+j under kitty.
|
|
58
|
+
{ name: "return", shift: true, action: "newline" },
|
|
59
|
+
{ name: "linefeed", action: "newline" },
|
|
60
|
+
{ name: "j", ctrl: true, action: "newline" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
inputBox.add(this.input);
|
|
64
|
+
root.add(inputBox);
|
|
65
|
+
this.status = new TextRenderable(renderer, {
|
|
66
|
+
id: "status",
|
|
67
|
+
content: GUIDE,
|
|
68
|
+
});
|
|
69
|
+
root.add(this.status);
|
|
70
|
+
renderer.root.add(root);
|
|
71
|
+
this.input.onSubmit = () => {
|
|
72
|
+
const text = this.input.plainText;
|
|
73
|
+
// Mirrors every case ChatModel.submit drops, so the textarea is never
|
|
74
|
+
// cleared for input the model is going to ignore.
|
|
75
|
+
if (!text.trim() ||
|
|
76
|
+
this.model.status === "busy" ||
|
|
77
|
+
this.model.fatal !== undefined) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.input.clear();
|
|
81
|
+
void this.model.submit(text);
|
|
82
|
+
};
|
|
83
|
+
this.model.onChange = () => this.update();
|
|
84
|
+
this.input.focus();
|
|
85
|
+
this.update();
|
|
86
|
+
}
|
|
87
|
+
/** Appends messages not yet drawn and syncs the status line. */
|
|
88
|
+
update() {
|
|
89
|
+
// A turn still in flight when the view is destroyed would otherwise
|
|
90
|
+
// write to renderables the renderer has already torn down.
|
|
91
|
+
if (this.destroyed)
|
|
92
|
+
return;
|
|
93
|
+
for (; this.rendered < this.model.messages.length; this.rendered++) {
|
|
94
|
+
const message = this.model.messages[this.rendered];
|
|
95
|
+
if (message)
|
|
96
|
+
this.history.add(this.messageBox(message));
|
|
97
|
+
}
|
|
98
|
+
if (this.statusPinned)
|
|
99
|
+
return;
|
|
100
|
+
if (this.model.status === "busy") {
|
|
101
|
+
this.startSpinner();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.stopSpinner();
|
|
105
|
+
this.status.content = GUIDE;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Pins a message on the status line (e.g. "Closing browser...") so the
|
|
109
|
+
* user sees that teardown started. Later model changes leave it alone. */
|
|
110
|
+
setStatus(text) {
|
|
111
|
+
if (this.destroyed)
|
|
112
|
+
return;
|
|
113
|
+
this.statusPinned = true;
|
|
114
|
+
this.stopSpinner();
|
|
115
|
+
this.status.content = text;
|
|
116
|
+
}
|
|
117
|
+
destroy() {
|
|
118
|
+
this.destroyed = true;
|
|
119
|
+
// Deliberately severs the model→view link: a turn still in flight must
|
|
120
|
+
// not reach renderables the renderer is about to tear down.
|
|
121
|
+
this.model.onChange = () => { };
|
|
122
|
+
this.stopSpinner();
|
|
123
|
+
}
|
|
124
|
+
messageBox(message) {
|
|
125
|
+
const box = new BoxRenderable(this.renderer, {
|
|
126
|
+
flexDirection: "column",
|
|
127
|
+
marginBottom: 1,
|
|
128
|
+
});
|
|
129
|
+
box.add(new TextRenderable(this.renderer, { content: LABELS[message.role] }));
|
|
130
|
+
box.add(new TextRenderable(this.renderer, {
|
|
131
|
+
content: message.text,
|
|
132
|
+
wrapMode: "word",
|
|
133
|
+
}));
|
|
134
|
+
return box;
|
|
135
|
+
}
|
|
136
|
+
startSpinner() {
|
|
137
|
+
if (this.spinner)
|
|
138
|
+
return;
|
|
139
|
+
const tick = () => {
|
|
140
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
141
|
+
this.status.content = `${SPINNER[this.frame]} Waiting for response...`;
|
|
142
|
+
};
|
|
143
|
+
tick();
|
|
144
|
+
this.spinner = setInterval(tick, SPINNER_INTERVAL_MS);
|
|
145
|
+
}
|
|
146
|
+
stopSpinner() {
|
|
147
|
+
if (!this.spinner)
|
|
148
|
+
return;
|
|
149
|
+
clearInterval(this.spinner);
|
|
150
|
+
this.spinner = undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ChatSessionOptions } from "@chatbridge/core";
|
|
2
|
+
import { type CliRenderer } from "@opentui/core";
|
|
3
|
+
import { ChatModel } from "./chat-model.js";
|
|
4
|
+
export interface InteractiveOptions extends ChatSessionOptions {
|
|
5
|
+
/** Shown in the header, e.g. the CLI name. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** Test-only: replaces createCliRenderer. */
|
|
8
|
+
createRenderer?: () => Promise<CliRenderer>;
|
|
9
|
+
}
|
|
10
|
+
/** What the bounded close needs from a session; lets tests inject a fake. */
|
|
11
|
+
export interface ClosableSession {
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves when the user asks to quit: Ctrl+C, a fatal model error (the
|
|
16
|
+
* error is the resolved value), or the renderer being destroyed from
|
|
17
|
+
* outside — OpenTUI installs its own SIGINT/SIGTERM/SIGHUP handlers that
|
|
18
|
+
* destroy the renderer without exiting the process, so without this the
|
|
19
|
+
* caller's promise would stay pending and the browser would keep the
|
|
20
|
+
* process alive.
|
|
21
|
+
*
|
|
22
|
+
* Must be called after the ChatView is built: it chains onto the view's
|
|
23
|
+
* `onChange` handler rather than replacing it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function waitForQuit(renderer: CliRenderer, model: ChatModel): Promise<unknown>;
|
|
26
|
+
/** Opens a ChatSession (errors propagate before any UI exists), runs the
|
|
27
|
+
* TUI until Ctrl+C or a fatal error, then restores the terminal.
|
|
28
|
+
* Resolves with the fatal error, if any, for the caller to report. */
|
|
29
|
+
export declare function runInteractive(opts: InteractiveOptions): Promise<{
|
|
30
|
+
fatal?: unknown;
|
|
31
|
+
}>;
|
|
32
|
+
/** Playwright close can hang on a wedged browser; never block exit on it.
|
|
33
|
+
* Returns true when the session closed within `ms`. Close errors are
|
|
34
|
+
* swallowed: teardown must not mask the result the caller is returning. */
|
|
35
|
+
export declare function closeWithTimeout(session: ClosableSession, ms: number): Promise<boolean>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ChatSession } from "@chatbridge/core";
|
|
2
|
+
import { createCliRenderer, } from "@opentui/core";
|
|
3
|
+
import { ChatModel } from "./chat-model.js";
|
|
4
|
+
import { ChatView } from "./chat-view.js";
|
|
5
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
6
|
+
const CLOSING_STATUS = "Closing browser...";
|
|
7
|
+
/**
|
|
8
|
+
* Resolves when the user asks to quit: Ctrl+C, a fatal model error (the
|
|
9
|
+
* error is the resolved value), or the renderer being destroyed from
|
|
10
|
+
* outside — OpenTUI installs its own SIGINT/SIGTERM/SIGHUP handlers that
|
|
11
|
+
* destroy the renderer without exiting the process, so without this the
|
|
12
|
+
* caller's promise would stay pending and the browser would keep the
|
|
13
|
+
* process alive.
|
|
14
|
+
*
|
|
15
|
+
* Must be called after the ChatView is built: it chains onto the view's
|
|
16
|
+
* `onChange` handler rather than replacing it.
|
|
17
|
+
*/
|
|
18
|
+
export function waitForQuit(renderer, model) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const notify = model.onChange;
|
|
21
|
+
model.onChange = () => {
|
|
22
|
+
notify();
|
|
23
|
+
if (model.fatal !== undefined)
|
|
24
|
+
resolve(model.fatal);
|
|
25
|
+
};
|
|
26
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
27
|
+
if (key.ctrl && key.name === "c")
|
|
28
|
+
resolve(undefined);
|
|
29
|
+
});
|
|
30
|
+
renderer.on("destroy", () => resolve(undefined));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Opens a ChatSession (errors propagate before any UI exists), runs the
|
|
34
|
+
* TUI until Ctrl+C or a fatal error, then restores the terminal.
|
|
35
|
+
* Resolves with the fatal error, if any, for the caller to report. */
|
|
36
|
+
export async function runInteractive(opts) {
|
|
37
|
+
const session = await ChatSession.open(opts);
|
|
38
|
+
let renderer;
|
|
39
|
+
try {
|
|
40
|
+
renderer = await (opts.createRenderer ?? (() => createCliRenderer({ exitOnCtrlC: false })))();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
// The session is already open; nothing else would ever close it.
|
|
44
|
+
await closeWithTimeout(session, CLOSE_TIMEOUT_MS);
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
let view;
|
|
48
|
+
try {
|
|
49
|
+
const model = new ChatModel(session);
|
|
50
|
+
view = new ChatView(renderer, model, {
|
|
51
|
+
title: opts.title,
|
|
52
|
+
providerName: opts.provider.name,
|
|
53
|
+
});
|
|
54
|
+
const quit = waitForQuit(renderer, model);
|
|
55
|
+
renderer.start();
|
|
56
|
+
const fatal = await quit;
|
|
57
|
+
return fatal === undefined ? {} : { fatal };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
view?.setStatus(CLOSING_STATUS);
|
|
61
|
+
const closed = await closeWithTimeout(session, CLOSE_TIMEOUT_MS);
|
|
62
|
+
view?.destroy();
|
|
63
|
+
renderer.destroy();
|
|
64
|
+
if (!closed) {
|
|
65
|
+
// The Playwright connection would keep the event loop alive forever;
|
|
66
|
+
// the terminal is restored by now, so exiting hard is safe here.
|
|
67
|
+
process.stderr.write("browser did not close within 5 s; exiting\n");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Playwright close can hang on a wedged browser; never block exit on it.
|
|
73
|
+
* Returns true when the session closed within `ms`. Close errors are
|
|
74
|
+
* swallowed: teardown must not mask the result the caller is returning. */
|
|
75
|
+
export async function closeWithTimeout(session, ms) {
|
|
76
|
+
let timer;
|
|
77
|
+
const deadline = new Promise((resolve) => {
|
|
78
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
return await Promise.race([
|
|
82
|
+
session.close().then(() => true, () => true),
|
|
83
|
+
deadline,
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @opentui/core needs Bun >= 1.3 or Node >= 26.4. Bun wins when present. */
|
|
2
|
+
export function supportsInteractive(versions) {
|
|
3
|
+
if (versions.bun)
|
|
4
|
+
return atLeast(versions.bun, 1, 3);
|
|
5
|
+
if (versions.node)
|
|
6
|
+
return atLeast(versions.node, 26, 4);
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
function atLeast(version, major, minor) {
|
|
10
|
+
const [a = 0, b = 0] = version.split(".").map((n) => Number.parseInt(n, 10));
|
|
11
|
+
return a > major || (a === major && b >= minor);
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatbridge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Drive browser-only web chat AI services from the command line",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"keywords": ["chatbridge", "cli", "playwright", "chat", "ai"],
|
|
12
12
|
"type": "module",
|
|
13
13
|
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
14
|
+
"node": ">=20",
|
|
15
|
+
"bun": ">=1.3"
|
|
15
16
|
},
|
|
16
17
|
"exports": {
|
|
17
18
|
".": {
|
|
@@ -27,7 +28,8 @@
|
|
|
27
28
|
"access": "public"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@chatbridge/core": "0.
|
|
31
|
+
"@chatbridge/core": "0.2.0",
|
|
32
|
+
"@opentui/core": "0.5.10"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
33
35
|
"@chatbridge/example-dummy-chat": "0.0.0"
|