@steerable/agent-cli 0.6.74 → 0.7.1

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.
@@ -1,4 +1,5 @@
1
1
  import { Box, CURSOR_MARKER, Text } from '@earendil-works/pi-tui';
2
+ import { cliText } from '../copy.js';
2
3
  import { childLines } from './children.js';
3
4
  import { splitAtCursor } from './editor.js';
4
5
  import { keyLabel } from './keys.js';
@@ -64,6 +65,7 @@ export function helpLines() {
64
65
  '/tasks 后台任务',
65
66
  '/fork 分叉',
66
67
  '/rewind 回退',
68
+ '/rules 项目规则',
67
69
  '/permissions 会话权限',
68
70
  '/copy 复制回答',
69
71
  '/editor 外部编辑器',
@@ -145,11 +147,27 @@ function screenLines(screen) {
145
147
  if (screen.search)
146
148
  lines.push(screen.search);
147
149
  lines.push(draftLine(screen.draft, screen.cursor));
150
+ const project = projectStatusLine(screen);
151
+ if (project)
152
+ lines.push(project);
148
153
  if (screen.status)
149
154
  lines.push(screen.status);
150
155
  lines.push(footerText());
151
156
  return lines;
152
157
  }
158
+ /** 状态行最左侧是无人值守提示,后面用竖线和项目、规则接在同一行。 */
159
+ export function projectStatusLine(screen) {
160
+ let approval = '';
161
+ if (screen.permission === 'auto' && screen.approvalPolicy === 'allow-all') {
162
+ approval = cliText('tui.approvalClosed');
163
+ }
164
+ else if (screen.permission !== 'auto' && screen.approvalPolicy === 'allow-read') {
165
+ approval = cliText('tui.approvalRead');
166
+ }
167
+ return [approval, screen.projectLabel, screen.ruleLabel]
168
+ .filter((line) => line && line.length > 0)
169
+ .join(' │ ');
170
+ }
153
171
  export function bannerLines(screen) {
154
172
  const lines = [];
155
173
  if (!screen.help && !screen.chats) {
@@ -163,8 +181,9 @@ export function bannerLines(screen) {
163
181
  }
164
182
  if (screen.mode === 'plan')
165
183
  lines.push('模式 计划');
166
- if (screen.permission === 'auto')
184
+ if (screen.permission === 'auto' && screen.approvalPolicy !== 'allow-all') {
167
185
  lines.push('权限 本会话自动');
186
+ }
168
187
  if (screen.permission === 'read')
169
188
  lines.push('权限 只读');
170
189
  for (const row of screen.panel ?? [])
@@ -1,5 +1,6 @@
1
1
  import { type Component } from '@earendil-works/pi-tui';
2
2
  import { type AgentClient } from '@steerable/agent-client';
3
+ import { type ApprovalSpec } from '../approve.js';
3
4
  import { type ClipboardRead } from './clipboard.js';
4
5
  import { type SavedFile } from './files.js';
5
6
  import { type TuiScreen } from './screen.js';
@@ -16,6 +17,9 @@ export interface AgentTuiOptions {
16
17
  writeClipboard?: (text: string) => Promise<void>;
17
18
  editInEditor?: (text: string) => Promise<string>;
18
19
  exportDir?: string;
20
+ projectId?: string;
21
+ projectLabel?: string;
22
+ approvalPolicy?: ApprovalSpec;
19
23
  liveIntervalMs?: number;
20
24
  onExit: () => void;
21
25
  onChange?: () => void;
@@ -49,6 +53,13 @@ export declare class AgentTui implements Component {
49
53
  private panel;
50
54
  private mode;
51
55
  private permission;
56
+ private approvalPolicy;
57
+ private readonly pendingAuto;
58
+ private markAutoTool;
59
+ private ensureEgressLine;
60
+ private latestAskLine;
61
+ private unboundAskLine;
62
+ private takePendingAuto;
52
63
  private todos;
53
64
  private goal;
54
65
  private loops;
@@ -58,6 +69,7 @@ export declare class AgentTui implements Component {
58
69
  private children;
59
70
  private readonly cwd;
60
71
  private status;
72
+ private ruleLabel;
61
73
  private stopped;
62
74
  private turnAbort;
63
75
  private pending;
@@ -72,6 +84,11 @@ export declare class AgentTui implements Component {
72
84
  private pasteClipboard;
73
85
  private browseHistory;
74
86
  private composing;
87
+ /**
88
+ * Insert text from a bracketed paste the terminal already split out.
89
+ * OpenTUI delivers that as a paste event, not as the raw `\x1b[200~` sequence.
90
+ */
91
+ acceptDecodedPaste(text: string): void;
75
92
  private consumePaste;
76
93
  private screen;
77
94
  private searchBar;
@@ -94,7 +111,11 @@ export declare class AgentTui implements Component {
94
111
  private manageLoop;
95
112
  private attach;
96
113
  private prepareMessage;
114
+ private newChatBody;
97
115
  private ensureChat;
116
+ private refreshRules;
117
+ private showRules;
118
+ private ruleContext;
98
119
  private runTurn;
99
120
  private observe;
100
121
  private watch;
@@ -6,6 +6,8 @@ import path from 'node:path';
6
6
  import { getKeybindings } from '@earendil-works/pi-tui';
7
7
  import { listBusyChatIds } from '@steerable/agent-client';
8
8
  import { saveAttachmentFiles } from '@steerable/agent-shell/attachments';
9
+ import { autoAnswer, decisionFor } from '../approve.js';
10
+ import { cliText } from '../copy.js';
9
11
  import { applyChildEvent, childLines } from './children.js';
10
12
  import { readSystemClipboard, saveClipboardImage, writeSystemClipboard } from './clipboard.js';
11
13
  import { completeSlash, slashAt } from './commands.js';
@@ -16,9 +18,10 @@ import { ensureAgentKeybindings } from './keys.js';
16
18
  import { createPromptHistory, leavePromptHistory, onFirstLine, onLastLine, recallPrompt, rememberPrompt, } from './prompt-history.js';
17
19
  import { renderScreen } from './screen.js';
18
20
  import { searchHits, searchLabel } from './search.js';
21
+ import { ruleStatusLine } from '../rules.js';
19
22
  import { transcriptEdge } from './scroll.js';
20
23
  import { todosFromAction } from './todos.js';
21
- import { historyRows, toolOutput, toolStatus } from './transcript.js';
24
+ import { formatDuration, historyRows, toolOutput, toolStatus } from './transcript.js';
22
25
  import { formatUsage } from './usage.js';
23
26
  export class AgentTui {
24
27
  client;
@@ -49,6 +52,60 @@ export class AgentTui {
49
52
  panel = null;
50
53
  mode = 'agent';
51
54
  permission = 'ask';
55
+ approvalPolicy = { policy: 'ask', answerAsk: false };
56
+ pendingAuto = new Map();
57
+ markAutoTool(name) {
58
+ const line = [...this.lines].reverse().find((item) => item.kind === 'tool' && item.name === name);
59
+ if (line && !line.auto && !sessionAllowStatus(line.status)) {
60
+ line.auto = true;
61
+ line.status = appendAuto(line.status);
62
+ return;
63
+ }
64
+ this.pendingAuto.set(name, (this.pendingAuto.get(name) ?? 0) + 1);
65
+ }
66
+ ensureEgressLine(args) {
67
+ const target = egressTarget(args);
68
+ const existing = [...this.lines].reverse().find((line) => line.kind === 'tool' && line.name === 'network_egress');
69
+ if (existing && !existing.auto) {
70
+ if (!existing.args && target)
71
+ existing.args = target;
72
+ if (!existing.status || existing.status === '…')
73
+ existing.status = '✓';
74
+ return;
75
+ }
76
+ this.lines.push({
77
+ kind: 'tool',
78
+ name: 'network_egress',
79
+ args: target,
80
+ status: '✓',
81
+ });
82
+ }
83
+ latestAskLine() {
84
+ for (let index = this.lines.length - 1; index >= 0; index -= 1) {
85
+ const line = this.lines[index];
86
+ if (line?.kind === 'tool' && line.name === 'ask_user')
87
+ return line;
88
+ }
89
+ return undefined;
90
+ }
91
+ unboundAskLine() {
92
+ for (let index = this.lines.length - 1; index >= 0; index -= 1) {
93
+ const line = this.lines[index];
94
+ if (line?.kind === 'tool' && line.name === 'ask_user' && !line.id)
95
+ return index;
96
+ }
97
+ return -1;
98
+ }
99
+ takePendingAuto(name) {
100
+ const pending = this.pendingAuto.get(name) ?? 0;
101
+ if (pending <= 0)
102
+ return false;
103
+ if (pending === 1)
104
+ this.pendingAuto.delete(name);
105
+ else
106
+ this.pendingAuto.set(name, pending - 1);
107
+ return true;
108
+ }
52
109
  todos = [];
53
110
  goal = null;
54
111
  loops = [];
@@ -58,6 +115,7 @@ export class AgentTui {
58
115
  children = [];
59
116
  cwd;
60
117
  status = '';
118
+ ruleLabel = '';
61
119
  stopped = false;
62
120
  turnAbort = null;
63
121
  pending = Promise.resolve();
@@ -65,6 +123,11 @@ export class AgentTui {
65
123
  this.client = client;
66
124
  this.options = options;
67
125
  this.cwd = options.cwd ?? process.cwd();
126
+ if (options.approvalPolicy) {
127
+ this.approvalPolicy = options.approvalPolicy;
128
+ if (options.approvalPolicy.policy === 'allow-all')
129
+ this.permission = 'auto';
130
+ }
68
131
  ensureAgentKeybindings();
69
132
  }
70
133
  async open() {
@@ -75,9 +138,16 @@ export class AgentTui {
75
138
  this.modelName = model;
76
139
  }
77
140
  this.rows = await this.loadChats();
78
- const current = this.rows[0];
141
+ const projectId = this.options.projectId;
142
+ const current = projectId
143
+ ? this.rows.find((chat) => chat.projectId === projectId)
144
+ : this.rows[0];
79
145
  if (current)
80
146
  await this.openChat(current.id);
147
+ else if (projectId) {
148
+ await this.ensureChat();
149
+ await this.refreshRules();
150
+ }
81
151
  void this.watch();
82
152
  this.touch();
83
153
  }
@@ -253,7 +323,7 @@ export class AgentTui {
253
323
  return;
254
324
  const edited = editDraft(this.buffer, data);
255
325
  if (edited === 'submit') {
256
- void this.submit();
326
+ this.pending = this.submit();
257
327
  return;
258
328
  }
259
329
  if (edited === 'edited')
@@ -313,6 +383,20 @@ export class AgentTui {
313
383
  composing() {
314
384
  return !this.approval && !this.help && !this.chats;
315
385
  }
386
+ /**
387
+ * Insert text from a bracketed paste the terminal already split out.
388
+ * OpenTUI delivers that as a paste event, not as the raw `\x1b[200~` sequence.
389
+ */
390
+ acceptDecodedPaste(text) {
391
+ if (!this.composing())
392
+ return;
393
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
394
+ if (normalized.length === 0)
395
+ return;
396
+ insertText(this.buffer, normalized);
397
+ this.refreshPicks();
398
+ this.touch();
399
+ }
316
400
  consumePaste(data) {
317
401
  let chunk = data;
318
402
  if (this.paste === null) {
@@ -323,11 +407,10 @@ export class AgentTui {
323
407
  const end = this.paste.indexOf('\x1b[201~');
324
408
  if (end < 0)
325
409
  return;
326
- const text = this.paste.slice(0, end).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
410
+ const text = this.paste.slice(0, end);
327
411
  const rest = this.paste.slice(end + '\x1b[201~'.length);
328
412
  this.paste = null;
329
- if (text.length > 0)
330
- insertText(this.buffer, text);
413
+ this.acceptDecodedPaste(text);
331
414
  if (rest.length > 0)
332
415
  this.applyInput(rest);
333
416
  }
@@ -345,6 +428,8 @@ export class AgentTui {
345
428
  draft: this.buffer.text,
346
429
  cursor: this.buffer.cursor,
347
430
  status: this.status,
431
+ ...(this.options.projectLabel ? { projectLabel: this.options.projectLabel } : {}),
432
+ ...(this.ruleLabel ? { ruleLabel: this.ruleLabel } : {}),
348
433
  picks: this.pickRows(),
349
434
  attachments: this.attachments.map((file) => file.name),
350
435
  children: this.children,
@@ -355,6 +440,7 @@ export class AgentTui {
355
440
  panel: this.panel,
356
441
  mode: this.mode,
357
442
  permission: this.permission,
443
+ approvalPolicy: this.approvalPolicy.policy,
358
444
  todos: this.todos,
359
445
  goal: this.goal,
360
446
  loops: this.loops,
@@ -496,6 +582,7 @@ export class AgentTui {
496
582
  title: chat.title || chat.id,
497
583
  busy: busy.has(chat.id),
498
584
  selected: false,
585
+ projectId: chat.projectId ?? null,
499
586
  }));
500
587
  }
501
588
  markSelection() {
@@ -530,6 +617,7 @@ export class AgentTui {
530
617
  this.attachments = [];
531
618
  await this.refreshGoal(id);
532
619
  await this.refreshLoops(id);
620
+ await this.refreshRules();
533
621
  this.touch();
534
622
  this.armLive(id);
535
623
  }
@@ -622,7 +710,7 @@ export class AgentTui {
622
710
  return;
623
711
  }
624
712
  if (command === 'new') {
625
- const created = await this.client.request('POST', '/api/v2/chats/new', {});
713
+ const created = await this.client.request('POST', '/api/v2/chats/new', this.newChatBody());
626
714
  const chatId = created.data.chatId;
627
715
  if (!chatId) {
628
716
  this.status = 'failed to create chat';
@@ -697,7 +785,18 @@ export class AgentTui {
697
785
  await this.rewindChat();
698
786
  return;
699
787
  }
788
+ if (command === 'rules') {
789
+ await this.showRules();
790
+ return;
791
+ }
700
792
  if (command === 'permissions') {
793
+ if (this.approvalPolicy.policy === 'allow-all' || this.approvalPolicy.policy === 'allow-read') {
794
+ this.approvalPolicy = { policy: 'ask', answerAsk: false };
795
+ this.permission = 'ask';
796
+ this.status = '权限 询问';
797
+ this.touch();
798
+ return;
799
+ }
701
800
  this.permission = this.permission === 'ask' ? 'auto' : this.permission === 'auto' ? 'read' : 'ask';
702
801
  this.status = this.permission === 'ask' ? '权限 询问' : this.permission === 'auto' ? '权限 本会话自动' : '权限 只读';
703
802
  this.touch();
@@ -822,13 +921,46 @@ export class AgentTui {
822
921
  images: stored.filter((file) => isImagePath(file.path)).map((file) => ({ path: file.path, name: file.name })),
823
922
  };
824
923
  }
924
+ newChatBody() {
925
+ return this.options.projectId ? { projectId: this.options.projectId } : {};
926
+ }
825
927
  async ensureChat() {
826
928
  if (this.chatId)
827
929
  return;
828
- const created = await this.client.request('POST', '/api/v2/chats/new', {});
930
+ const created = await this.client.request('POST', '/api/v2/chats/new', this.newChatBody());
829
931
  this.chatId = created.data.chatId ?? 'draft';
830
932
  this.title = this.chatId;
831
933
  }
934
+ async refreshRules() {
935
+ const context = await this.ruleContext();
936
+ if (!context)
937
+ return;
938
+ this.ruleLabel = ruleStatusLine(context, 'tui') ?? '';
939
+ }
940
+ async showRules() {
941
+ await this.ensureChat();
942
+ const context = await this.ruleContext();
943
+ if (!context?.project) {
944
+ this.status = '没有绑定项目';
945
+ this.touch();
946
+ return;
947
+ }
948
+ const files = context.files ?? [];
949
+ const summary = ruleStatusLine(context, 'tui') ?? '没有规则文件';
950
+ this.lines.push({ kind: 'tree', text: summary });
951
+ for (const file of files)
952
+ this.lines.push({ kind: 'tree', text: file });
953
+ this.ruleLabel = ruleStatusLine(context, 'tui') ?? '';
954
+ this.touch();
955
+ }
956
+ async ruleContext() {
957
+ if (!this.chatId)
958
+ return null;
959
+ const response = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(this.chatId)}/project-context`, undefined);
960
+ if (response.status !== 200)
961
+ return null;
962
+ return response.data;
963
+ }
832
964
  async runTurn(text, images = []) {
833
965
  await this.ensureChat();
834
966
  if (!this.chatId)
@@ -866,6 +998,8 @@ export class AgentTui {
866
998
  this.queue = [];
867
999
  if (this.turnAbort === controller)
868
1000
  this.turnAbort = null;
1001
+ await this.refreshRules();
1002
+ this.touch();
869
1003
  }
870
1004
  }
871
1005
  observe(event, signal) {
@@ -887,13 +1021,38 @@ export class AgentTui {
887
1021
  const payload = event.payload ?? {};
888
1022
  const name = typeof payload.name === 'string' ? payload.name : 'tool';
889
1023
  const args = toolArgs(payload.arguments ?? payload.input);
890
- this.lines.push({ kind: 'tool', name, args, status: '…' });
1024
+ const auto = this.takePendingAuto(name);
1025
+ if (name === 'network_egress') {
1026
+ const existing = [...this.lines].reverse().find((line) => line.kind === 'tool' && line.name === name);
1027
+ if (existing) {
1028
+ if (!existing.args && args)
1029
+ existing.args = args;
1030
+ if (auto) {
1031
+ existing.auto = true;
1032
+ existing.status = appendAuto(existing.status);
1033
+ }
1034
+ return;
1035
+ }
1036
+ }
1037
+ this.lines.push({
1038
+ kind: 'tool',
1039
+ name,
1040
+ args,
1041
+ status: auto ? cliText('tui.toolAuto') : '…',
1042
+ ...(auto ? { auto: true } : {}),
1043
+ });
891
1044
  return;
892
1045
  }
893
1046
  if (event.type === 'tool_result') {
894
- const tool = [...this.lines].reverse().find((line) => line.kind === 'tool' && line.status === '…');
895
- if (tool)
896
- tool.status = '✓';
1047
+ const tool = [...this.lines].reverse().find((line) => line.kind === 'tool' && openToolStatus(line.status));
1048
+ if (!tool || sessionAllowStatus(tool.status))
1049
+ return;
1050
+ const payload = event.payload ?? {};
1051
+ const failed = payload.success === false || payload.error != null;
1052
+ const duration = typeof payload.durationMs === 'number' ? formatDuration(payload.durationMs) : '';
1053
+ const mark = failed ? '✗' : '✓';
1054
+ const base = duration ? `${mark} ${duration}` : mark;
1055
+ tool.status = tool.auto ? appendAuto(base) : base;
897
1056
  return;
898
1057
  }
899
1058
  if (String(event.type) === 'reasoning' && typeof event.content === 'string') {
@@ -930,8 +1089,17 @@ export class AgentTui {
930
1089
  const payload = event.payload;
931
1090
  if (!payload.requestId)
932
1091
  continue;
933
- if (this.permission === 'auto') {
934
- void this.client.decideApproval(payload.requestId, 'allow_for_session');
1092
+ const spec = this.permission === 'auto'
1093
+ ? { policy: 'allow-all', answerAsk: this.approvalPolicy.answerAsk }
1094
+ : this.approvalPolicy;
1095
+ const decision = decisionFor(spec, { mode: payload.mode }, 'tui');
1096
+ if (decision !== 'prompt') {
1097
+ if (decision === 'allow_once' && payload.toolName === 'network_egress') {
1098
+ this.ensureEgressLine(payload.arguments);
1099
+ }
1100
+ if (decision === 'allow_once' && payload.toolName)
1101
+ this.markAutoTool(payload.toolName);
1102
+ void this.client.decideApproval(payload.requestId, decision);
935
1103
  this.touch();
936
1104
  continue;
937
1105
  }
@@ -943,8 +1111,40 @@ export class AgentTui {
943
1111
  }
944
1112
  else if (event.channel === 'ask-user:request') {
945
1113
  const payload = event.payload;
946
- if (payload.requestId)
947
- this.ask = { requestId: payload.requestId, prompt: payload.prompt ?? '' };
1114
+ if (!payload.requestId)
1115
+ continue;
1116
+ const intro = (payload.prompt || payload.intro || '').trim();
1117
+ if (this.approvalPolicy.answerAsk) {
1118
+ const questions = payload.questions?.length ? payload.questions : [{ id: 'answer' }];
1119
+ void this.client.answerAsk(payload.requestId, {
1120
+ answers: autoAnswer(questions.flatMap((question) => question.id ? [{ id: question.id }] : [])),
1121
+ });
1122
+ const asked = questions
1123
+ .map((question) => question.text?.trim() ?? '')
1124
+ .filter((text) => text.length > 0);
1125
+ const answer = cliText('ask.unattended');
1126
+ const args = intro || asked[0] || '';
1127
+ const output = [...asked.map((text) => `问 ${text}`), `答 ${answer}`].join('\n');
1128
+ const line = this.latestAskLine();
1129
+ if (line && !autoAnswerStatus(line.status)) {
1130
+ if (!line.args)
1131
+ line.args = args;
1132
+ line.status = autoAnswerMark();
1133
+ line.output = output;
1134
+ }
1135
+ else {
1136
+ this.lines.push({
1137
+ kind: 'tool',
1138
+ name: 'ask_user',
1139
+ args,
1140
+ status: autoAnswerMark(),
1141
+ output,
1142
+ });
1143
+ }
1144
+ this.touch();
1145
+ continue;
1146
+ }
1147
+ this.ask = { requestId: payload.requestId, prompt: intro };
948
1148
  }
949
1149
  else if (event.channel === 'goal-changed') {
950
1150
  const payload = event.payload;
@@ -983,11 +1183,30 @@ export class AgentTui {
983
1183
  if (nextTodos)
984
1184
  this.todos = nextTodos;
985
1185
  const next = toolLine(action);
986
- const index = next.id
1186
+ let index = next.id
987
1187
  ? this.lines.findIndex((line) => line.kind === 'tool' && line.id === next.id)
988
1188
  : -1;
1189
+ if (index < 0 && next.name === 'ask_user')
1190
+ index = this.unboundAskLine();
1191
+ const prior = index >= 0 ? this.lines[index] : undefined;
1192
+ const keepAnswer = autoAnswerStatus(prior?.status);
1193
+ const auto = prior?.auto === true || (!keepAnswer && this.takePendingAuto(next.name ?? ''));
1194
+ if (auto) {
1195
+ next.auto = true;
1196
+ next.status = appendAuto(next.status);
1197
+ }
989
1198
  if (index >= 0) {
990
- next.open = this.lines[index]?.open;
1199
+ next.open = prior?.open;
1200
+ if (keepAnswer && prior) {
1201
+ next.status = autoAnswerMark();
1202
+ if (prior.output)
1203
+ next.output = prior.output;
1204
+ if (!next.args && prior.args)
1205
+ next.args = prior.args;
1206
+ }
1207
+ else if (prior?.output && !next.output) {
1208
+ next.output = prior.output;
1209
+ }
991
1210
  this.lines[index] = next;
992
1211
  }
993
1212
  else {
@@ -1152,7 +1371,18 @@ export class AgentTui {
1152
1371
  async forkChat() {
1153
1372
  if (!this.chatId)
1154
1373
  return;
1155
- const created = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/fork`, {});
1374
+ const created = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/fork`, { locale: 'zh' });
1375
+ if (created.status === 409) {
1376
+ this.status = '正在生成,结束后再分叉';
1377
+ this.touch();
1378
+ return;
1379
+ }
1380
+ if (created.status === 413) {
1381
+ const bytes = numberField(created.data, 'bytes');
1382
+ this.status = `工作区太大,无法分叉(${formatForkBytes(bytes)})`;
1383
+ this.touch();
1384
+ return;
1385
+ }
1156
1386
  const chatId = created.status === 200 ? textField(created.data, 'chatId') : '';
1157
1387
  if (!chatId) {
1158
1388
  this.status = '分叉失败';
@@ -1274,6 +1504,43 @@ async function defaultSaveAttachments(chatId, files) {
1274
1504
  ...(file.error ? { error: file.error } : {}),
1275
1505
  }));
1276
1506
  }
1507
+ function egressTarget(value) {
1508
+ if (!value || typeof value !== 'object')
1509
+ return '';
1510
+ const record = value;
1511
+ const host = typeof record.host === 'string' ? record.host : '';
1512
+ if (!host)
1513
+ return '';
1514
+ const port = typeof record.port === 'number' ? record.port : null;
1515
+ if (port === null || port === 80 || port === 443)
1516
+ return host;
1517
+ return `${host}:${port}`;
1518
+ }
1519
+ function autoAnswerMark() {
1520
+ return `✓ ${cliText('tui.toolAutoAnswer')}`;
1521
+ }
1522
+ function autoAnswerStatus(status) {
1523
+ const phrase = cliText('tui.toolAutoAnswer');
1524
+ return status === phrase || status === autoAnswerMark() || (status?.endsWith(` ${phrase}`) ?? false);
1525
+ }
1526
+ function appendAuto(status) {
1527
+ const auto = cliText('tui.toolAuto');
1528
+ const session = cliText('tui.toolSessionAllow');
1529
+ if (autoAnswerStatus(status))
1530
+ return autoAnswerMark();
1531
+ if (!status || status === '…')
1532
+ return auto;
1533
+ if (status === session || status.endsWith(` ${session}`) || status === auto || status.endsWith(` ${auto}`))
1534
+ return status;
1535
+ return `${status} ${auto}`;
1536
+ }
1537
+ function sessionAllowStatus(status) {
1538
+ const session = cliText('tui.toolSessionAllow');
1539
+ return status === session || (status?.endsWith(` ${session}`) ?? false);
1540
+ }
1541
+ function openToolStatus(status) {
1542
+ return status === '…' || status === cliText('tui.toolAuto');
1543
+ }
1277
1544
  function toolLine(action) {
1278
1545
  const name = typeof action.tool === 'string' && action.tool ? action.tool : 'tool';
1279
1546
  const title = typeof action.view?.title === 'string' ? action.view.title : '';
@@ -1300,6 +1567,13 @@ function textField(value, key) {
1300
1567
  const field = value[key];
1301
1568
  return typeof field === 'string' ? field : '';
1302
1569
  }
1570
+ function formatForkBytes(bytes) {
1571
+ if (bytes >= 1024 * 1024)
1572
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1573
+ if (bytes >= 1024)
1574
+ return `${Math.round(bytes / 1024)} KB`;
1575
+ return `${bytes} B`;
1576
+ }
1303
1577
  function numberField(value, key) {
1304
1578
  if (!value || typeof value !== 'object')
1305
1579
  return 0;
@@ -9,6 +9,10 @@ export interface ToolAction {
9
9
  success?: boolean;
10
10
  error?: unknown;
11
11
  durationMs?: number;
12
+ approval?: {
13
+ kind?: string;
14
+ cached?: boolean;
15
+ };
12
16
  }
13
17
  export declare function formatDuration(ms: number): string;
14
18
  export declare function toolStatus(action: ToolAction): string;
@@ -1,4 +1,5 @@
1
1
  import { applyChildEvent, childLines } from './children.js';
2
+ import { cliText } from '../copy.js';
2
3
  export function formatDuration(ms) {
3
4
  if (!Number.isFinite(ms) || ms < 0)
4
5
  return '';
@@ -12,7 +13,11 @@ export function toolStatus(action) {
12
13
  return '…';
13
14
  const mark = action.success === false || action.error != null ? '✗' : '✓';
14
15
  const duration = typeof action.durationMs === 'number' ? formatDuration(action.durationMs) : '';
15
- return duration ? `${mark} ${duration}` : mark;
16
+ const base = duration ? `${mark} ${duration}` : mark;
17
+ if (action.approval?.kind === 'allow_for_session' && action.approval.cached === true) {
18
+ return `${base} ${cliText('tui.toolSessionAllow')}`;
19
+ }
20
+ return base;
16
21
  }
17
22
  export function toolOutput(action) {
18
23
  const error = textOf(action.error);
package/dist/tui/view.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BoxRenderable, ScrollBoxRenderable, TextRenderable, bold, dim as dimText, italic, reverse, strikethrough, t, underline, } from '@opentui/core';
2
2
  import { splitAtCursor } from './editor.js';
3
3
  import { childLines } from './children.js';
4
- import { approvalLines, attachmentText, bannerLines, chatText, composerRows, draftHint, footerText, helpLines, pickText, readOnlyText, reasoningBlock, toolBlock, } from './screen.js';
4
+ import { approvalLines, attachmentText, bannerLines, projectStatusLine, chatText, composerRows, draftHint, footerText, helpLines, pickText, readOnlyText, reasoningBlock, toolBlock, } from './screen.js';
5
5
  import { pageScrollLines } from './scroll.js';
6
6
  import { plainMarkdown } from './transcript.js';
7
7
  const canvas = '#16161e';
@@ -172,8 +172,10 @@ export class OpenTuiView {
172
172
  this.readOnly.visible = screen.readOnly;
173
173
  this.draft.content = draftContent(screen.draft, screen.cursor);
174
174
  this.composer.height = composerRows(screen.draft, Math.max(1, this.renderer.width - 2));
175
- this.status.content = screen.status;
176
- this.status.visible = screen.status.length > 0;
175
+ const project = projectStatusLine(screen);
176
+ const status = [project, screen.status].filter((line) => line && line.length > 0).join('\n');
177
+ this.status.content = status;
178
+ this.status.visible = status.length > 0;
177
179
  this.footer.content = footerText();
178
180
  const attached = attachmentText(screen.attachments ?? []);
179
181
  this.attached.content = attached;