@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,21 @@
1
+ import type { DraftBuffer } from './editor.js';
2
+ export interface PromptHistory {
3
+ items: string[];
4
+ index: number;
5
+ saved: {
6
+ text: string;
7
+ cursor: number;
8
+ } | null;
9
+ }
10
+ export declare function createPromptHistory(): PromptHistory;
11
+ /** Remember a sent line. Newest is first. A repeat of the latest line is kept once. */
12
+ export declare function rememberPrompt(history: PromptHistory, text: string): void;
13
+ /** Typing leaves history browsing and keeps the text now in the composer. */
14
+ export declare function leavePromptHistory(history: PromptHistory): void;
15
+ /**
16
+ * Step through sent lines.
17
+ * `direction` -1 is older, 1 is newer. Past the newest line restores the draft that was there before browsing.
18
+ */
19
+ export declare function recallPrompt(history: PromptHistory, draft: DraftBuffer, direction: -1 | 1): boolean;
20
+ export declare function onFirstLine(text: string, cursor: number): boolean;
21
+ export declare function onLastLine(text: string, cursor: number): boolean;
@@ -0,0 +1,57 @@
1
+ const LIMIT = 100;
2
+ export function createPromptHistory() {
3
+ return { items: [], index: -1, saved: null };
4
+ }
5
+ /** Remember a sent line. Newest is first. A repeat of the latest line is kept once. */
6
+ export function rememberPrompt(history, text) {
7
+ const trimmed = text.trim();
8
+ history.index = -1;
9
+ history.saved = null;
10
+ if (!trimmed || history.items[0] === trimmed)
11
+ return;
12
+ history.items.unshift(trimmed);
13
+ if (history.items.length > LIMIT)
14
+ history.items.pop();
15
+ }
16
+ /** Typing leaves history browsing and keeps the text now in the composer. */
17
+ export function leavePromptHistory(history) {
18
+ if (history.index < 0)
19
+ return;
20
+ history.index = -1;
21
+ history.saved = null;
22
+ }
23
+ /**
24
+ * Step through sent lines.
25
+ * `direction` -1 is older, 1 is newer. Past the newest line restores the draft that was there before browsing.
26
+ */
27
+ export function recallPrompt(history, draft, direction) {
28
+ if (history.items.length === 0)
29
+ return false;
30
+ const next = history.index - direction;
31
+ if (next < -1 || next >= history.items.length)
32
+ return false;
33
+ if (history.index < 0)
34
+ history.saved = { text: draft.text, cursor: draft.cursor };
35
+ history.index = next;
36
+ if (next < 0) {
37
+ const saved = history.saved;
38
+ history.saved = null;
39
+ draft.text = saved?.text ?? '';
40
+ draft.cursor = saved?.cursor ?? 0;
41
+ return true;
42
+ }
43
+ draft.text = history.items[next] ?? '';
44
+ draft.cursor = draft.text.length;
45
+ return true;
46
+ }
47
+ export function onFirstLine(text, cursor) {
48
+ const at = clamp(cursor, text.length);
49
+ return !text.slice(0, at).includes('\n');
50
+ }
51
+ export function onLastLine(text, cursor) {
52
+ const at = clamp(cursor, text.length);
53
+ return !text.slice(at).includes('\n');
54
+ }
55
+ function clamp(cursor, length) {
56
+ return Math.max(0, Math.min(cursor, length));
57
+ }
package/dist/tui/run.d.ts CHANGED
@@ -6,3 +6,5 @@ export declare function runTui(options: {
6
6
  dataDir?: string;
7
7
  terminal?: Terminal;
8
8
  }): Promise<number>;
9
+ /** Terminal reports (capability replies, focus) must reach the renderer so Shift+Enter stays distinct from Enter. */
10
+ export declare function yieldsToRenderer(sequence: string): boolean;
package/dist/tui/run.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ProcessTerminal, TuiAltScreen } from '@earendil-works/pi-tui';
2
2
  import { installAgentKeybindings } from './keys.js';
3
+ import { transcriptPage } from './scroll.js';
3
4
  import { AgentTui } from './session.js';
