@steerable/agent-cli 0.6.59 → 0.6.61

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,6 @@
1
+ export interface SessionTodo {
2
+ content: string;
3
+ status: 'pending' | 'in_progress' | 'completed';
4
+ }
5
+ /** The latest `todo_write` list. Each call replaces the whole list. */
6
+ export declare function todosFromAction(tool: string | undefined, args: unknown, result: unknown): SessionTodo[] | null;
@@ -0,0 +1,47 @@
1
+ /** The latest `todo_write` list. Each call replaces the whole list. */
2
+ export function todosFromAction(tool, args, result) {
3
+ if (tool !== 'todo_write')
4
+ return null;
5
+ return readTodos(result) ?? readTodos(args);
6
+ }
7
+ function readTodos(value) {
8
+ const direct = todoItems(value);
9
+ if (direct)
10
+ return direct;
11
+ const record = asRecord(value);
12
+ if (!record)
13
+ return null;
14
+ const nested = asRecord(record.data);
15
+ const envelope = asRecord(nested?.value);
16
+ return todoItems(record.todos) ?? todoItems(nested?.todos) ?? todoItems(envelope?.todos);
17
+ }
18
+ function todoItems(value) {
19
+ if (!Array.isArray(value))
20
+ return null;
21
+ const todos = [];
22
+ for (const item of value) {
23
+ const row = asRecord(item);
24
+ if (!row || typeof row.content !== 'string' || row.content.length === 0)
25
+ continue;
26
+ const status = row.status === 'in_progress' || row.status === 'completed' ? row.status : 'pending';
27
+ todos.push({ content: row.content, status });
28
+ }
29
+ return todos.length > 0 ? todos : null;
30
+ }
31
+ function asRecord(value) {
32
+ if (typeof value === 'string') {
33
+ const trimmed = value.trim();
34
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
35
+ return null;
36
+ try {
37
+ return asRecord(JSON.parse(trimmed));
38
+ }
39
+ catch (error) {
40
+ void error;
41
+ return null;
42
+ }
43
+ }
44
+ if (!value || typeof value !== 'object' || Array.isArray(value))
45
+ return null;
46
+ return value;
47
+ }
@@ -0,0 +1,31 @@
1
+ export interface ToolAction {
2
+ id?: string;
3
+ tool?: string;
4
+ arguments?: unknown;
5
+ view?: {
6
+ title?: string;
7
+ };
8
+ result?: unknown;
9
+ success?: boolean;
10
+ error?: unknown;
11
+ durationMs?: number;
12
+ }
13
+ export declare function formatDuration(ms: number): string;
14
+ export declare function toolStatus(action: ToolAction): string;
15
+ export declare function toolOutput(action: ToolAction): string;
16
+ export declare function plainMarkdown(source: string): string;
17
+ export interface StoredMessage {
18
+ role?: string;
19
+ content?: string;
20
+ createdAt?: string | number;
21
+ messageMetadata?: string | null;
22
+ }
23
+ export type HistoryRow = {
24
+ kind: 'user' | 'assistant' | 'tree' | 'reasoning';
25
+ text: string;
26
+ } | {
27
+ kind: 'tool';
28
+ action: ToolAction;
29
+ };
30
+ /** The list endpoint is newest-first. Dated lists are ordered by time; undated lists stay as given. */
31
+ export declare function historyRows(messages: readonly StoredMessage[]): HistoryRow[];
@@ -0,0 +1,146 @@
1
+ import { applyChildEvent, childLines } from './children.js';
2
+ export function formatDuration(ms) {
3
+ if (!Number.isFinite(ms) || ms < 0)
4
+ return '';
5
+ if (ms < 10_000)
6
+ return `${(Math.round(ms / 100) / 10).toFixed(1)}s`;
7
+ return `${Math.round(ms / 1000)}s`;
8
+ }
9
+ export function toolStatus(action) {
10
+ const settled = typeof action.durationMs === 'number' || typeof action.success === 'boolean' || action.error != null;
11
+ if (!settled)
12
+ return '…';
13
+ const mark = action.success === false || action.error != null ? '✗' : '✓';
14
+ const duration = typeof action.durationMs === 'number' ? formatDuration(action.durationMs) : '';
15
+ return duration ? `${mark} ${duration}` : mark;
16
+ }
17
+ export function toolOutput(action) {
18
+ const error = textOf(action.error);
19
+ if (error)
20
+ return clip(error);
21
+ return clip(textOf(action.result));
22
+ }
23
+ export function plainMarkdown(source) {
24
+ const out = [];
25
+ let fence = false;
26
+ for (const line of source.replace(/\r\n/g, '\n').split('\n')) {
27
+ if (line.trim().startsWith('```')) {
28
+ fence = !fence;
29
+ continue;
30
+ }
31
+ if (fence) {
32
+ out.push(line);
33
+ continue;
34
+ }
35
+ out.push(line
36
+ .replace(/^#{1,6}\s+/, '')
37
+ .replace(/\*\*([^*\n]+)\*\*/g, '$1')
38
+ .replace(/~~([^~\n]+)~~/g, '$1')
39
+ .replace(/`([^`\n]+)`/g, '$1')
40
+ .replace(/\[([^\]\n]+)\]\([^)\n]+\)/g, '$1'));
41
+ }
42
+ return out.join('\n');
43
+ }
44
+ function textOf(value) {
45
+ if (typeof value === 'string')
46
+ return value;
47
+ if (!value || typeof value !== 'object')
48
+ return '';
49
+ const record = value;
50
+ for (const key of ['stdout', 'output', 'content', 'text', 'message', 'error']) {
51
+ if (typeof record[key] === 'string' && record[key])
52
+ return record[key];
53
+ }
54
+ const data = record.data;
55
+ if (typeof data === 'string')
56
+ return data;
57
+ if (data && typeof data === 'object')
58
+ return textOf(data);
59
+ return '';
60
+ }
61
+ function clip(text) {
62
+ const trimmed = text.replace(/\s+$/g, '');
63
+ return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}…` : trimmed;
64
+ }
65
+ /** The list endpoint is newest-first. Dated lists are ordered by time; undated lists stay as given. */
66
+ export function historyRows(messages) {
67
+ const rows = [];
68
+ for (const message of inTimeOrder(messages)) {
69
+ if (message.role === 'user') {
70
+ rows.push({ kind: 'user', text: message.content ?? '' });
71
+ continue;
72
+ }
73
+ const meta = parseMeta(message.messageMetadata);
74
+ const timeline = Array.isArray(meta.timeline) ? meta.timeline : null;
75
+ if (timeline) {
76
+ let sawText = false;
77
+ for (const block of timeline) {
78
+ if (!block || typeof block !== 'object')
79
+ continue;
80
+ const record = block;
81
+ if (record.type === 'text' && typeof record.content === 'string' && record.content.length > 0) {
82
+ sawText = true;
83
+ rows.push({ kind: 'assistant', text: record.content });
84
+ }
85
+ else if (record.type === 'reasoning' && typeof record.content === 'string' && record.content.length > 0) {
86
+ rows.push({ kind: 'reasoning', text: record.content });
87
+ }
88
+ else if (record.type === 'tools' && Array.isArray(record.actions)) {
89
+ for (const action of record.actions) {
90
+ if (action && typeof action === 'object')
91
+ rows.push({ kind: 'tool', action: action });
92
+ }
93
+ }
94
+ }
95
+ if (!sawText && message.content)
96
+ rows.push({ kind: 'assistant', text: message.content });
97
+ }
98
+ else {
99
+ if (message.content)
100
+ rows.push({ kind: 'assistant', text: message.content });
101
+ const actions = Array.isArray(meta.executedActions) ? meta.executedActions : [];
102
+ for (const action of actions) {
103
+ if (action && typeof action === 'object')
104
+ rows.push({ kind: 'tool', action: action });
105
+ }
106
+ }
107
+ appendChildren(rows, meta.orchestrationChildEvents);
108
+ }
109
+ return rows;
110
+ }
111
+ function inTimeOrder(messages) {
112
+ if (messages.length < 2)
113
+ return [...messages];
114
+ const times = messages.map((message) => message.createdAt == null ? null : String(message.createdAt));
115
+ if (times.some((time) => time == null))
116
+ return [...messages];
117
+ const first = times[0] ?? '';
118
+ const last = times[times.length - 1] ?? '';
119
+ if (first > last)
120
+ return [...messages].reverse();
121
+ return [...messages];
122
+ }
123
+ function appendChildren(rows, events) {
124
+ if (!Array.isArray(events))
125
+ return;
126
+ let children = [];
127
+ for (const event of events) {
128
+ if (event && typeof event === 'object')
129
+ children = applyChildEvent(children, event);
130
+ }
131
+ for (const line of childLines(children))
132
+ rows.push({ kind: 'tree', text: line });
133
+ }
134
+ function parseMeta(raw) {
135
+ if (!raw)
136
+ return {};
137
+ try {
138
+ const parsed = JSON.parse(raw);
139
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
140
+ return parsed;
141
+ }
142
+ catch (error) {
143
+ void error;
144
+ }
145
+ return {};
146
+ }
@@ -0,0 +1,9 @@
1
+ export interface UsageTotals {
2
+ turns?: number;
3
+ totalTokens?: number;
4
+ costUsd?: number;
5
+ }
6
+ /** One status line from `GET /api/v2/usage/summary`. */
7
+ export declare function formatUsage(data: {
8
+ totals?: UsageTotals;
9
+ } | null | undefined): string;
@@ -0,0 +1,13 @@
1
+ /** One status line from `GET /api/v2/usage/summary`. */
2
+ export function formatUsage(data) {
3
+ const totals = data?.totals ?? {};
4
+ const turns = numberOf(totals.turns);
5
+ const tokens = numberOf(totals.totalTokens);
6
+ const cost = typeof totals.costUsd === 'number' && Number.isFinite(totals.costUsd)
7
+ ? ` · $${totals.costUsd.toFixed(2)}`
8
+ : '';
9
+ return `用量 ${turns} 回合 · ${tokens} token${cost}`;
10
+ }
11
+ function numberOf(value) {
12
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
13
+ }
@@ -8,13 +8,24 @@ export declare class OpenTuiView {
8
8
  private readonly body;
9
9
  private readonly readOnly;
10
10
  private readonly overlay;
11
+ private readonly picks;
12
+ private readonly attached;
13
+ private readonly queued;
14
+ private readonly search;
15
+ private readonly banner;
16
+ private readonly composer;
11
17
  private readonly draft;
12
18
  private readonly status;
13
19
  private readonly footer;
14
20
  private bodyKey;
15
21
  private overlayKey;
22
+ private picksKey;
23
+ private readonly syntax;
16
24
  constructor(renderer: CliRenderer);
25
+ scrollPage(direction: -1 | 1): void;
26
+ scrollEdge(edge: 'top' | 'bottom'): void;
17
27
  apply(screen: TuiScreen): void;
18
28
  private paintOverlay;
29
+ private paintPicks;
19
30
  private replaceBody;
20
31
  }
package/dist/tui/view.js CHANGED
@@ -1,5 +1,9 @@
1
- import { BoxRenderable, ScrollBoxRenderable, TextRenderable, } from '@opentui/core';
2
- import { approvalLines, chatText, footerText, helpLines, readOnlyText, toolText, } from './screen.js';
1
+ import { BoxRenderable, MarkdownRenderable, ScrollBoxRenderable, SyntaxStyle, TextRenderable, dim as dimText, reverse, t, } from '@opentui/core';
2
+ import { splitAtCursor } from './editor.js';
3
+ import { childLines } from './children.js';
4
+ import { approvalLines, attachmentText, bannerLines, chatText, composerRows, draftHint, footerText, helpLines, pickText, readOnlyText, reasoningBlock, toolBlock, } from './screen.js';
5
+ import { pageScrollLines } from './scroll.js';
6
+ import { plainMarkdown } from './transcript.js';
3
7
  const canvas = '#16161e';
4
8
  const bar = '#24283b';
5
9
  const ink = '#c0caf5';
@@ -18,13 +22,28 @@ export class OpenTuiView {
18
22
  body;
19
23
  readOnly;
20
24
  overlay;
25
+ picks;
26
+ attached;
27
+ queued;
28
+ search;
29
+ banner;
30
+ composer;
21
31
  draft;
22
32
  status;
23
33
  footer;
24
34
  bodyKey = '';
25
35
  overlayKey = '';
36
+ picksKey = '';
37
+ syntax;
26
38
  constructor(renderer) {
27
39
  this.renderer = renderer;
40
+ try {
41
+ this.syntax = SyntaxStyle.create();
42
+ }
43
+ catch (error) {
44
+ void error;
45
+ this.syntax = null;
46
+ }
28
47
  const shell = new BoxRenderable(renderer, {
29
48
  width: '100%',
30
49
  height: '100%',
@@ -66,15 +85,38 @@ export class OpenTuiView {
66
85
  paddingRight: 1,
67
86
  visible: false,
68
87
  });
69
- const input = new BoxRenderable(renderer, {
88
+ this.picks = new BoxRenderable(renderer, {
89
+ width: '100%',
90
+ border: true,
91
+ borderStyle: 'rounded',
92
+ borderColor: user,
93
+ backgroundColor: '#1a1b2e',
94
+ paddingLeft: 1,
95
+ paddingRight: 1,
96
+ visible: false,
97
+ });
98
+ this.attached = text(renderer, warn);
99
+ this.queued = text(renderer, warn);
100
+ this.queued.visible = false;
101
+ this.search = text(renderer, warn);
102
+ this.search.visible = false;
103
+ this.banner = text(renderer, warn);
104
+ this.banner.visible = false;
105
+ this.composer = new BoxRenderable(renderer, {
70
106
  width: '100%',
71
107
  height: 1,
108
+ flexShrink: 0,
72
109
  backgroundColor: bar,
73
110
  paddingLeft: 1,
74
111
  paddingRight: 1,
75
112
  });
76
- this.draft = text(renderer, prompt);
77
- input.add(this.draft);
113
+ this.draft = new TextRenderable(renderer, {
114
+ content: '',
115
+ fg: prompt,
116
+ width: '100%',
117
+ wrapMode: 'word',
118
+ });
119
+ this.composer.add(this.draft);
78
120
  this.status = text(renderer, warn);
79
121
  this.footer = text(renderer, dim);
80
122
  const footerBar = new BoxRenderable(renderer, {
@@ -89,20 +131,56 @@ export class OpenTuiView {
89
131
  shell.add(this.body);
90
132
  shell.add(this.readOnly);
91
133
  shell.add(this.overlay);
92
- shell.add(input);
134
+ shell.add(this.picks);
135
+ shell.add(this.attached);
136
+ shell.add(this.banner);
137
+ shell.add(this.queued);
138
+ shell.add(this.search);
139
+ shell.add(this.composer);
93
140
  shell.add(this.status);
94
141
  shell.add(footerBar);
95
142
  renderer.root.add(shell);
96
143
  }
144
+ scrollPage(direction) {
145
+ const height = this.body.viewport.height;
146
+ this.body.scrollBy(pageScrollLines(typeof height === 'number' ? height : 0, direction));
147
+ }
148
+ scrollEdge(edge) {
149
+ if (edge === 'top')
150
+ this.body.scrollTo(0);
151
+ else
152
+ this.body.scrollTo(this.body.scrollHeight);
153
+ }
97
154
  apply(screen) {
98
155
  this.heading.content = `${screen.product} ${screen.title}`;
99
156
  this.model.content = screen.modelName;
100
157
  this.readOnly.visible = screen.readOnly;
101
- this.draft.content = `> ${screen.draft}`;
158
+ this.draft.content = draftContent(screen.draft, screen.cursor);
159
+ this.composer.height = composerRows(screen.draft, Math.max(1, this.renderer.width - 2));
102
160
  this.status.content = screen.status;
103
161
  this.status.visible = screen.status.length > 0;
104
162
  this.footer.content = footerText();
163
+ const attached = attachmentText(screen.attachments ?? []);
164
+ this.attached.content = attached;
165
+ this.attached.visible = attached.length > 0;
166
+ const queued = screen.queued ?? [];
167
+ this.queued.content = queued.map((item) => `排队 ${item}`).join('\n');
168
+ this.queued.visible = queued.length > 0;
169
+ this.queued.height = queued.length;
170
+ const search = screen.search ?? '';
171
+ this.search.content = search;
172
+ this.search.visible = search.length > 0;
173
+ this.search.height = search.length > 0 ? 1 : 0;
174
+ const banner = bannerLines(screen);
175
+ this.banner.content = banner.join('\n');
176
+ this.banner.visible = banner.length > 0;
177
+ this.banner.height = banner.length;
178
+ if (screen.scroll)
179
+ this.scrollEdge(screen.scroll);
180
+ if (typeof screen.searchFocus === 'number')
181
+ this.body.scrollChildIntoView(`line-${screen.searchFocus}`);
105
182
  this.paintOverlay(screen);
183
+ this.paintPicks(screen);
106
184
  const key = bodyKey(screen);
107
185
  if (key === this.bodyKey)
108
186
  return;
@@ -135,6 +213,28 @@ export class OpenTuiView {
135
213
  wrapMode: 'none',
136
214
  }));
137
215
  }
216
+ paintPicks(screen) {
217
+ const lines = (screen.picks ?? []).map(pickText);
218
+ const key = lines.join('\n');
219
+ if (key === this.picksKey)
220
+ return;
221
+ this.picksKey = key;
222
+ for (const child of this.picks.getChildren()) {
223
+ this.picks.remove(child);
224
+ child.destroyRecursively();
225
+ }
226
+ this.picks.visible = lines.length > 0;
227
+ this.picks.height = lines.length === 0 ? 0 : lines.length + 2;
228
+ if (lines.length === 0)
229
+ return;
230
+ this.picks.add(new TextRenderable(this.renderer, {
231
+ content: lines.join('\n'),
232
+ fg: ink,
233
+ width: '100%',
234
+ height: lines.length,
235
+ wrapMode: 'none',
236
+ }));
237
+ }
138
238
  replaceBody(screen) {
139
239
  for (const child of this.body.getChildren()) {
140
240
  this.body.remove(child);
@@ -160,20 +260,48 @@ export class OpenTuiView {
160
260
  }
161
261
  return;
162
262
  }
163
- for (const line of screen.lines)
164
- this.body.add(transcriptRow(renderer, line));
263
+ screen.lines.forEach((line, index) => {
264
+ this.body.add(transcriptRow(renderer, line, this.syntax, `line-${index}`));
265
+ });
266
+ for (const line of childLines(screen.children ?? []))
267
+ this.body.add(text(renderer, tool, line));
165
268
  }
166
269
  }
270
+ function draftContent(draft, cursor) {
271
+ const { before, head, after } = splitAtCursor(draft, cursor);
272
+ const mark = reverse('▍');
273
+ if (draft.length === 0)
274
+ return t `> ${mark} ${dimText(draftHint())}`;
275
+ if (head.length === 0)
276
+ return t `> ${before}${mark}`;
277
+ return t `> ${before}${mark}${head}${after}`;
278
+ }
167
279
  function bodyKey(screen) {
168
280
  return JSON.stringify({
169
281
  help: screen.help,
170
282
  chats: screen.chats,
171
283
  lines: screen.lines,
284
+ children: screen.children ?? [],
172
285
  });
173
286
  }
174
- function transcriptRow(renderer, line) {
287
+ function transcriptRow(renderer, line, syntax, id) {
288
+ if (line.kind === 'reasoning') {
289
+ const card = new BoxRenderable(renderer, {
290
+ id,
291
+ width: '100%',
292
+ border: true,
293
+ borderStyle: 'rounded',
294
+ borderColor: warn,
295
+ paddingLeft: 1,
296
+ paddingRight: 1,
297
+ });
298
+ for (const row of reasoningBlock(line))
299
+ card.add(text(renderer, row.startsWith(' ') ? dim : warn, row));
300
+ return card;
301
+ }
175
302
  if (line.kind === 'tool') {
176
303
  const card = new BoxRenderable(renderer, {
304
+ id,
177
305
  width: '100%',
178
306
  border: true,
179
307
  borderStyle: 'rounded',
@@ -181,14 +309,27 @@ function transcriptRow(renderer, line) {
181
309
  paddingLeft: 1,
182
310
  paddingRight: 1,
183
311
  });
184
- card.add(text(renderer, tool, toolText(line)));
312
+ for (const row of toolBlock(line))
313
+ card.add(text(renderer, row.startsWith(' ') ? dim : tool, row));
185
314
  return card;
186
315
  }
187
316
  if (line.kind === 'user') {
188
- return text(renderer, user, `user ${line.text ?? ''}`);
317
+ return text(renderer, user, `user ${line.text ?? ''}`, id);
318
+ }
319
+ if (line.kind === 'assistant' && syntax) {
320
+ return new MarkdownRenderable(renderer, {
321
+ id,
322
+ content: line.text && line.text.length > 0 ? line.text : ' ',
323
+ syntaxStyle: syntax,
324
+ fg: ink,
325
+ conceal: true,
326
+ streaming: line.streaming === true,
327
+ width: '100%',
328
+ });
189
329
  }
190
- return text(renderer, ink, line.text && line.text.length > 0 ? line.text : ' ');
330
+ const plain = plainMarkdown(line.text ?? '');
331
+ return text(renderer, ink, plain.length > 0 ? plain : ' ', id);
191
332
  }
192
- function text(renderer, fg, content = '') {
193
- return new TextRenderable(renderer, { content, fg, wrapMode: 'word' });
333
+ function text(renderer, fg, content = '', id) {
334
+ return new TextRenderable(renderer, { ...(id ? { id } : {}), content, fg, wrapMode: 'word' });
194
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-cli",
3
- "version": "0.6.59",
3
+ "version": "0.6.61",
4
4
  "description": "Product-neutral command line for a Steerable host. Products supply the bin name and brand.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -38,9 +38,9 @@
38
38
  "dependencies": {
39
39
  "@earendil-works/pi-tui": "0.87.1",
40
40
  "@opentui/core": "0.5.12",
41
- "@steerable/agent-client": "0.6.59",
42
- "@steerable/agent-protocol": "0.6.59",
43
- "@steerable/agent-shell": "0.6.59"
41
+ "@steerable/agent-client": "0.6.61",
42
+ "@steerable/agent-protocol": "0.6.61",
43
+ "@steerable/agent-shell": "0.6.61"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.10.5",