@aitherium/shell-cli 1.1.0 → 1.11.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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Minimal MCP StreamableHTTP client.
3
+ *
4
+ * Talks JSON-RPC to a remote MCP gateway (e.g. https://mcp.aitherium.com/mcp)
5
+ * using the MCP "Streamable HTTP" transport: each request is a POST whose
6
+ * response is either a single `application/json` body or a `text/event-stream`
7
+ * carrying the JSON-RPC reply. No external deps — mirrors client.ts's thin SSE
8
+ * style so the bundle still compiles via `bun build --compile`.
9
+ *
10
+ * Why this exists: the shell's chat backend (genesisUrl) speaks a REST
11
+ * `/tools/call`, but the cloud gateway (config.mcpUrl) speaks the MCP protocol
12
+ * at `/mcp`. When mcpUrl is set we route tool discovery + invocation here so
13
+ * AitherNode's tools work in-REPL against the cloud workspace.
14
+ */
15
+ import { getActiveToken } from './auth.js';
16
+ const PROTOCOL_VERSION = '2025-06-18';
17
+ const CLIENT_INFO = { name: 'aither-shell', version: '1.7.3' };
18
+ export class McpHttpClient {
19
+ url;
20
+ token;
21
+ sessionId = null;
22
+ nextId = 1;
23
+ initialized = false;
24
+ initInFlight = null;
25
+ constructor(url, token) {
26
+ // Strip trailing slashes only — keep the `/mcp` path intact.
27
+ this.url = url.replace(/\/+$/, '');
28
+ this.token = token ?? null;
29
+ }
30
+ /** Send a JSON-RPC request (or notification) and return its `result`. */
31
+ async rpc(method, params, isNotification = false) {
32
+ const id = isNotification ? undefined : this.nextId++;
33
+ const body = { jsonrpc: '2.0', method };
34
+ if (params !== undefined)
35
+ body.params = params;
36
+ if (id !== undefined)
37
+ body.id = id;
38
+ const headers = {
39
+ 'Content-Type': 'application/json',
40
+ Accept: 'application/json, text/event-stream',
41
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
42
+ };
43
+ if (this.token) {
44
+ headers['Authorization'] = `Bearer ${this.token}`;
45
+ headers['X-API-Key'] = this.token;
46
+ }
47
+ if (this.sessionId)
48
+ headers['Mcp-Session-Id'] = this.sessionId;
49
+ let resp;
50
+ try {
51
+ resp = await fetch(this.url, {
52
+ method: 'POST',
53
+ headers,
54
+ body: JSON.stringify(body),
55
+ signal: AbortSignal.timeout(60_000),
56
+ });
57
+ }
58
+ catch (err) {
59
+ throw new Error(`MCP gateway unreachable (${method}): ${err?.message || err}`);
60
+ }
61
+ // The initialize response assigns the session id for subsequent calls.
62
+ const sid = resp.headers.get('mcp-session-id');
63
+ if (sid)
64
+ this.sessionId = sid;
65
+ if (isNotification)
66
+ return undefined; // 202 Accepted, no JSON-RPC reply
67
+ if (!resp.ok) {
68
+ const text = await resp.text().catch(() => '');
69
+ if (resp.status === 401 || resp.status === 403) {
70
+ throw new Error(`MCP auth failed (HTTP ${resp.status}) — run \`/login\` or check your token.`);
71
+ }
72
+ throw new Error(`MCP ${method} failed: HTTP ${resp.status} ${text.slice(0, 200)}`);
73
+ }
74
+ const ctype = resp.headers.get('content-type') || '';
75
+ const message = ctype.includes('text/event-stream')
76
+ ? await this.readSseForId(resp, id)
77
+ : await resp.json();
78
+ if (message?.error) {
79
+ const e = message.error;
80
+ throw new Error(`MCP ${method} error: ${e?.message || JSON.stringify(e)}`);
81
+ }
82
+ return message?.result;
83
+ }
84
+ /** Read an SSE response body and return the JSON-RPC message matching `id`. */
85
+ async readSseForId(resp, id) {
86
+ const stream = resp.body;
87
+ if (!stream)
88
+ throw new Error('No MCP response body');
89
+ const reader = stream.getReader();
90
+ const decoder = new TextDecoder();
91
+ let buffer = '';
92
+ try {
93
+ while (true) {
94
+ const { done, value } = await reader.read();
95
+ if (done)
96
+ break;
97
+ buffer += decoder.decode(value, { stream: true });
98
+ buffer = buffer.replace(/\r\n/g, '\n');
99
+ const parts = buffer.split('\n\n');
100
+ buffer = parts.pop() || '';
101
+ for (const part of parts) {
102
+ for (const line of part.split('\n')) {
103
+ if (!line.startsWith('data:'))
104
+ continue;
105
+ const payload = line.slice(line.indexOf(':') + 1).trim();
106
+ if (!payload)
107
+ continue;
108
+ try {
109
+ const msg = JSON.parse(payload);
110
+ // Skip server-initiated requests/notifications; we want our reply.
111
+ if (msg && msg.id === id)
112
+ return msg;
113
+ }
114
+ catch {
115
+ /* keep-alive or partial — ignore */
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ finally {
122
+ reader.releaseLock();
123
+ }
124
+ throw new Error(`MCP stream ended without a reply for id ${id}`);
125
+ }
126
+ /** Initialize the MCP session (idempotent, single-flight). */
127
+ async connect() {
128
+ if (this.initialized)
129
+ return;
130
+ if (this.initInFlight)
131
+ return this.initInFlight;
132
+ this.initInFlight = (async () => {
133
+ await this.rpc('initialize', {
134
+ protocolVersion: PROTOCOL_VERSION,
135
+ capabilities: {},
136
+ clientInfo: CLIENT_INFO,
137
+ });
138
+ // Best-effort "initialized" notification — some servers require it.
139
+ try {
140
+ await this.rpc('notifications/initialized', undefined, true);
141
+ }
142
+ catch {
143
+ /* non-fatal */
144
+ }
145
+ this.initialized = true;
146
+ })();
147
+ try {
148
+ await this.initInFlight;
149
+ }
150
+ finally {
151
+ this.initInFlight = null;
152
+ }
153
+ }
154
+ /** List the tools the authenticated caller is entitled to. */
155
+ async listTools() {
156
+ await this.connect();
157
+ const result = await this.rpc('tools/list', {});
158
+ return (result?.tools || []);
159
+ }
160
+ /** Invoke a tool by name. Returns the MCP `{content, isError}` result. */
161
+ async callTool(name, args = {}) {
162
+ await this.connect();
163
+ return (await this.rpc('tools/call', { name, arguments: args }));
164
+ }
165
+ /** Cheap reachability + auth check. */
166
+ async ping() {
167
+ try {
168
+ await this.connect();
169
+ return true;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ }
176
+ // ── Singleton keyed by (url, token) ───────────────────────────────────────
177
+ // The REPL reuses one session per endpoint; a token/endpoint change rebuilds it.
178
+ let _client = null;
179
+ let _key = '';
180
+ /**
181
+ * Return a remote MCP client for the active config, or null when no MCP
182
+ * gateway URL is configured (local/Genesis mode uses the chat backend's
183
+ * REST /tools/call instead).
184
+ */
185
+ export function getRemoteMcpClient(config) {
186
+ if (!config?.mcpUrl)
187
+ return null;
188
+ // Prefer the live token from ~/.aither/auth.json so an in-REPL /login or
189
+ // /logout re-keys the client; fall back to the token captured at load time.
190
+ const token = getActiveToken() ?? config.authToken ?? null;
191
+ const key = `${config.mcpUrl}::${token || ''}`;
192
+ if (_client && _key === key)
193
+ return _client;
194
+ _client = new McpHttpClient(config.mcpUrl, token);
195
+ _key = key;
196
+ return _client;
197
+ }
198
+ /** Drop the cached client (e.g. after logout/endpoint change). */
199
+ export function resetRemoteMcpClient() {
200
+ _client = null;
201
+ _key = '';
202
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * AitherRelay client — native IRC-style group chat (humans + agents) over the
3
+ * relay WebSocket (`/ws/chat`, served by CommunicationCore). Agents joined to a
4
+ * channel respond in-line (server-side `_trigger_group_chat`), so this is the
5
+ * same multiagent room model as the DaoOS room.
6
+ *
7
+ * Protocol (client → server):
8
+ * { type: 'join', channel, nick, token }
9
+ * { type: 'message', channel, content }
10
+ * { type: 'typing', channel }
11
+ * { type: 'command', command: '/part', channel }
12
+ * (server → client): history | message | join | part | userlist | typing | error | system
13
+ *
14
+ * Auth: the token rides in the join payload (the relay resolves identity from
15
+ * it) — no custom WS headers needed, so the platform global WebSocket works.
16
+ */
17
+ export interface RelayMessage {
18
+ channel: string;
19
+ nick: string;
20
+ content: string;
21
+ agent?: boolean;
22
+ timestamp?: number | string;
23
+ id?: string;
24
+ }
25
+ export interface RelayUser {
26
+ nick: string;
27
+ is_agent?: boolean;
28
+ status?: string;
29
+ }
30
+ export type RelayStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
31
+ export interface RelayHandlers {
32
+ onStatus?: (status: RelayStatus, detail?: string) => void;
33
+ onHistory?: (channel: string, messages: RelayMessage[]) => void;
34
+ onMessage?: (msg: RelayMessage) => void;
35
+ onJoin?: (nick: string, channel: string, isAgent?: boolean) => void;
36
+ onPart?: (nick: string, channel: string) => void;
37
+ onUserlist?: (channel: string, users: RelayUser[]) => void;
38
+ onTyping?: (nick: string, channel: string) => void;
39
+ onError?: (message: string) => void;
40
+ }
41
+ export interface RelayChannel {
42
+ name: string;
43
+ topic?: string;
44
+ mode?: string;
45
+ user_count?: number;
46
+ }
47
+ /** Resolve the relay WS URL: AITHER_RELAY_URL override, else local CommunicationCore. */
48
+ export declare function resolveRelayUrl(): string;
49
+ export declare class RelayClient {
50
+ private ws;
51
+ private readonly url;
52
+ private readonly token?;
53
+ readonly nick: string;
54
+ private channel;
55
+ private readonly handlers;
56
+ private closedByUser;
57
+ private backoff;
58
+ private outbox;
59
+ constructor(opts: {
60
+ url?: string;
61
+ token?: string;
62
+ nick: string;
63
+ handlers: RelayHandlers;
64
+ });
65
+ get currentChannel(): string;
66
+ /** Join payload. When authenticated, OMIT the requested nick — the relay
67
+ * rejects a join whose nick doesn't match the token's identity nick. Letting
68
+ * the server pick the identity nick avoids that hard failure. Anonymous
69
+ * sessions send their chosen nick. */
70
+ private joinPayload;
71
+ /** HTTP base for the relay REST API (derive from the ws url). */
72
+ private httpBase;
73
+ private authHeaders;
74
+ /** List channels via REST for the picker. `scope` selects platform (default —
75
+ * public + the channels you can access, including workspace channels you're a
76
+ * member of), 'community', or 'workspace:<slug>'. Anonymous sessions get the
77
+ * public global/platform channels. Never throws. */
78
+ listChannels(scope?: string): Promise<RelayChannel[]>;
79
+ /** Fetch messages OLDER than `beforeTimestamp` (ISO) for scrollback. Returns
80
+ * them oldest→newest. Never throws. */
81
+ loadOlder(beforeTimestamp: string, limit?: number): Promise<RelayMessage[]>;
82
+ /** Connect and join `channel`. Reconnects with backoff until disconnect(). */
83
+ connect(channel: string): void;
84
+ private open;
85
+ private scheduleReconnect;
86
+ private onData;
87
+ /** Switch channels (server sends fresh history + userlist). */
88
+ join(channel: string): void;
89
+ /** Send a chat message to the current channel. Queues if the socket is mid-
90
+ * reconnect so nothing is silently dropped (flushed on the next open). */
91
+ send(content: string): void;
92
+ typing(): void;
93
+ part(channel?: string): void;
94
+ disconnect(): void;
95
+ private sendRaw;
96
+ }
package/dist/relay.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * AitherRelay client — native IRC-style group chat (humans + agents) over the
3
+ * relay WebSocket (`/ws/chat`, served by CommunicationCore). Agents joined to a
4
+ * channel respond in-line (server-side `_trigger_group_chat`), so this is the
5
+ * same multiagent room model as the DaoOS room.
6
+ *
7
+ * Protocol (client → server):
8
+ * { type: 'join', channel, nick, token }
9
+ * { type: 'message', channel, content }
10
+ * { type: 'typing', channel }
11
+ * { type: 'command', command: '/part', channel }
12
+ * (server → client): history | message | join | part | userlist | typing | error | system
13
+ *
14
+ * Auth: the token rides in the join payload (the relay resolves identity from
15
+ * it) — no custom WS headers needed, so the platform global WebSocket works.
16
+ */
17
+ /** Resolve the relay WS URL: AITHER_RELAY_URL override, else local CommunicationCore. */
18
+ export function resolveRelayUrl() {
19
+ const env = process.env.AITHER_RELAY_URL;
20
+ if (env)
21
+ return env.replace(/\/+$/, '');
22
+ // Local default — CommunicationCore serves the relay (root-mounted) on 8205,
23
+ // TLS-only, so wss://. The shell sets NODE_TLS_REJECT_UNAUTHORIZED=0 for the
24
+ // self-signed localhost cert. Point at wss://irc.aitherium.com/ws/chat for the
25
+ // hosted relay via AITHER_RELAY_URL.
26
+ return 'wss://127.0.0.1:8205/ws/chat';
27
+ }
28
+ export class RelayClient {
29
+ ws = null;
30
+ url;
31
+ token;
32
+ nick;
33
+ channel = '#general';
34
+ handlers;
35
+ closedByUser = false;
36
+ backoff = 1000;
37
+ outbox = []; // messages typed while the socket is down
38
+ constructor(opts) {
39
+ this.url = (opts.url || resolveRelayUrl()).replace(/\/+$/, '');
40
+ this.token = opts.token;
41
+ this.nick = opts.nick;
42
+ this.handlers = opts.handlers;
43
+ }
44
+ get currentChannel() { return this.channel; }
45
+ /** Join payload. When authenticated, OMIT the requested nick — the relay
46
+ * rejects a join whose nick doesn't match the token's identity nick. Letting
47
+ * the server pick the identity nick avoids that hard failure. Anonymous
48
+ * sessions send their chosen nick. */
49
+ joinPayload(channel) {
50
+ const p = { type: 'join', channel };
51
+ if (this.token)
52
+ p.token = this.token;
53
+ else
54
+ p.nick = this.nick;
55
+ return p;
56
+ }
57
+ /** HTTP base for the relay REST API (derive from the ws url). */
58
+ httpBase() {
59
+ return this.url.replace(/^ws/, 'http').replace(/\/ws\/chat$/, '');
60
+ }
61
+ authHeaders() {
62
+ const h = { Accept: 'application/json' };
63
+ if (this.token)
64
+ h['Authorization'] = `Bearer ${this.token}`;
65
+ return h;
66
+ }
67
+ /** List channels via REST for the picker. `scope` selects platform (default —
68
+ * public + the channels you can access, including workspace channels you're a
69
+ * member of), 'community', or 'workspace:<slug>'. Anonymous sessions get the
70
+ * public global/platform channels. Never throws. */
71
+ async listChannels(scope = 'platform') {
72
+ try {
73
+ const u = `${this.httpBase()}/v1/channels?scope=${encodeURIComponent(scope)}&nick=${encodeURIComponent(this.nick)}`;
74
+ const r = await fetch(u, { headers: this.authHeaders(), signal: AbortSignal.timeout(6000) });
75
+ if (!r.ok)
76
+ return [];
77
+ const data = await r.json();
78
+ const raw = Array.isArray(data) ? data : (data.channels || data.items || []);
79
+ return raw.map((c) => ({
80
+ name: typeof c === 'string' ? c : (c.name || c.channel || c.id),
81
+ topic: c.topic,
82
+ mode: c.mode,
83
+ user_count: c.user_count ?? c.users ?? c.member_count,
84
+ })).filter((c) => c.name);
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ }
90
+ /** Fetch messages OLDER than `beforeTimestamp` (ISO) for scrollback. Returns
91
+ * them oldest→newest. Never throws. */
92
+ async loadOlder(beforeTimestamp, limit = 50) {
93
+ try {
94
+ const ch = this.channel.replace(/^#/, '');
95
+ const u = `${this.httpBase()}/v1/channels/${encodeURIComponent(ch)}/messages`
96
+ + `?limit=${limit}&before=${encodeURIComponent(beforeTimestamp)}`;
97
+ const r = await fetch(u, { headers: this.authHeaders(), signal: AbortSignal.timeout(8000) });
98
+ if (!r.ok)
99
+ return [];
100
+ const data = await r.json();
101
+ const msgs = (Array.isArray(data) ? data : (data.messages || []));
102
+ return msgs.slice().sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }
108
+ /** Connect and join `channel`. Reconnects with backoff until disconnect(). */
109
+ connect(channel) {
110
+ this.channel = channel || this.channel;
111
+ this.closedByUser = false;
112
+ this.open();
113
+ }
114
+ open() {
115
+ const WS = globalThis.WebSocket;
116
+ if (!WS) {
117
+ this.handlers.onError?.('WebSocket unavailable (needs Node 22+/bun).');
118
+ return;
119
+ }
120
+ this.handlers.onStatus?.('connecting');
121
+ let ws;
122
+ try {
123
+ ws = new WS(this.url);
124
+ }
125
+ catch (e) {
126
+ this.handlers.onError?.(`relay connect failed: ${e?.message || e}`);
127
+ this.scheduleReconnect();
128
+ return;
129
+ }
130
+ this.ws = ws;
131
+ ws.addEventListener('open', () => {
132
+ this.backoff = 1000;
133
+ this.handlers.onStatus?.('open');
134
+ this.sendRaw(this.joinPayload(this.channel));
135
+ // Flush anything typed while we were down (after the rejoin).
136
+ if (this.outbox.length) {
137
+ const pending = this.outbox.splice(0);
138
+ for (const content of pending)
139
+ this.sendRaw({ type: 'message', channel: this.channel, content });
140
+ }
141
+ });
142
+ ws.addEventListener('message', (ev) => this.onData(ev.data));
143
+ ws.addEventListener('close', () => {
144
+ this.ws = null;
145
+ if (this.closedByUser)
146
+ this.handlers.onStatus?.('closed');
147
+ else
148
+ this.scheduleReconnect();
149
+ });
150
+ ws.addEventListener('error', () => { });
151
+ }
152
+ scheduleReconnect() {
153
+ if (this.closedByUser)
154
+ return;
155
+ this.handlers.onStatus?.('reconnecting');
156
+ const wait = this.backoff;
157
+ this.backoff = Math.min(this.backoff * 2, 15000);
158
+ setTimeout(() => { if (!this.closedByUser)
159
+ this.open(); }, wait);
160
+ }
161
+ onData(raw) {
162
+ let d;
163
+ try {
164
+ d = JSON.parse(String(raw));
165
+ }
166
+ catch {
167
+ return;
168
+ }
169
+ switch (d.type) {
170
+ case 'history':
171
+ this.handlers.onHistory?.(d.channel, (d.messages || []));
172
+ break;
173
+ case 'message':
174
+ this.handlers.onMessage?.(d);
175
+ break;
176
+ case 'join':
177
+ this.handlers.onJoin?.(d.nick, d.channel, d.is_agent);
178
+ break;
179
+ case 'part':
180
+ this.handlers.onPart?.(d.nick, d.channel);
181
+ break;
182
+ case 'userlist':
183
+ this.handlers.onUserlist?.(d.channel, (d.users || []));
184
+ break;
185
+ case 'typing':
186
+ this.handlers.onTyping?.(d.nick, d.channel);
187
+ break;
188
+ case 'error':
189
+ case 'system':
190
+ this.handlers.onError?.(d.message || d.content || 'relay error');
191
+ break;
192
+ default:
193
+ break;
194
+ }
195
+ }
196
+ /** Switch channels (server sends fresh history + userlist). */
197
+ join(channel) {
198
+ this.channel = channel.startsWith('#') ? channel : `#${channel}`;
199
+ this.sendRaw(this.joinPayload(this.channel));
200
+ }
201
+ /** Send a chat message to the current channel. Queues if the socket is mid-
202
+ * reconnect so nothing is silently dropped (flushed on the next open). */
203
+ send(content) {
204
+ const c = (content || '').trim();
205
+ if (!c)
206
+ return;
207
+ const open = this.ws && this.ws.readyState === 1 /* OPEN */;
208
+ if (open)
209
+ this.sendRaw({ type: 'message', channel: this.channel, content: c });
210
+ else {
211
+ this.outbox.push(c);
212
+ if (this.outbox.length > 100)
213
+ this.outbox.shift();
214
+ }
215
+ }
216
+ typing() { this.sendRaw({ type: 'typing', channel: this.channel }); }
217
+ part(channel) {
218
+ this.sendRaw({ type: 'command', command: '/part', channel: channel || this.channel });
219
+ }
220
+ disconnect() {
221
+ this.closedByUser = true;
222
+ try {
223
+ this.ws?.close();
224
+ }
225
+ catch { /* */ }
226
+ this.ws = null;
227
+ }
228
+ sendRaw(obj) {
229
+ try {
230
+ this.ws?.send(JSON.stringify(obj));
231
+ }
232
+ catch { /* dropped; reconnect will rejoin */ }
233
+ }
234
+ }
@@ -2,6 +2,9 @@
2
2
  * Terminal renderer — banners, streaming tokens, markdown, tables.
3
3
  */
4
4
  import type { SSEEvent } from './client.js';
5
+ export declare const TRACE_FULL: boolean;
6
+ /** Wrap text in an OSC 8 clickable hyperlink for terminals that support it. */
7
+ export declare function osc8Link(url: string, label?: string): string;
5
8
  export declare function renderBanner(info: {
6
9
  genesis: string;
7
10
  genesisOnline?: boolean;
@@ -21,11 +24,21 @@ export declare function renderBanner(info: {
21
24
  * and render as clickable OSC 8 terminal hyperlinks.
22
25
  */
23
26
  export declare function resolveImagePath(relativePath: string): string;
27
+ /** Strip OSC-8 hyperlink escapes (blessed/TUI can't render them → raw `]8;;`). */
28
+ export declare function stripOsc8(text: string): string;
29
+ /** Open a local image in the OS default viewer (detached). Best-effort. */
30
+ export declare function openLocalImage(p: string): void;
31
+ /** Scan answer text for generated-image markdown and open any LOCAL ones in the
32
+ * OS viewer (so the user actually SEES the image — the TUI can't render it
33
+ * inline). Opt out with AITHER_AUTO_OPEN_IMAGES=0. */
34
+ export declare function autoOpenImagesFromText(text: string): void;
24
35
  export declare function renderMarkdown(text: string): string;
25
36
  export interface StreamRenderer {
26
37
  onEvent(event: SSEEvent): void;
27
38
  getContent(): string;
28
- finish(): void;
39
+ /** End-of-turn cleanup. `aborted` = the user interrupted (Ctrl+C) — skip
40
+ * the "stream ended before completion" warning in that case. */
41
+ finish(aborted?: boolean): void;
29
42
  /** Full trace of all events received during this session prompt. */
30
43
  getTrace(): SSEEvent[];
31
44
  /** Session profile for persistence and future reference. */
@@ -55,43 +68,53 @@ export interface SessionArtifact {
55
68
  size: number;
56
69
  language: string;
57
70
  retrieve_cmd: string;
71
+ download_url: string;
58
72
  timestamp: string;
59
73
  }
60
74
  export declare function getSessionArtifacts(): SessionArtifact[];
61
75
  export declare function clearSessionArtifacts(): void;
76
+ /** Register a locally-saved file as a session artifact (so /get + /artifacts find it). */
77
+ export declare function addSessionArtifact(a: {
78
+ path: string;
79
+ size?: number;
80
+ language?: string;
81
+ }): number;
62
82
  export declare function createStreamRenderer(sessionId?: string, prompt?: string, steeringBar?: SteeringBar): StreamRenderer;
63
83
  export declare function formatTable(headers: string[], rows: string[][]): string;
64
84
  /**
65
- * Keeps a visible input line at the bottom of the terminal while
66
- * stream output prints above. Works on Windows Terminal (no ANSI
67
- * scroll regions).
85
+ * Keeps a fixed input bar at the bottom of the terminal while
86
+ * stream output scrolls above. Uses DECSTBM (Set Scrolling Region)
87
+ * to physically separate the output area from the bar — no stdout
88
+ * monkey-patching, no erase/redraw races.
68
89
  *
69
- * How it works:
70
- * 1. Intercepts process.stdout.write
71
- * 2. Before each write: erase the bar line so output doesn't collide
72
- * 3. After each write: redraw the bar on a fresh line below output
73
- * 4. User keystrokes update the bar's displayed text in real time
74
- * 5. On Enter, the bar clears and the repl routes to /chat/steer
90
+ * Layout:
91
+ * Rows 1 to (rows-3): SCROLL REGION — all output, tokens, spinners
92
+ * Row (rows-2): Status spinner (e.g., "generation...")
93
+ * Row (rows-1): Separator line ────────
94
+ * Row (rows): [steer] > input
75
95
  *
76
- * The bar line looks like:
77
- * ── [steer] > what the user is typing_
96
+ * Output cannot collide with the bar because they occupy different
97
+ * terminal regions.
78
98
  */
79
99
  export declare class SteeringBar {
80
100
  private _active;
81
101
  private _inputText;
82
102
  private _statusText;
83
- private _origWrite;
84
- private _barDrawn;
85
- private _debounceTimer;
86
103
  private _spinFrame;
87
104
  private _spinTimer;
105
+ private _tokenMode;
106
+ private _resizeHandler;
107
+ private _resizeTimer;
108
+ private _lastRows;
88
109
  private static readonly _SPIN_CHARS;
89
110
  get active(): boolean;
90
111
  get inputText(): string;
91
112
  activate(): void;
113
+ /** Debounced, artifact-free resize repaint. */
114
+ private _handleResize;
92
115
  deactivate(): void;
93
- /** Schedule a single debounced redraw. All state changes route here. */
94
- private _scheduleRedraw;
116
+ /** Set scroll region and draw bar, preserving cursor position. */
117
+ private _setupRegion;
95
118
  /** Show spinner + status text in the bar (replaces ora when bar is active). */
96
119
  setStatus(text: string): void;
97
120
  /** Clear status text. */
@@ -100,5 +123,12 @@ export declare class SteeringBar {
100
123
  setInput(text: string): void;
101
124
  /** Clear input after steer send. */
102
125
  clearInput(): void;
126
+ enterTokenMode(): void;
127
+ exitTokenMode(): void;
128
+ /**
129
+ * Draw the fixed bottom bar — single atomic write.
130
+ * Uses absolute positioning outside the scroll region, then restores
131
+ * cursor back into the scroll region.
132
+ */
103
133
  private _drawBar;
104
134
  }