@steerable/agent-cli 0.6.57

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,25 @@
1
+ import { ProcessTerminal, TuiAltScreen } from '@earendil-works/pi-tui';
2
+ import { installAgentKeybindings } from './keys.js';
3
+ import { AgentTui } from './session.js';
4
+ export async function runTui(options) {
5
+ installAgentKeybindings();
6
+ const terminal = options.terminal ?? new ProcessTerminal();
7
+ const tui = new TuiAltScreen(terminal);
8
+ return new Promise((resolve) => {
9
+ const session = new AgentTui(options.client, {
10
+ product: options.product,
11
+ ...(options.dataDir ? { dataDir: options.dataDir } : {}),
12
+ onChange() {
13
+ tui.requestRender();
14
+ },
15
+ onExit() {
16
+ tui.stop();
17
+ resolve(0);
18
+ },
19
+ });
20
+ tui.addChild(session);
21
+ tui.setFocus(session);
22
+ void session.open().then(() => tui.requestRender());
23
+ tui.start();
24
+ });
25
+ }
@@ -0,0 +1,33 @@
1
+ export interface TranscriptLine {
2
+ kind: 'user' | 'assistant' | 'tool';
3
+ text?: string;
4
+ name?: string;
5
+ args?: string;
6
+ status?: string;
7
+ }
8
+ export interface ChatRow {
9
+ id: string;
10
+ title: string;
11
+ busy: boolean;
12
+ selected: boolean;
13
+ }
14
+ export interface TuiScreen {
15
+ product: string;
16
+ title: string;
17
+ modelName: string;
18
+ lines: TranscriptLine[];
19
+ approval: {
20
+ toolName: string;
21
+ summary: string;
22
+ } | null;
23
+ ask: {
24
+ prompt: string;
25
+ } | null;
26
+ chats: ChatRow[] | null;
27
+ readOnly: boolean;
28
+ help: boolean;
29
+ draft: string;
30
+ status: string;
31
+ }
32
+ export declare function renderScreen(screen: TuiScreen, width: number): string[];
33
+ export declare function visibleText(lines: string[]): string;
@@ -0,0 +1,59 @@
1
+ import { Box, Text } from '@earendil-works/pi-tui';
2
+ import { keyLabel } from './keys.js';
3
+ export function renderScreen(screen, width) {
4
+ const box = new Box(0, 0);
5
+ for (const line of screenLines(screen))
6
+ box.addChild(new PlainLine(line));
7
+ return box.render(width);
8
+ }
9
+ export function visibleText(lines) {
10
+ return lines.map((line) => line.trimEnd()).join('\n');
11
+ }
12
+ function screenLines(screen) {
13
+ const lines = [`${screen.product} · ${screen.title} · ${screen.modelName}`];
14
+ if (screen.help) {
15
+ lines.push('/new 新会话', '/model 查看或切换模型', '/clear 清屏', '/help 帮助');
16
+ }
17
+ else if (screen.chats) {
18
+ lines.push('会话');
19
+ for (const chat of screen.chats) {
20
+ const mark = chat.selected ? '*' : ' ';
21
+ const busy = chat.busy ? ' 只读' : '';
22
+ lines.push(`${mark} ${chat.id} ${chat.title}${busy}`);
23
+ }
24
+ }
25
+ else {
26
+ for (const line of screen.lines) {
27
+ if (line.kind === 'tool')
28
+ lines.push(`▸ ${line.name} ${line.args} ${line.status ?? ''}`.trimEnd());
29
+ else if (line.kind === 'user')
30
+ lines.push(`user ${line.text ?? ''}`);
31
+ else
32
+ lines.push(line.text ?? '');
33
+ }
34
+ }
35
+ if (screen.readOnly)
36
+ lines.push('只读 · 另一个进程正在运行');
37
+ if (screen.approval) {
38
+ lines.push(`审批 ${screen.approval.toolName} ${screen.approval.summary}`.trimEnd());
39
+ lines.push(`${keyLabel('agent.approval.allowOnce')} 本次允许 ${keyLabel('agent.approval.allowSession')} 本会话 ${keyLabel('agent.approval.allowAlways')} 总是`);
40
+ lines.push(`${keyLabel('agent.approval.denyOnce')} 拒绝 ${keyLabel('agent.approval.denySession')} 本会话拒绝 ${keyLabel('agent.approval.denyAlways')} 总是拒绝 ${keyLabel('agent.approval.abort')} 中止`);
41
+ }
42
+ if (screen.ask)
43
+ lines.push(`追问 ${screen.ask.prompt}`);
44
+ lines.push(`> ${screen.draft}`);
45
+ if (screen.status)
46
+ lines.push(screen.status);
47
+ lines.push(`${keyLabel('agent.interrupt')} 中断 · ${keyLabel('agent.chats')} 会话 · /help`);
48
+ return lines;
49
+ }
50
+ class PlainLine {
51
+ text;
52
+ constructor(text) {
53
+ this.text = text;
54
+ }
55
+ invalidate() { }
56
+ render(width) {
57
+ return new Text(this.text.length > 0 ? this.text : ' ', 0, 0).render(width);
58
+ }
59
+ }
@@ -0,0 +1,48 @@
1
+ import { type Component } from '@earendil-works/pi-tui';
2
+ import { type AgentClient } from '@steerable/agent-client';
3
+ export interface AgentTuiOptions {
4
+ product: string;
5
+ dataDir?: string;
6
+ busyChatIds?: string[];
7
+ onExit: () => void;
8
+ onChange?: () => void;
9
+ }
10
+ export declare class AgentTui implements Component {
11
+ private readonly client;
12
+ private readonly options;
13
+ private title;
14
+ private modelName;
15
+ private chatId;
16
+ private lines;
17
+ private rows;
18
+ private chats;
19
+ private selected;
20
+ private approval;
21
+ private ask;
22
+ private readOnly;
23
+ private help;
24
+ private draft;
25
+ private status;
26
+ private stopped;
27
+ private turnAbort;
28
+ private pending;
29
+ constructor(client: AgentClient, options: AgentTuiOptions);
30
+ open(): Promise<void>;
31
+ settled(): Promise<void>;
32
+ invalidate(): void;
33
+ render(width: number): string[];
34
+ handleInput(data: string): void;
35
+ private screen;
36
+ private showChats;
37
+ private loadChats;
38
+ private markSelection;
39
+ private openChat;
40
+ private submit;
41
+ private slash;
42
+ private runTurn;
43
+ private observe;
44
+ private watch;
45
+ private decide;
46
+ private touch;
47
+ private exit;
48
+ }
@@ -0,0 +1,367 @@
1
+ import { getKeybindings } from '@earendil-works/pi-tui';
2
+ import { listBusyChatIds } from '@steerable/agent-client';
3
+ import { ensureAgentKeybindings } from './keys.js';
4
+ import { renderScreen } from './screen.js';
5
+ export class AgentTui {
6
+ client;
7
+ options;
8
+ title = '新会话';
9
+ modelName = '';
10
+ chatId = null;
11
+ lines = [];
12
+ rows = [];
13
+ chats = null;
14
+ selected = 0;
15
+ approval = null;
16
+ ask = null;
17
+ readOnly = false;
18
+ help = false;
19
+ draft = '';
20
+ status = '';
21
+ stopped = false;
22
+ turnAbort = null;
23
+ pending = Promise.resolve();
24
+ constructor(client, options) {
25
+ this.client = client;
26
+ this.options = options;
27
+ ensureAgentKeybindings();
28
+ }
29
+ async open() {
30
+ const settings = await this.client.request('GET', '/api/v2/local-settings/llm', undefined);
31
+ if (settings.status === 200) {
32
+ const model = settings.data.model;
33
+ if (model)
34
+ this.modelName = model;
35
+ }
36
+ this.rows = await this.loadChats();
37
+ const current = this.rows[0];
38
+ if (current)
39
+ await this.openChat(current.id);
40
+ void this.watch();
41
+ this.touch();
42
+ }
43
+ async settled() {
44
+ await this.pending;
45
+ }
46
+ invalidate() { }
47
+ render(width) {
48
+ return renderScreen(this.screen(), width);
49
+ }
50
+ handleInput(data) {
51
+ if (data.length > 1 && !data.includes('\x1b')) {
52
+ for (const char of data)
53
+ this.handleInput(char);
54
+ return;
55
+ }
56
+ const keys = getKeybindings();
57
+ if (keys.matches(data, 'agent.interrupt')) {
58
+ if (this.turnAbort && !this.turnAbort.signal.aborted) {
59
+ this.turnAbort.abort();
60
+ this.status = '已中断';
61
+ return;
62
+ }
63
+ if (this.approval) {
64
+ void this.decide('abort');
65
+ return;
66
+ }
67
+ this.exit();
68
+ return;
69
+ }
70
+ if (this.approval) {
71
+ const kind = approvalKind(data);
72
+ if (kind)
73
+ void this.decide(kind);
74
+ return;
75
+ }
76
+ if (this.help) {
77
+ if (keys.matches(data, 'tui.select.cancel'))
78
+ this.help = false;
79
+ return;
80
+ }
81
+ if (this.chats) {
82
+ if (keys.matches(data, 'tui.select.up'))
83
+ this.selected = Math.max(0, this.selected - 1);
84
+ if (keys.matches(data, 'tui.select.down'))
85
+ this.selected = Math.min(this.chats.length - 1, this.selected + 1);
86
+ if (keys.matches(data, 'tui.select.cancel')) {
87
+ this.chats = null;
88
+ return;
89
+ }
90
+ if (keys.matches(data, 'tui.select.confirm')) {
91
+ const chat = this.chats[this.selected];
92
+ this.chats = null;
93
+ if (chat) {
94
+ this.readOnly = chat.busy;
95
+ this.title = chat.title;
96
+ this.chatId = chat.id;
97
+ void this.openChat(chat.id);
98
+ }
99
+ return;
100
+ }
101
+ this.markSelection();
102
+ return;
103
+ }
104
+ if (keys.matches(data, 'agent.chats')) {
105
+ this.showChats();
106
+ return;
107
+ }
108
+ if (this.ask && keys.matches(data, 'tui.input.submit')) {
109
+ const requestId = this.ask.requestId;
110
+ const answer = this.draft;
111
+ this.draft = '';
112
+ this.ask = null;
113
+ void this.client.answerAsk(requestId, { text: answer });
114
+ return;
115
+ }
116
+ if (keys.matches(data, 'tui.input.newLine')) {
117
+ this.draft += '\n';
118
+ return;
119
+ }
120
+ if (keys.matches(data, 'tui.input.submit')) {
121
+ void this.submit();
122
+ return;
123
+ }
124
+ if (keys.matches(data, 'tui.editor.deleteCharBackward')) {
125
+ this.draft = [...this.draft].slice(0, -1).join('');
126
+ return;
127
+ }
128
+ if (data.length > 0 && [...data].every((char) => char >= ' ' && char !== '\x7f'))
129
+ this.draft += data;
130
+ }
131
+ screen() {
132
+ return {
133
+ product: this.options.product,
134
+ title: this.title,
135
+ modelName: this.modelName,
136
+ lines: this.lines,
137
+ approval: this.approval,
138
+ ask: this.ask ? { prompt: this.ask.prompt } : null,
139
+ chats: this.chats,
140
+ readOnly: this.readOnly,
141
+ help: this.help,
142
+ draft: this.draft,
143
+ status: this.status,
144
+ };
145
+ }
146
+ showChats() {
147
+ const index = this.rows.findIndex((chat) => chat.id === this.chatId);
148
+ this.selected = index >= 0 ? index : 0;
149
+ this.chats = this.rows.map((chat, row) => ({ ...chat, selected: row === this.selected }));
150
+ void this.loadChats().then((rows) => {
151
+ this.rows = rows;
152
+ if (this.chats)
153
+ this.markSelection();
154
+ });
155
+ }
156
+ async loadChats() {
157
+ const listed = await this.client.request('GET', '/api/v2/chats', undefined);
158
+ const rows = listed.status === 200
159
+ ? (listed.data.chats ?? [])
160
+ : [];
161
+ const busy = new Set(this.options.busyChatIds ?? (this.options.dataDir ? listBusyChatIds(this.options.dataDir) : []));
162
+ return rows.map((chat) => ({
163
+ id: chat.id,
164
+ title: chat.title || chat.id,
165
+ busy: busy.has(chat.id),
166
+ selected: false,
167
+ }));
168
+ }
169
+ markSelection() {
170
+ this.chats = (this.chats ?? []).map((chat, index) => ({ ...chat, selected: index === this.selected }));
171
+ }
172
+ async openChat(id) {
173
+ const encoded = encodeURIComponent(id);
174
+ const chat = await this.client.request('GET', `/api/v2/chats/${encoded}`, undefined);
175
+ const messages = await this.client.request('GET', `/api/v2/chats/${encoded}/messages`, undefined);
176
+ this.chatId = id;
177
+ const title = chat.status === 200 ? chat.data.title : '';
178
+ this.title = title || id;
179
+ this.readOnly = (this.options.busyChatIds ?? []).includes(id)
180
+ || (this.options.dataDir ? listBusyChatIds(this.options.dataDir).includes(id) : false);
181
+ const records = messages.status === 200
182
+ ? (messages.data.messages ?? [])
183
+ : [];
184
+ this.lines = records.map((message) => ({
185
+ kind: message.role === 'user' ? 'user' : 'assistant',
186
+ text: message.content ?? '',
187
+ }));
188
+ this.help = false;
189
+ this.touch();
190
+ }
191
+ async submit() {
192
+ const text = this.draft.trim();
193
+ this.draft = '';
194
+ if (!text)
195
+ return;
196
+ if (text.startsWith('/')) {
197
+ await this.slash(text);
198
+ return;
199
+ }
200
+ if (this.readOnly) {
201
+ this.status = '只读 · 另一个进程正在运行';
202
+ return;
203
+ }
204
+ this.lines.push({ kind: 'user', text });
205
+ this.pending = this.runTurn(text);
206
+ await this.pending;
207
+ }
208
+ async slash(text) {
209
+ const [command, ...rest] = text.slice(1).split(/\s+/);
210
+ if (command === 'help') {
211
+ this.help = true;
212
+ this.touch();
213
+ return;
214
+ }
215
+ if (command === 'clear') {
216
+ this.lines = [];
217
+ this.status = '';
218
+ this.touch();
219
+ return;
220
+ }
221
+ if (command === 'new') {
222
+ const created = await this.client.request('POST', '/api/v2/chats/new', {});
223
+ const chatId = created.data.chatId;
224
+ if (!chatId) {
225
+ this.status = 'failed to create chat';
226
+ return;
227
+ }
228
+ this.lines = [];
229
+ this.readOnly = false;
230
+ await this.openChat(chatId);
231
+ return;
232
+ }
233
+ if (command === 'model') {
234
+ const next = rest.join(' ');
235
+ if (!next) {
236
+ this.status = this.modelName;
237
+ return;
238
+ }
239
+ const current = await this.client.request('GET', '/api/v2/local-settings/llm', undefined);
240
+ const body = { ...current.data, model: next };
241
+ const saved = await this.client.request('POST', '/api/v2/local-settings/llm', body);
242
+ if (saved.status === 200)
243
+ this.modelName = next;
244
+ else
245
+ this.status = `config set failed (${saved.status})`;
246
+ this.touch();
247
+ return;
248
+ }
249
+ this.status = `unknown command /${command}`;
250
+ this.touch();
251
+ }
252
+ async runTurn(text) {
253
+ if (!this.chatId) {
254
+ const created = await this.client.request('POST', '/api/v2/chats/new', {});
255
+ this.chatId = created.data.chatId ?? 'draft';
256
+ this.title = this.chatId;
257
+ this.touch();
258
+ }
259
+ const controller = new AbortController();
260
+ this.turnAbort = controller;
261
+ this.status = '';
262
+ try {
263
+ for await (const event of this.client.stream(`/api/v2/chats/${encodeURIComponent(this.chatId)}/send`, { message: text }, controller.signal)) {
264
+ this.observe(event, controller.signal);
265
+ this.touch();
266
+ }
267
+ }
268
+ finally {
269
+ if (this.turnAbort === controller)
270
+ this.turnAbort = null;
271
+ }
272
+ }
273
+ observe(event, signal) {
274
+ if (event.type === 'tool_call') {
275
+ const payload = event.payload ?? {};
276
+ const name = typeof payload.name === 'string' ? payload.name : 'tool';
277
+ const args = toolArgs(payload.arguments ?? payload.input);
278
+ this.lines.push({ kind: 'tool', name, args, status: '…' });
279
+ return;
280
+ }
281
+ if (event.type === 'tool_result') {
282
+ const tool = [...this.lines].reverse().find((line) => line.kind === 'tool' && line.status === '…');
283
+ if (tool)
284
+ tool.status = '✓';
285
+ return;
286
+ }
287
+ if (event.type === 'content' && typeof event.content === 'string') {
288
+ const last = this.lines[this.lines.length - 1];
289
+ if (last?.kind === 'assistant')
290
+ last.text = `${last.text ?? ''}${event.content}`;
291
+ else
292
+ this.lines.push({ kind: 'assistant', text: event.content });
293
+ return;
294
+ }
295
+ if (event.type === 'error' && !signal.aborted) {
296
+ this.status = typeof event.message === 'string' ? event.message : 'error';
297
+ }
298
+ }
299
+ async watch() {
300
+ for await (const event of this.client.events()) {
301
+ if (this.stopped)
302
+ return;
303
+ if (event.channel === 'approval:request') {
304
+ const payload = event.payload;
305
+ if (!payload.requestId)
306
+ continue;
307
+ this.approval = {
308
+ requestId: payload.requestId,
309
+ toolName: payload.toolName || 'tool',
310
+ summary: toolArgs(payload.arguments),
311
+ };
312
+ }
313
+ else if (event.channel === 'ask-user:request') {
314
+ const payload = event.payload;
315
+ if (payload.requestId)
316
+ this.ask = { requestId: payload.requestId, prompt: payload.prompt ?? '' };
317
+ }
318
+ this.touch();
319
+ }
320
+ }
321
+ async decide(kind) {
322
+ const request = this.approval;
323
+ if (!request)
324
+ return;
325
+ this.approval = null;
326
+ this.touch();
327
+ await this.client.decideApproval(request.requestId, kind);
328
+ }
329
+ touch() {
330
+ this.options.onChange?.();
331
+ }
332
+ exit() {
333
+ this.stopped = true;
334
+ this.turnAbort?.abort();
335
+ this.options.onExit();
336
+ }
337
+ }
338
+ function approvalKind(data) {
339
+ const keys = getKeybindings();
340
+ if (keys.matches(data, 'agent.approval.allowOnce'))
341
+ return 'allow_once';
342
+ if (keys.matches(data, 'agent.approval.allowSession'))
343
+ return 'allow_for_session';
344
+ if (keys.matches(data, 'agent.approval.allowAlways'))
345
+ return 'allow_always';
346
+ if (keys.matches(data, 'agent.approval.denyOnce'))
347
+ return 'deny_once';
348
+ if (keys.matches(data, 'agent.approval.denySession'))
349
+ return 'deny_for_session';
350
+ if (keys.matches(data, 'agent.approval.denyAlways'))
351
+ return 'deny_always';
352
+ if (keys.matches(data, 'agent.approval.abort'))
353
+ return 'abort';
354
+ return null;
355
+ }
356
+ function toolArgs(value) {
357
+ if (typeof value === 'string')
358
+ return value;
359
+ if (value && typeof value === 'object' && 'command' in value) {
360
+ const command = value.command;
361
+ if (typeof command === 'string')
362
+ return command;
363
+ }
364
+ if (value === undefined)
365
+ return '';
366
+ return JSON.stringify(value);
367
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ import { runTui } from './run.js';
2
+ const queue = [];
3
+ const waiters = [];
4
+ let closed = false;
5
+ let decided = () => { };
6
+ function push(item) {
7
+ const waiter = waiters.shift();
8
+ if (waiter)
9
+ waiter(item);
10
+ else if (item)
11
+ queue.push(item);
12
+ }
13
+ const client = {
14
+ lastStatus: 200,
15
+ request: async (method, requestPath) => {
16
+ if (method === 'GET' && requestPath === '/api/v2/chats')
17
+ return { status: 200, data: { chats: [] } };
18
+ if (method === 'GET' && requestPath === '/api/v2/local-settings/llm')
19
+ return { status: 200, data: { model: 'demo-model' } };
20
+ if (method === 'POST' && requestPath === '/api/v2/chats/new')
21
+ return { status: 200, data: { chatId: 'chat-1' } };
22
+ return { status: 200, data: {} };
23
+ },
24
+ async *stream(_requestPath, body, signal) {
25
+ const message = String(body.message ?? '');
26
+ if (message.includes('hang')) {
27
+ yield { type: 'content', content: 'running' };
28
+ await new Promise((resolve) => signal.addEventListener('abort', () => resolve()));
29
+ yield { type: 'error', message: 'interrupted' };
30
+ return;
31
+ }
32
+ yield { type: 'tool_call', payload: { name: 'local_exec_shell', arguments: { command: 'ls' } } };
33
+ const approval = new Promise((resolve) => {
34
+ decided = resolve;
35
+ });
36
+ push({
37
+ channel: 'approval:request',
38
+ payload: { requestId: 'req-1', toolName: 'local_exec_shell', arguments: { command: 'ls' } },
39
+ });
40
+ await approval;
41
+ yield { type: 'tool_result', payload: { name: 'local_exec_shell' } };
42
+ yield { type: 'content', content: 'listed' };
43
+ },
44
+ async *events() {
45
+ while (!closed) {
46
+ const queued = queue.shift();
47
+ if (queued) {
48
+ yield queued;
49
+ continue;
50
+ }
51
+ const next = await new Promise((resolve) => waiters.push(resolve));
52
+ if (!next)
53
+ return;
54
+ yield next;
55
+ }
56
+ },
57
+ decideApproval: async () => {
58
+ decided();
59
+ return true;
60
+ },
61
+ answerAsk: async () => true,
62
+ close: async () => {
63
+ closed = true;
64
+ for (const waiter of waiters.splice(0))
65
+ waiter(null);
66
+ },
67
+ };
68
+ await runTui({ client, product: 'Demo' });
69
+ await client.close();
70
+ process.exit(0);
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@steerable/agent-cli",
3
+ "version": "0.6.57",
4
+ "description": "Product-neutral command line for a Steerable host. Products supply the bin name and brand.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://steerableframework.com/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pathlyapp/steerable.git",
10
+ "directory": "packages/agent-cli/ts"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/pathlyapp/steerable/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "bin": {
19
+ "steerable-cli": "dist/bin.js"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dependencies": {
39
+ "@earendil-works/pi-tui": "0.87.1",
40
+ "@steerable/agent-client": "0.6.57",
41
+ "@steerable/agent-protocol": "0.6.57",
42
+ "@steerable/agent-shell": "0.6.57"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.10.5",
46
+ "typescript": "^5.8.3",
47
+ "vitest": "^3.2.4"
48
+ },
49
+ "scripts": {
50
+ "gen": "echo agent-cli",
51
+ "build": "tsc -p tsconfig.json",
52
+ "test": "vitest run",
53
+ "lint": "tsc -p tsconfig.json --noEmit",
54
+ "check:drift": "echo ok"
55
+ }
56
+ }