@steerable/agent-cli 0.6.60 → 0.6.62

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,17 @@
1
+ export interface ChildRow {
2
+ id: string;
3
+ task: string;
4
+ profile: string;
5
+ status: 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted';
6
+ depth: number;
7
+ }
8
+ export interface ChildEvent {
9
+ kind?: unknown;
10
+ childId?: unknown;
11
+ task?: unknown;
12
+ profile?: unknown;
13
+ depth?: unknown;
14
+ }
15
+ /** Fold one `orchestration_child` lifecycle event into the live sub-agent list. */
16
+ export declare function applyChildEvent(children: readonly ChildRow[], event: ChildEvent): ChildRow[];
17
+ export declare function childLines(children: readonly ChildRow[]): string[];
@@ -0,0 +1,74 @@
1
+ /** Fold one `orchestration_child` lifecycle event into the live sub-agent list. */
2
+ export function applyChildEvent(children, event) {
3
+ const kind = typeof event.kind === 'string' ? event.kind : '';
4
+ const id = typeof event.childId === 'string' ? event.childId : '';
5
+ if (!kind || !id)
6
+ return [...children];
7
+ const next = children.map((child) => ({ ...child }));
8
+ const index = next.findIndex((child) => child.id === id);
9
+ if (kind === 'child_spawned') {
10
+ if (index >= 0)
11
+ return next;
12
+ next.push({
13
+ id,
14
+ task: typeof event.task === 'string' ? event.task : '',
15
+ profile: typeof event.profile === 'string' ? event.profile : '',
16
+ depth: typeof event.depth === 'number' ? Math.max(0, event.depth) : 0,
17
+ status: 'running',
18
+ });
19
+ return next;
20
+ }
21
+ const current = next[index];
22
+ if (!current)
23
+ return next;
24
+ next[index] = { ...current, status: childStatus(kind, current.status) };
25
+ return next;
26
+ }
27
+ export function childLines(children) {
28
+ if (children.length === 0)
29
+ return [];
30
+ const done = children.filter((child) => child.status === 'completed').length;
31
+ const lines = [`子任务 ${done}/${children.length}`];
32
+ for (const child of children) {
33
+ const name = child.profile || '子任务';
34
+ const task = child.task ? ` ${child.task}` : '';
35
+ const indent = ' '.repeat(Math.min(child.depth, 6));
36
+ lines.push(`${indent}${statusMark(child.status)} ${name}${task} ${statusLabel(child.status)}`);
37
+ }
38
+ return lines;
39
+ }
40
+ function childStatus(kind, current) {
41
+ if (kind === 'child_completed')
42
+ return 'completed';
43
+ if (kind === 'child_failed')
44
+ return 'failed';
45
+ if (kind === 'child_cancelled')
46
+ return 'cancelled';
47
+ if (kind === 'child_interrupted')
48
+ return 'interrupted';
49
+ if (kind === 'child_resumed')
50
+ return 'running';
51
+ return current;
52
+ }
53
+ function statusMark(status) {
54
+ if (status === 'completed')
55
+ return '✓';
56
+ if (status === 'failed')
57
+ return '✗';
58
+ if (status === 'cancelled')
59
+ return '·';
60
+ if (status === 'interrupted')
61
+ return '‖';
62
+ return '▸';
63
+ }
64
+ function statusLabel(status) {
65
+ if (status === 'completed')
66
+ return '完成';
67
+ if (status === 'failed')
68
+ return '失败';
69
+ if (status === 'cancelled')
70
+ return '已取消';
71
+ if (status === 'interrupted')
72
+ return '已暂停';
73
+ return '进行中';
74
+ }
@@ -0,0 +1,14 @@
1
+ export interface ClipboardRead {
2
+ image?: Uint8Array | null;
3
+ text?: string | null;
4
+ }
5
+ /** File extension for a clipboard image, or null when the bytes are not a supported picture. */
6
+ export declare function imageExtension(bytes: Uint8Array): 'png' | 'jpg' | 'gif' | 'webp' | null;
7
+ /** Write clipboard image bytes to a temp file the attachment path can send. */
8
+ export declare function saveClipboardImage(bytes: Uint8Array): Promise<{
9
+ name: string;
10
+ path: string;
11
+ } | null>;
12
+ /** Image first, then text. An empty clipboard returns an empty result. */
13
+ export declare function writeSystemClipboard(text: string): Promise<void>;
14
+ export declare function readSystemClipboard(): Promise<ClipboardRead>;
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { getNativeClipboard } from '@earendil-works/pi-tui';
6
+ /** File extension for a clipboard image, or null when the bytes are not a supported picture. */
7
+ export function imageExtension(bytes) {
8
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
9
+ return 'png';
10
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
11
+ return 'jpg';
12
+ if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46)
13
+ return 'gif';
14
+ if (bytes.length >= 12
15
+ && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46
16
+ && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
17
+ return 'webp';
18
+ return null;
19
+ }
20
+ /** Write clipboard image bytes to a temp file the attachment path can send. */
21
+ export async function saveClipboardImage(bytes) {
22
+ const ext = imageExtension(bytes);
23
+ if (!ext)
24
+ return null;
25
+ const name = `clipboard-${randomUUID().slice(0, 8)}.${ext}`;
26
+ const file = path.join(os.tmpdir(), `steerable-${name}`);
27
+ await fs.writeFile(file, bytes);
28
+ return { name, path: file };
29
+ }
30
+ /** Image first, then text. An empty clipboard returns an empty result. */
31
+ export async function writeSystemClipboard(text) {
32
+ const clipboard = getNativeClipboard();
33
+ if (!clipboard?.setText)
34
+ throw new Error('clipboard unavailable');
35
+ await clipboard.setText(text);
36
+ }
37
+ export async function readSystemClipboard() {
38
+ const clipboard = getNativeClipboard();
39
+ if (!clipboard)
40
+ return {};
41
+ try {
42
+ const image = await clipboard.getImage();
43
+ if (image && image.length > 0)
44
+ return { image };
45
+ }
46
+ catch (error) {
47
+ void error;
48
+ }
49
+ try {
50
+ const text = await clipboard.getText();
51
+ if (text)
52
+ return { text };
53
+ }
54
+ catch (error) {
55
+ void error;
56
+ }
57
+ return {};
58
+ }
@@ -0,0 +1,9 @@
1
+ import type { FilePick } from './files.js';
2
+ export interface SlashToken {
3
+ start: number;
4
+ query: string;
5
+ }
6
+ /** A `/command` at the start of the draft, touching the cursor. */
7
+ export declare function slashAt(text: string, cursor: number): SlashToken | null;
8
+ /** Commands already implemented by the composer, filtered by the text after `/`. */
9
+ export declare function completeSlash(query: string): FilePick[];
@@ -0,0 +1,41 @@
1
+ const COMMANDS = [
2
+ { name: 'new', hint: '新会话' },
3
+ { name: 'model', hint: '查看或切换模型' },
4
+ { name: 'attach', hint: '附加文件' },
5
+ { name: 'clear', hint: '清屏' },
6
+ { name: 'help', hint: '帮助' },
7
+ { name: 'status', hint: '用量' },
8
+ { name: 'skills', hint: '技能' },
9
+ { name: 'mcp', hint: 'MCP' },
10
+ { name: 'export', hint: '导出会话' },
11
+ { name: 'compact', hint: '压缩上下文' },
12
+ { name: 'plan', hint: '计划模式' },
13
+ { name: 'tasks', hint: '后台任务' },
14
+ { name: 'fork', hint: '分叉' },
15
+ { name: 'rewind', hint: '回退' },
16
+ { name: 'permissions', hint: '会话权限' },
17
+ { name: 'copy', hint: '复制回答' },
18
+ { name: 'editor', hint: '外部编辑器' },
19
+ ];
20
+ /** A `/command` at the start of the draft, touching the cursor. */
21
+ export function slashAt(text, cursor) {
22
+ const at = Math.max(0, Math.min(cursor, text.length));
23
+ const head = text.slice(0, at);
24
+ if (head.includes('\n'))
25
+ return null;
26
+ const match = /^\/([^\s]*)$/.exec(head);
27
+ if (!match)
28
+ return null;
29
+ return { start: 0, query: match[1] ?? '' };
30
+ }
31
+ /** Commands already implemented by the composer, filtered by the text after `/`. */
32
+ export function completeSlash(query) {
33
+ const needle = query.toLowerCase();
34
+ return COMMANDS
35
+ .filter((command) => command.name.startsWith(needle))
36
+ .map((command) => ({
37
+ label: `/${command.name} ${command.hint}`,
38
+ insert: `/${command.name} `,
39
+ directory: false,
40
+ }));
41
+ }
@@ -0,0 +1,18 @@
1
+ export interface DraftBuffer {
2
+ text: string;
3
+ cursor: number;
4
+ }
5
+ export declare function createDraft(): DraftBuffer;
6
+ export declare function clearDraft(draft: DraftBuffer): void;
7
+ export declare function insertText(draft: DraftBuffer, text: string): void;
8
+ /** Place the caret inside `text`. `head` is the grapheme under an insert cursor. */
9
+ export declare function splitAtCursor(text: string, cursor: number): {
10
+ before: string;
11
+ head: string;
12
+ after: string;
13
+ };
14
+ /**
15
+ * Apply one composer key.
16
+ * `submit` leaves the buffer unchanged so the caller can send it.
17
+ */
18
+ export declare function editDraft(draft: DraftBuffer, data: string): 'submit' | 'edited' | 'ignored';
@@ -0,0 +1,201 @@
1
+ import { decodeKittyPrintable, getKeybindings } from '@earendil-works/pi-tui';
2
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ export function createDraft() {
4
+ return { text: '', cursor: 0 };
5
+ }
6
+ export function clearDraft(draft) {
7
+ draft.text = '';
8
+ draft.cursor = 0;
9
+ }
10
+ export function insertText(draft, text) {
11
+ const at = clamp(draft.cursor, draft.text.length);
12
+ draft.text = draft.text.slice(0, at) + text + draft.text.slice(at);
13
+ draft.cursor = at + text.length;
14
+ }
15
+ /** Place the caret inside `text`. `head` is the grapheme under an insert cursor. */
16
+ export function splitAtCursor(text, cursor) {
17
+ const at = clamp(cursor, text.length);
18
+ const rest = text.slice(at);
19
+ const head = graphemesOf(rest)[0]?.text ?? '';
20
+ return { before: text.slice(0, at), head, after: rest.slice(head.length) };
21
+ }
22
+ /**
23
+ * Apply one composer key.
24
+ * `submit` leaves the buffer unchanged so the caller can send it.
25
+ */
26
+ export function editDraft(draft, data) {
27
+ const keys = getKeybindings();
28
+ if (keys.matches(data, 'tui.input.newLine') || isLegacyNewline(data)) {
29
+ insertText(draft, '\n');
30
+ return 'edited';
31
+ }
32
+ if (keys.matches(data, 'tui.input.submit'))
33
+ return 'submit';
34
+ if (keys.matches(data, 'tui.editor.deleteCharBackward')) {
35
+ deleteSpan(draft, previousGrapheme(draft.text, draft.cursor), draft.cursor);
36
+ return 'edited';
37
+ }
38
+ if (keys.matches(data, 'tui.editor.deleteCharForward')) {
39
+ deleteSpan(draft, draft.cursor, nextGrapheme(draft.text, draft.cursor));
40
+ return 'edited';
41
+ }
42
+ if (keys.matches(data, 'tui.editor.deleteWordBackward')) {
43
+ deleteSpan(draft, wordBoundary(draft.text, draft.cursor, -1), draft.cursor);
44
+ return 'edited';
45
+ }
46
+ if (keys.matches(data, 'tui.editor.deleteWordForward')) {
47
+ deleteSpan(draft, draft.cursor, wordBoundary(draft.text, draft.cursor, 1));
48
+ return 'edited';
49
+ }
50
+ if (keys.matches(data, 'tui.editor.deleteToLineStart')) {
51
+ deleteSpan(draft, lineStart(draft.text, draft.cursor), draft.cursor);
52
+ return 'edited';
53
+ }
54
+ if (keys.matches(data, 'tui.editor.deleteToLineEnd')) {
55
+ deleteSpan(draft, draft.cursor, lineEnd(draft.text, draft.cursor));
56
+ return 'edited';
57
+ }
58
+ if (keys.matches(data, 'tui.editor.cursorLeft')) {
59
+ draft.cursor = previousGrapheme(draft.text, draft.cursor);
60
+ return 'edited';
61
+ }
62
+ if (keys.matches(data, 'tui.editor.cursorRight')) {
63
+ draft.cursor = nextGrapheme(draft.text, draft.cursor);
64
+ return 'edited';
65
+ }
66
+ if (keys.matches(data, 'tui.editor.cursorLineStart')) {
67
+ draft.cursor = lineStart(draft.text, draft.cursor);
68
+ return 'edited';
69
+ }
70
+ if (keys.matches(data, 'tui.editor.cursorLineEnd')) {
71
+ draft.cursor = lineEnd(draft.text, draft.cursor);
72
+ return 'edited';
73
+ }
74
+ if (keys.matches(data, 'tui.editor.cursorWordLeft')) {
75
+ draft.cursor = wordBoundary(draft.text, draft.cursor, -1);
76
+ return 'edited';
77
+ }
78
+ if (keys.matches(data, 'tui.editor.cursorWordRight')) {
79
+ draft.cursor = wordBoundary(draft.text, draft.cursor, 1);
80
+ return 'edited';
81
+ }
82
+ if (keys.matches(data, 'tui.editor.cursorUp')) {
83
+ draft.cursor = moveLine(draft.text, draft.cursor, -1);
84
+ return 'edited';
85
+ }
86
+ if (keys.matches(data, 'tui.editor.cursorDown')) {
87
+ draft.cursor = moveLine(draft.text, draft.cursor, 1);
88
+ return 'edited';
89
+ }
90
+ const kitty = decodeKittyPrintable(data);
91
+ if (kitty) {
92
+ insertText(draft, kitty);
93
+ return 'edited';
94
+ }
95
+ if (isPrintable(data)) {
96
+ insertText(draft, data);
97
+ return 'edited';
98
+ }
99
+ return 'ignored';
100
+ }
101
+ function deleteSpan(draft, from, to) {
102
+ const start = clamp(Math.min(from, to), draft.text.length);
103
+ const end = clamp(Math.max(from, to), draft.text.length);
104
+ if (start === end)
105
+ return;
106
+ draft.text = draft.text.slice(0, start) + draft.text.slice(end);
107
+ draft.cursor = start;
108
+ }
109
+ function previousGrapheme(text, cursor) {
110
+ const at = clamp(cursor, text.length);
111
+ const prior = graphemesOf(text).filter((part) => part.end <= at);
112
+ return prior[prior.length - 1]?.start ?? 0;
113
+ }
114
+ function nextGrapheme(text, cursor) {
115
+ const at = clamp(cursor, text.length);
116
+ return graphemesOf(text).find((part) => part.start >= at)?.end ?? text.length;
117
+ }
118
+ function lineStart(text, cursor) {
119
+ const at = clamp(cursor, text.length);
120
+ if (at === 0)
121
+ return 0;
122
+ const index = text.lastIndexOf('\n', at - 1);
123
+ return index < 0 ? 0 : index + 1;
124
+ }
125
+ function lineEnd(text, cursor) {
126
+ const at = clamp(cursor, text.length);
127
+ const index = text.indexOf('\n', at);
128
+ return index < 0 ? text.length : index;
129
+ }
130
+ function wordBoundary(text, cursor, direction) {
131
+ const parts = graphemesOf(text);
132
+ const at = clamp(cursor, text.length);
133
+ if (direction < 0) {
134
+ let index = -1;
135
+ for (let part = 0; part < parts.length; part += 1) {
136
+ if ((parts[part]?.end ?? 0) <= at)
137
+ index = part;
138
+ }
139
+ while (index >= 0 && isSpace(parts[index]?.text ?? ''))
140
+ index -= 1;
141
+ while (index >= 0 && !isSpace(parts[index]?.text ?? ''))
142
+ index -= 1;
143
+ return parts[index + 1]?.start ?? 0;
144
+ }
145
+ let index = parts.findIndex((part) => part.start >= at);
146
+ if (index < 0)
147
+ return text.length;
148
+ while (index < parts.length && isSpace(parts[index]?.text ?? ''))
149
+ index += 1;
150
+ while (index < parts.length && !isSpace(parts[index]?.text ?? ''))
151
+ index += 1;
152
+ return parts[index - 1]?.end ?? text.length;
153
+ }
154
+ function moveLine(text, cursor, direction) {
155
+ const at = clamp(cursor, text.length);
156
+ const start = lineStart(text, at);
157
+ const column = graphemesOf(text.slice(start, at)).length;
158
+ if (direction < 0) {
159
+ if (start === 0)
160
+ return at;
161
+ const previousEnd = start - 1;
162
+ return columnOnLine(text, lineStart(text, previousEnd), previousEnd, column);
163
+ }
164
+ const end = lineEnd(text, at);
165
+ if (end >= text.length)
166
+ return at;
167
+ const nextStart = end + 1;
168
+ return columnOnLine(text, nextStart, lineEnd(text, nextStart), column);
169
+ }
170
+ function columnOnLine(text, start, end, column) {
171
+ const parts = graphemesOf(text.slice(start, end));
172
+ if (column >= parts.length)
173
+ return end;
174
+ return start + (parts[column]?.start ?? 0);
175
+ }
176
+ function graphemesOf(text) {
177
+ return [...segmenter.segment(text)].map((part) => ({
178
+ start: part.index,
179
+ end: part.index + part.segment.length,
180
+ text: part.segment,
181
+ }));
182
+ }
183
+ function isSpace(text) {
184
+ return text.length > 0 && /^\s+$/u.test(text);
185
+ }
186
+ function isLegacyNewline(data) {
187
+ if (data === '\x1b\r' || data === '\x1b[13;2~')
188
+ return true;
189
+ return data.length > 1 && data.includes('\x1b') && data.includes('\r');
190
+ }
191
+ function isPrintable(data) {
192
+ if (data.length === 0)
193
+ return false;
194
+ return [...data].every((char) => {
195
+ const code = char.codePointAt(0) ?? 0;
196
+ return code >= 32 && code !== 0x7f && (code < 0x80 || code > 0x9f);
197
+ });
198
+ }
199
+ function clamp(cursor, length) {
200
+ return Math.max(0, Math.min(cursor, length));
201
+ }
@@ -0,0 +1,4 @@
1
+ export declare function markdownChat(title: string, messages: readonly {
2
+ role?: string;
3
+ content?: string;
4
+ }[]): string;
@@ -0,0 +1,7 @@
1
+ export function markdownChat(title, messages) {
2
+ const parts = [`# ${title}`, ''];
3
+ for (const message of messages) {
4
+ parts.push(`## ${message.role ?? ''}`, '', message.content ?? '', '');
5
+ }
6
+ return `${parts.join('\n').replace(/\n$/, '')}\n`;
7
+ }
@@ -0,0 +1,23 @@
1
+ export interface MentionToken {
2
+ start: number;
3
+ query: string;
4
+ }
5
+ export interface FilePick {
6
+ label: string;
7
+ insert: string;
8
+ directory: boolean;
9
+ }
10
+ export interface SavedFile {
11
+ name: string;
12
+ path: string;
13
+ error?: string;
14
+ }
15
+ /** The `@query` touching the cursor, if the `@` starts a token. */
16
+ export declare function mentionAt(text: string, cursor: number): MentionToken | null;
17
+ /** Files and directories under `root` whose relative path matches `@query`. */
18
+ export declare function completeFiles(root: string, query: string, limit?: number): Promise<FilePick[]>;
19
+ export declare function isImagePath(filePath: string): boolean;
20
+ /** Append stored paths the way the web composer does, so the agent can read them back. */
21
+ export declare function attachmentMessage(text: string, files: Array<{
22
+ path: string;
23
+ }>): string;
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
4
+ /** The `@query` touching the cursor, if the `@` starts a token. */
5
+ export function mentionAt(text, cursor) {
6
+ const at = Math.max(0, Math.min(cursor, text.length));
7
+ const match = /(^|\s)@([^\s@]*)$/.exec(text.slice(0, at));
8
+ if (!match)
9
+ return null;
10
+ const query = match[2] ?? '';
11
+ return { start: at - query.length - 1, query };
12
+ }
13
+ /** Files and directories under `root` whose relative path matches `@query`. */
14
+ export async function completeFiles(root, query, limit = 8) {
15
+ const normalized = query.replaceAll('\\', '/');
16
+ const slash = normalized.lastIndexOf('/');
17
+ const dirPart = slash >= 0 ? normalized.slice(0, slash) : '';
18
+ const prefix = slash >= 0 ? normalized.slice(slash + 1) : normalized;
19
+ let names;
20
+ try {
21
+ names = await fs.readdir(path.resolve(root, dirPart));
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const visible = names
27
+ .filter((name) => (prefix.startsWith('.') ? true : !name.startsWith('.')))
28
+ .filter((name) => name.startsWith(prefix))
29
+ .sort((left, right) => left.localeCompare(right))
30
+ .slice(0, 40);
31
+ const picks = [];
32
+ for (const name of visible) {
33
+ const full = path.join(path.resolve(root, dirPart), name);
34
+ let directory = false;
35
+ try {
36
+ directory = (await fs.stat(full)).isDirectory();
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ const relative = path.relative(root, full).split(path.sep).join('/');
42
+ const shown = directory ? `${relative}/` : relative;
43
+ picks.push({
44
+ label: shown,
45
+ insert: `@${shown}${directory ? '' : ' '}`,
46
+ directory,
47
+ });
48
+ }
49
+ picks.sort((left, right) => Number(right.directory) - Number(left.directory) || left.label.localeCompare(right.label));
50
+ return picks.slice(0, limit);
51
+ }
52
+ export function isImagePath(filePath) {
53
+ const dot = filePath.lastIndexOf('.');
54
+ return dot >= 0 && IMAGE_EXTENSIONS.has(filePath.slice(dot).toLowerCase());
55
+ }
56
+ /** Append stored paths the way the web composer does, so the agent can read them back. */
57
+ export function attachmentMessage(text, files) {
58
+ if (files.length === 0)
59
+ return text;
60
+ const refs = files.map((file) => `- \`${file.path}\``).join('\n');
61
+ const block = `相关文件:\n${refs}`;
62
+ return text ? `${text}\n\n---\n${block}` : block;
63
+ }
@@ -10,6 +10,11 @@ declare module '@earendil-works/pi-tui' {
10
10
  'agent.approval.denySession': true;
11
11
  'agent.approval.denyAlways': true;
12
12
  'agent.approval.abort': true;
13
+ 'agent.tool.toggle': true;
14
+ 'agent.clipboard.paste': true;
15
+ 'agent.queue.pull': true;
16
+ 'agent.copy.reply': true;
17
+ 'agent.editor': true;
13
18
  }
14
19
  }
15
20
  export declare const AGENT_KEYBINDINGS: {
@@ -49,7 +54,28 @@ export declare const AGENT_KEYBINDINGS: {
49
54
  readonly defaultKeys: "escape";
50
55
  readonly description: "Abort the turn";
51
56
  };
57
+ readonly 'agent.tool.toggle': {
58
+ readonly defaultKeys: "ctrl+o";
59
+ readonly description: "Expand the latest finished tool";
60
+ };
61
+ readonly 'agent.clipboard.paste': {
62
+ readonly defaultKeys: "ctrl+v" | "alt+v";
63
+ readonly description: "Paste an image or text from the clipboard";
64
+ };
65
+ readonly 'agent.queue.pull': {
66
+ readonly defaultKeys: "alt+up";
67
+ readonly description: "Pull the latest queued line back into the composer";
68
+ };
69
+ readonly 'agent.copy.reply': {
70
+ readonly defaultKeys: "alt+c";
71
+ readonly description: "Copy the latest assistant reply";
72
+ };
73
+ readonly 'agent.editor': {
74
+ readonly defaultKeys: "alt+e";
75
+ readonly description: "Edit the draft in an external editor";
76
+ };
52
77
  };
53
78
  export declare function installAgentKeybindings(user?: KeybindingsConfig): void;
54
79
  export declare function ensureAgentKeybindings(): void;
55
80
  export declare function keyLabel(id: Keybinding): string;
81
+ export declare function formatKey(key: string, platform?: NodeJS.Platform): string;
package/dist/tui/keys.js CHANGED
@@ -9,6 +9,14 @@ export const AGENT_KEYBINDINGS = {
9
9
  'agent.approval.denySession': { defaultKeys: 'shift+n', description: 'Deny this tool for the chat' },
10
10
  'agent.approval.denyAlways': { defaultKeys: 'shift+a', description: 'Always deny this tool' },
11
11
  'agent.approval.abort': { defaultKeys: 'escape', description: 'Abort the turn' },
12
+ 'agent.tool.toggle': { defaultKeys: 'ctrl+o', description: 'Expand the latest finished tool' },
13
+ 'agent.clipboard.paste': {
14
+ defaultKeys: process.platform === 'win32' ? 'alt+v' : 'ctrl+v',
15
+ description: 'Paste an image or text from the clipboard',
16
+ },
17
+ 'agent.queue.pull': { defaultKeys: 'alt+up', description: 'Pull the latest queued line back into the composer' },
18
+ 'agent.copy.reply': { defaultKeys: 'alt+c', description: 'Copy the latest assistant reply' },
19
+ 'agent.editor': { defaultKeys: 'alt+e', description: 'Edit the draft in an external editor' },
12
20
  };
13
21
  const DEFINITIONS = { ...TUI_KEYBINDINGS, ...AGENT_KEYBINDINGS };
14
22
  export function installAgentKeybindings(user = {}) {
@@ -23,18 +31,52 @@ export function keyLabel(id) {
23
31
  const key = getKeybindings().getKeys(id)[0] ?? '';
24
32
  return formatKey(key);
25
33
  }
26
- function formatKey(key) {
27
- if (key === 'escape')
28
- return 'Esc';
34
+ const MAC_NAME = {
35
+ ctrl: 'Control',
36
+ control: 'Control',
37
+ shift: 'Shift',
38
+ alt: 'Option',
39
+ opt: 'Option',
40
+ option: 'Option',
41
+ meta: 'Command',
42
+ cmd: 'Command',
43
+ command: 'Command',
44
+ super: 'Command',
45
+ enter: 'Return',
46
+ return: 'Return',
47
+ escape: 'Esc',
48
+ esc: 'Esc',
49
+ tab: 'Tab',
50
+ backspace: 'Delete',
51
+ delete: 'Forward Delete',
52
+ space: 'Space',
53
+ };
54
+ export function formatKey(key, platform = process.platform) {
29
55
  if (/^shift\+[a-z]$/.test(key))
30
56
  return key.slice('shift+'.length).toUpperCase();
57
+ if (platform === 'darwin') {
58
+ const parts = key.split('+');
59
+ return parts.map((part, index) => macName(part, parts.length > 1 && index === parts.length - 1)).join('+');
60
+ }
61
+ if (key === 'escape')
62
+ return 'Esc';
63
+ if (key === 'enter')
64
+ return 'Enter';
31
65
  if (!key.includes('+'))
32
66
  return key;
33
- return key.split('+').map((part) => {
34
- if (part === 'ctrl')
35
- return 'Ctrl';
36
- if (part.length === 1)
37
- return part.toUpperCase();
38
- return part[0].toUpperCase() + part.slice(1);
39
- }).join('+');
67
+ return key.split('+').map((part) => wordPart(part)).join('+');
68
+ }
69
+ function macName(part, chordKey) {
70
+ if (MAC_NAME[part])
71
+ return MAC_NAME[part];
72
+ if (part.length === 1)
73
+ return chordKey ? part.toUpperCase() : part;
74
+ return part[0].toUpperCase() + part.slice(1);
75
+ }
76
+ function wordPart(part) {
77
+ if (part === 'ctrl')
78
+ return 'Ctrl';
79
+ if (part.length === 1)
80
+ return part.toUpperCase();
81
+ return part[0].toUpperCase() + part.slice(1);
40
82
  }