4
5
  export async function runTui(options) {
5
6
  installAgentKeybindings();
@@ -40,6 +41,13 @@ async function runOpenTui(options) {
40
41
  });
41
42
  const view = new OpenTuiView(renderer);
42
43
  renderer.prependInputHandler((sequence) => {
44
+ if (yieldsToRenderer(sequence))
45
+ return false;
46
+ const page = transcriptPage(sequence);
47
+ if (page !== 0) {
48
+ view.scrollPage(page);
49
+ return true;
50
+ }
43
51
  session.handleInput(sequence);
44
52
  return true;
45
53
  });
@@ -67,6 +75,16 @@ function runPlainTui(options) {
67
75
  tui.start();
68
76
  });
69
77
  }
78
+ /** Terminal reports (capability replies, focus) must reach the renderer so Shift+Enter stays distinct from Enter. */
79
+ export function yieldsToRenderer(sequence) {
80
+ if (sequence === '\x1b[I' || sequence === '\x1b[O')
81
+ return true;
82
+ if (sequence.startsWith('\x1b]') || sequence.startsWith('\x1bP') || sequence.startsWith('\x1b_'))
83
+ return true;
84
+ if (sequence.startsWith('\x1b[?'))
85
+ return true;
86
+ return /^\x1b\[\d+(;\d+)?R$/.test(sequence);
87
+ }
70
88
  function openTuiUnavailable(error) {
71
89
  const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : '';
72
90
  const message = `${error instanceof Error ? error.message : String(error)} ${cause}`;
@@ -1,9 +1,15 @@
1
+ import { type ChildRow } from './children.js';
1
2
  export interface TranscriptLine {
2
- kind: 'user' | 'assistant' | 'tool';
3
+ kind: 'user' | 'assistant' | 'tool' | 'tree' | 'reasoning';
3
4
  text?: string;
4
5
  name?: string;
5
6
  args?: string;
6
7
  status?: string;
8
+ id?: string;
9
+ output?: string;
10
+ diff?: string;
11
+ open?: boolean;
12
+ streaming?: boolean;
7
13
  }
8
14
  export interface ChatRow {
9
15
  id: string;
@@ -11,6 +17,10 @@ export interface ChatRow {
11
17
  busy: boolean;
12
18
  selected: boolean;
13
19
  }
20
+ export interface PickRow {
21
+ label: string;
22
+ selected: boolean;
23
+ }
14
24
  export interface TuiScreen {
15
25
  product: string;
16
26
  title: string;
@@ -27,16 +37,39 @@ export interface TuiScreen {
27
37
  readOnly: boolean;
28
38
  help: boolean;
29
39
  draft: string;
40
+ cursor: number;
30
41
  status: string;
42
+ picks?: PickRow[] | null;
43
+ attachments?: string[];
44
+ children?: ChildRow[];
45
+ queued?: string[];
46
+ search?: string | null;
47
+ searchFocus?: number | null;
48
+ scroll?: 'top' | 'bottom' | null;
49
+ panel?: string[] | null;
50
+ mode?: 'agent' | 'plan';
51
+ permission?: 'ask' | 'auto' | 'read';
52
+ todos?: Array<{
53
+ content: string;
54
+ status: 'pending' | 'in_progress' | 'completed';
55
+ }>;
31
56
  }
57
+ export declare function draftHint(): string;
32
58
  export declare function renderScreen(screen: TuiScreen, width: number): string[];
33
59
  export declare function toolText(line: TranscriptLine): string;
60
+ export declare function toolBlock(line: TranscriptLine): string[];
61
+ export declare function reasoningBlock(line: TranscriptLine): string[];
34
62
  export declare function chatText(chat: ChatRow): string;
35
63
  export declare function helpLines(): string[];
64
+ export declare function pickText(pick: PickRow): string;
65
+ export declare function attachmentText(names: readonly string[]): string;
36
66
  export declare function approvalLines(approval: {
37
67
  toolName: string;
38
68
  summary: string;
39
69
  }): string[];
40
70
  export declare function footerText(): string;
71
+ export declare function draftLine(draft: string, cursor: number): string;
72
+ export declare function composerRows(draft: string, columns: number): number;
41
73
  export declare const readOnlyText = "\u53EA\u8BFB \u00B7 \u53E6\u4E00\u4E2A\u8FDB\u7A0B\u6B63\u5728\u8FD0\u884C";
42
74
  export declare function visibleText(lines: string[]): string;
75
+ export declare function bannerLines(screen: TuiScreen): string[];
@@ -1,5 +1,12 @@
1
- import { Box, Text } from '@earendil-works/pi-tui';
1
+ import { Box, CURSOR_MARKER, Text } from '@earendil-works/pi-tui';
2
+ import { childLines } from './children.js';
3
+ import { splitAtCursor } from './editor.js';
2
4
  import { keyLabel } from './keys.js';
5
+ import { plainMarkdown } from './transcript.js';
6
+ export function draftHint() {
7
+ return `输入消息,${keyLabel('tui.input.submit')} 发送,${keyLabel('tui.input.newLine')} 换行`;
8
+ }
9
+ const caret = '▍';
3
10
  export function renderScreen(screen, width) {
4
11
  const box = new Box(0, 0);
5
12
  for (const line of screenLines(screen))
@@ -9,13 +16,62 @@ export function renderScreen(screen, width) {
9
16
  export function toolText(line) {
10
17
  return `▸ ${line.name} ${line.args} ${line.status ?? ''}`.trimEnd();
11
18
  }
19
+ export function toolBlock(line) {
20
+ const head = toolText(line);
21
+ if (!line.open)
22
+ return [head];
23
+ const rows = [];
24
+ if (line.diff)
25
+ rows.push(...line.diff.split('\n').slice(0, 12).map((row) => ` ${row}`));
26
+ if (line.output)
27
+ rows.push(...line.output.split('\n').slice(0, 8).map((row) => ` ${row}`));
28
+ return rows.length > 0 ? [head, ...rows] : [head];
29
+ }
30
+ export function reasoningBlock(line) {
31
+ const head = line.streaming ? '思考 …' : '思考';
32
+ if (!line.open || !line.text)
33
+ return [head];
34
+ return [head, ...line.text.split('\n').slice(0, 8).map((row) => ` ${row}`)];
35
+ }
12
36
  export function chatText(chat) {
13
37
  const mark = chat.selected ? '*' : ' ';
14
38
  const busy = chat.busy ? ' 只读' : '';
15
39
  return `${mark} ${chat.id} ${chat.title}${busy}`;
16
40
  }
17
41
  export function helpLines() {
18
- return ['/new 新会话', '/model 查看或切换模型', '/clear 清屏', '/help 帮助'];
42
+ return [
43
+ '/new 新会话',
44
+ '/model 查看或切换模型',
45
+ '/attach 附加文件',
46
+ '@ 补全文件',
47
+ '/clear 清屏',
48
+ '/help 帮助',
49
+ `${keyLabel('agent.tool.toggle')} 展开工具或思考`,
50
+ `${keyLabel('agent.clipboard.paste')} 粘贴图片`,
51
+ `${keyLabel('tui.altScreen.search')} 搜索记录`,
52
+ `${keyLabel('tui.altScreen.top')} 记录顶 ${keyLabel('tui.altScreen.bottom')} 记录底`,
53
+ `${keyLabel('agent.queue.pull')} 取回排队`,
54
+ `${keyLabel('agent.copy.reply')} 复制回答`,
55
+ `${keyLabel('agent.editor')} 外部编辑器`,
56
+ '/status 用量',
57
+ '/skills 技能',
58
+ '/mcp MCP',
59
+ '/export 导出会话',
60
+ '/compact 压缩上下文',
61
+ '/plan 计划模式',
62
+ '/tasks 后台任务',
63
+ '/fork 分叉',
64
+ '/rewind 回退',
65
+ '/permissions 会话权限',
66
+ '/copy 复制回答',
67
+ '/editor 外部编辑器',
68
+ ];
69
+ }
70
+ export function pickText(pick) {
71
+ return `${pick.selected ? '*' : ' '} ${pick.label}`;
72
+ }
73
+ export function attachmentText(names) {
74
+ return names.length > 0 ? `附件 ${names.join(' ')}` : '';
19
75
  }
20
76
  export function approvalLines(approval) {
21
77
  return [
@@ -25,7 +81,22 @@ export function approvalLines(approval) {
25
81
  ];
26
82
  }
27
83
  export function footerText() {
28
- return `${keyLabel('agent.interrupt')} 中断 · ${keyLabel('agent.chats')} 会话 · /help`;
84
+ return `${keyLabel('agent.interrupt')} 中断 · ${keyLabel('agent.chats')} 会话 · ${keyLabel('tui.input.submit')} 发送 · /help`;
85
+ }
86
+ export function draftLine(draft, cursor) {
87
+ const { before, head, after } = splitAtCursor(draft, cursor);
88
+ if (draft.length === 0)
89
+ return `> ${CURSOR_MARKER}${caret} ${draftHint()}`;
90
+ return `> ${before}${CURSOR_MARKER}${caret}${head}${after}`;
91
+ }
92
+ export function composerRows(draft, columns) {
93
+ const width = Math.max(1, columns);
94
+ const shown = draft.length > 0 ? draft : `▍ ${draftHint()}`;
95
+ let rows = 0;
96
+ for (const line of shown.split('\n')) {
97
+ rows += Math.max(1, Math.ceil(displayWidth(`> ${line}`) / width));
98
+ }
99
+ return Math.min(8, rows);
29
100
  }
30
101
  export const readOnlyText = '只读 · 另一个进程正在运行';
31
102
  export function visibleText(lines) {
@@ -44,25 +115,70 @@ function screenLines(screen) {
44
115
  else {
45
116
  for (const line of screen.lines) {
46
117
  if (line.kind === 'tool')
47
- lines.push(toolText(line));
118
+ lines.push(...toolBlock(line));
119
+ else if (line.kind === 'reasoning')
120
+ lines.push(...reasoningBlock(line));
48
121
  else if (line.kind === 'user')
49
122
  lines.push(`user ${line.text ?? ''}`);
50
123
  else
51
- lines.push(line.text ?? '');
124
+ lines.push(...plainMarkdown(line.text ?? '').split('\n'));
52
125
  }
53
126
  }
127
+ if (!screen.help && !screen.chats)
128
+ lines.push(...childLines(screen.children ?? []));
129
+ lines.push(...bannerLines(screen));
54
130
  if (screen.readOnly)
55
131
  lines.push(readOnlyText);
56
132
  if (screen.approval)
57
133
  lines.push(...approvalLines(screen.approval));
58
134
  if (screen.ask)
59
135
  lines.push(`追问 ${screen.ask.prompt}`);
60
- lines.push(`> ${screen.draft}`);
136
+ for (const pick of screen.picks ?? [])
137
+ lines.push(pickText(pick));
138
+ const attached = attachmentText(screen.attachments ?? []);
139
+ if (attached)
140
+ lines.push(attached);
141
+ for (const item of screen.queued ?? [])
142
+ lines.push(`排队 ${item}`);
143
+ if (screen.search)
144
+ lines.push(screen.search);
145
+ lines.push(draftLine(screen.draft, screen.cursor));
61
146
  if (screen.status)
62
147
  lines.push(screen.status);
63
148
  lines.push(footerText());
64
149
  return lines;
65
150
  }
151
+ export function bannerLines(screen) {
152
+ const lines = [];
153
+ if (!screen.help && !screen.chats) {
154
+ for (const todo of screen.todos ?? [])
155
+ lines.push(todoText(todo));
156
+ }
157
+ if (screen.mode === 'plan')
158
+ lines.push('模式 计划');
159
+ if (screen.permission === 'auto')
160
+ lines.push('权限 本会话自动');
161
+ if (screen.permission === 'read')
162
+ lines.push('权限 只读');
163
+ for (const row of screen.panel ?? [])
164
+ lines.push(row);
165
+ return lines;
166
+ }
167
+ function todoText(todo) {
168
+ if (todo.status === 'completed')
169
+ return `✓ ${todo.content}`;
170
+ if (todo.status === 'in_progress')
171
+ return `▸ ${todo.content}`;
172
+ return `○ ${todo.content}`;
173
+ }
174
+ function displayWidth(text) {
175
+ let width = 0;
176
+ for (const char of text) {
177
+ const code = char.codePointAt(0) ?? 0;
178
+ width += code > 0xff ? 2 : 1;
179
+ }
180
+ return width;
181
+ }
66
182
  class PlainLine {
67
183
  text;
68
184
  constructor(text) {
@@ -0,0 +1,6 @@
1
+ /** Page Up / Page Down scroll the transcript. `0` means the key belongs to the composer. */
2
+ export declare function transcriptPage(sequence: string): -1 | 1 | 0;
3
+ /** Home / End jump the transcript. `null` means the key belongs to the composer. */
4
+ export declare function transcriptEdge(sequence: string): 'top' | 'bottom' | null;
5
+ /** One page, keeping a few lines of overlap so the reader can see where they were. */
6
+ export declare function pageScrollLines(viewportHeight: number, direction: -1 | 1): number;
@@ -0,0 +1,28 @@
1
+ import { getKeybindings } from '@earendil-works/pi-tui';
2
+ import { ensureAgentKeybindings } from './keys.js';
3
+ const PAGE_OVERLAP = 4;
4
+ /** Page Up / Page Down scroll the transcript. `0` means the key belongs to the composer. */
5
+ export function transcriptPage(sequence) {
6
+ ensureAgentKeybindings();
7
+ const keys = getKeybindings();
8
+ if (keys.matches(sequence, 'tui.altScreen.pageUp'))
9
+ return -1;
10
+ if (keys.matches(sequence, 'tui.altScreen.pageDown'))
11
+ return 1;
12
+ return 0;
13
+ }
14
+ /** Home / End jump the transcript. `null` means the key belongs to the composer. */
15
+ export function transcriptEdge(sequence) {
16
+ ensureAgentKeybindings();
17
+ const keys = getKeybindings();
18
+ if (keys.matches(sequence, 'tui.altScreen.top'))
19
+ return 'top';
20
+ if (keys.matches(sequence, 'tui.altScreen.bottom'))
21
+ return 'bottom';
22
+ return null;
23
+ }
24
+ /** One page, keeping a few lines of overlap so the reader can see where they were. */
25
+ export function pageScrollLines(viewportHeight, direction) {
26
+ const rows = Number.isFinite(viewportHeight) ? viewportHeight : 0;
27
+ return direction * Math.max(1, Math.floor(rows) - PAGE_OVERLAP);
28
+ }
@@ -0,0 +1,8 @@
1
+ import type { TranscriptLine } from './screen.js';
2
+ export interface SearchHit {
3
+ line: number;
4
+ snippet: string;
5
+ }
6
+ /** Case-insensitive hits over user, assistant, tool, reasoning, and tree text. */
7
+ export declare function searchHits(lines: readonly TranscriptLine[], query: string): SearchHit[];
8
+ export declare function searchLabel(query: string, hits: readonly SearchHit[], index: number): string;
@@ -0,0 +1,38 @@
1
+ /** Case-insensitive hits over user, assistant, tool, reasoning, and tree text. */
2
+ export function searchHits(lines, query) {
3
+ const needle = query.trim().toLowerCase();
4
+ if (!needle)
5
+ return [];
6
+ const hits = [];
7
+ lines.forEach((line, index) => {
8
+ const text = lineText(line);
9
+ const at = text.toLowerCase().indexOf(needle);
10
+ if (at < 0)
11
+ return;
12
+ hits.push({ line: index, snippet: snippetAt(text, at, needle.length) });
13
+ });
14
+ return hits;
15
+ }
16
+ export function searchLabel(query, hits, index) {
17
+ const shown = query.trim();
18
+ if (!shown)
19
+ return '搜索';
20
+ if (hits.length === 0)
21
+ return `搜索 ${shown} 无匹配`;
22
+ const hit = hits[Math.min(index, hits.length - 1)] ?? hits[0];
23
+ return `搜索 ${shown} ${Math.min(index, hits.length - 1) + 1}/${hits.length} ${hit?.snippet ?? ''}`;
24
+ }
25
+ function lineText(line) {
26
+ if (line.kind === 'tool') {
27
+ return [line.name, line.args, line.output, line.diff].filter((part) => part && part.length > 0).join(' ');
28
+ }
29
+ return line.text ?? '';
30
+ }
31
+ function snippetAt(text, at, length) {
32
+ const start = Math.max(0, at - 8);
33
+ const end = Math.min(text.length, at + length + 16);
34
+ const slice = text.slice(start, end).replace(/\s+/g, ' ').trim();
35
+ const prefix = start > 0 ? '…' : '';
36
+ const suffix = end < text.length ? '…' : '';
37
+ return `${prefix}${slice}${suffix}`;
38
+ }
@@ -1,10 +1,22 @@
1
1
  import { type Component } from '@earendil-works/pi-tui';
2
2
  import { type AgentClient } from '@steerable/agent-client';
3
+ import { type ClipboardRead } from './clipboard.js';
4
+ import { type SavedFile } from './files.js';
3
5
  import { type TuiScreen } from './screen.js';
4
6
  export interface AgentTuiOptions {
5
7
  product: string;
6
8
  dataDir?: string;
7
9
  busyChatIds?: string[];
10
+ cwd?: string;
11
+ saveAttachments?: (chatId: string, files: Array<{
12
+ name: string;
13
+ path: string;
14
+ }>) => Promise<SavedFile[]>;
15
+ readClipboard?: () => Promise<ClipboardRead>;
16
+ writeClipboard?: (text: string) => Promise<void>;
17
+ editInEditor?: (text: string) => Promise<string>;
18
+ exportDir?: string;
19
+ liveIntervalMs?: number;
8
20
  onExit: () => void;
9
21
  onChange?: () => void;
10
22
  }
@@ -22,7 +34,24 @@ export declare class AgentTui implements Component {
22
34
  private ask;
23
35
  private readOnly;
24
36
  private help;
25
- private draft;
37
+ private buffer;
38
+ private prompts;
39
+ private paste;
40
+ private picks;
41
+ private pickIndex;
42
+ private pickGeneration;
43
+ private attachments;
44
+ private queue;
45
+ private sending;
46
+ private search;
47
+ private scroll;
48
+ private panel;
49
+ private mode;
50
+ private permission;
51
+ private todos;
52
+ private liveGeneration;
53
+ private children;
54
+ private readonly cwd;
26
55
  private status;
27
56
  private stopped;
28
57
  private turnAbort;
@@ -34,16 +63,45 @@ export declare class AgentTui implements Component {
34
63
  render(width: number): string[];
35
64
  snapshot(): TuiScreen;
36
65
  handleInput(data: string): void;
66
+ private applyInput;
67
+ private pasteClipboard;
68
+ private browseHistory;
69
+ private composing;
70
+ private consumePaste;
37
71
  private screen;
72
+ private searchBar;
73
+ private editSearch;
74
+ private pullQueue;
75
+ private pickRows;
76
+ private markPicks;
77
+ private refreshPicks;
78
+ private acceptPick;
38
79
  private showChats;
39
80
  private loadChats;
40
81
  private markSelection;
41
82
  private openChat;
42
83
  private submit;
84
+ private sendPrepared;
85
+ private prepareOwned;
43
86
  private slash;
87
+ private attach;
88
+ private prepareMessage;
89
+ private ensureChat;
44
90
  private runTurn;
45
91
  private observe;
46
92
  private watch;
93
+ private applyActions;
94
+ private toggleFold;
95
+ private armLive;
96
+ private pollLive;
97
+ private showList;
98
+ private showTasks;
99
+ private exportChat;
100
+ private compactChat;
101
+ private forkChat;
102
+ private rewindChat;
103
+ private copyReply;
104
+ private editDraftExternal;
47
105
  private decide;
48
106
  private touch;
49
107
  private exit;