@steerable/agent-cli 0.6.62 → 0.6.64

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.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
1
  # @steerable/agent-cli
2
2
 
3
- Product-neutral command line for a Steerable host. Products wrap `createCli` and supply the bin name. Commands: `run`, `chat`, `skills`, `mcp`, `config`, and `doctor`.
3
+ Product-neutral command line for a Steerable host. Products wrap `createCli` and supply the bin name. A bare invocation opens the TUI when stdin is a terminal; `tui` opens it explicitly. Other commands are `run`, `chat`, `skills`, `mcp`, `config`, and `doctor`.
4
+
5
+ `pnpm test:tui` runs the deterministic renderer and interaction cases on every supported operating system. `pnpm test:tui:pty` runs the built TUI in a 100×32 tmux pane on Linux. Failed state waits write the visible frame, input trace, and environment metadata under `test-results/tui/`.
package/dist/cli.js CHANGED
@@ -64,10 +64,8 @@ async function runCli(options) {
64
64
  stdout.write(`${rootHelp(packCommands)}\n`);
65
65
  return 0;
66
66
  }
67
- if (!command) {
68
- stderr.write(`${rootHelp(packCommands)}\n`);
69
- return 2;
70
- }
67
+ if (!command)
68
+ return tuiCommand(parsed, options, stdout, stderr);
71
69
  if (command === 'run')
72
70
  return runCommand(parsed, options, stdout, stderr);
73
71
  if (command === 'chat')
@@ -10,6 +10,8 @@ const COMMANDS = [
10
10
  { name: 'export', hint: '导出会话' },
11
11
  { name: 'compact', hint: '压缩上下文' },
12
12
  { name: 'plan', hint: '计划模式' },
13
+ { name: 'goal', hint: '持续目标' },
14
+ { name: 'loop', hint: '循环执行' },
13
15
  { name: 'tasks', hint: '后台任务' },
14
16
  { name: 'fork', hint: '分叉' },
15
17
  { name: 'rewind', hint: '回退' },
package/dist/tui/run.d.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { type Terminal } from '@earendil-works/pi-tui';
2
+ import type { CliRenderer } from '@opentui/core';
2
3
  import type { AgentClient } from '@steerable/agent-client';
3
- export declare function runTui(options: {
4
+ export interface RunTuiOptions {
4
5
  client: AgentClient;
5
6
  product: string;
6
7
  dataDir?: string;
7
8
  terminal?: Terminal;
8
- }): Promise<number>;
9
+ fallbackTerminal?: Terminal;
10
+ openRenderer?: CliRenderer;
11
+ }
12
+ export declare function runTui(options: RunTuiOptions): Promise<number>;
9
13
  /** Terminal reports (capability replies, focus) must reach the renderer so Shift+Enter stays distinct from Enter. */
10
14
  export declare function yieldsToRenderer(sequence: string): boolean;
package/dist/tui/run.js CHANGED
@@ -12,13 +12,18 @@ export async function runTui(options) {
12
12
  catch (error) {
13
13
  if (!openTuiUnavailable(error))
14
14
  throw error;
15
- return runPlainTui(options);
15
+ return runPlainTui({
16
+ ...options,
17
+ ...(options.fallbackTerminal ? { terminal: options.fallbackTerminal } : {}),
18
+ });
16
19
  }
17
20
  }
18
21
  async function runOpenTui(options) {
19
- const { createCliRenderer } = await import('@opentui/core');
22
+ const createCliRenderer = options.openRenderer
23
+ ? null
24
+ : (await import('@opentui/core')).createCliRenderer;
20
25
  const { OpenTuiView } = await import('./view.js');
21
- const renderer = await createCliRenderer({
26
+ const renderer = options.openRenderer ?? await createCliRenderer({
22
27
  exitOnCtrlC: false,
23
28
  useMouse: false,
24
29
  consoleMode: 'disabled',
@@ -27,7 +32,7 @@ async function runOpenTui(options) {
27
32
  backgroundColor: '#16161e',
28
33
  targetFps: 30,
29
34
  });
30
- return new Promise((resolve) => {
35
+ return new Promise((resolve, reject) => {
31
36
  const session = new AgentTui(options.client, {
32
37
  product: options.product,
33
38
  ...(options.dataDir ? { dataDir: options.dataDir } : {}),
@@ -51,13 +56,16 @@ async function runOpenTui(options) {
51
56
  session.handleInput(sequence);
52
57
  return true;
53
58
  });
54
- void session.open().then(() => view.apply(session.snapshot()));
59
+ void session.open().then(() => view.apply(session.snapshot()), (error) => {
60
+ renderer.destroy();
61
+ reject(error);
62
+ });
55
63
  });
56
64
  }
57
65
  function runPlainTui(options) {
58
66
  const terminal = options.terminal ?? new ProcessTerminal();
59
67
  const tui = new TuiAltScreen(terminal);
60
- return new Promise((resolve) => {
68
+ return new Promise((resolve, reject) => {
61
69
  const session = new AgentTui(options.client, {
62
70
  product: options.product,
63
71
  ...(options.dataDir ? { dataDir: options.dataDir } : {}),
@@ -71,7 +79,10 @@ function runPlainTui(options) {
71
79
  });
72
80
  tui.addChild(session);
73
81
  tui.setFocus(session);
74
- void session.open().then(() => tui.requestRender());
82
+ void session.open().then(() => tui.requestRender(), (error) => {
83
+ tui.stop();
84
+ reject(error);
85
+ });
75
86
  tui.start();
76
87
  });
77
88
  }
@@ -53,6 +53,17 @@ export interface TuiScreen {
53
53
  content: string;
54
54
  status: 'pending' | 'in_progress' | 'completed';
55
55
  }>;
56
+ goal?: {
57
+ objective: string;
58
+ phase: 'active' | 'paused' | 'blocked' | 'complete';
59
+ turns: number;
60
+ blockedReason?: string;
61
+ } | null;
62
+ loops?: Array<{
63
+ id: string;
64
+ prompt: string;
65
+ intervalSeconds: number;
66
+ }>;
56
67
  }
57
68
  export declare function draftHint(): string;
58
69
  export declare function renderScreen(screen: TuiScreen, width: number): string[];
@@ -59,6 +59,8 @@ export function helpLines() {
59
59
  '/export 导出会话',
60
60
  '/compact 压缩上下文',
61
61
  '/plan 计划模式',
62
+ '/goal <目标> 持续执行',
63
+ '/loop <间隔> <任务> 循环执行',
62
64
  '/tasks 后台任务',
63
65
  '/fork 分叉',
64
66
  '/rewind 回退',
@@ -151,6 +153,11 @@ function screenLines(screen) {
151
153
  export function bannerLines(screen) {
152
154
  const lines = [];
153
155
  if (!screen.help && !screen.chats) {
156
+ if (screen.goal)
157
+ lines.push(goalText(screen.goal));
158
+ if ((screen.loops?.length ?? 0) > 0) {
159
+ lines.push(`Loop · ${screen.loops.length} 个运行中`);
160
+ }
154
161
  for (const todo of screen.todos ?? [])
155
162
  lines.push(todoText(todo));
156
163
  }
@@ -164,6 +171,19 @@ export function bannerLines(screen) {
164
171
  lines.push(row);
165
172
  return lines;
166
173
  }
174
+ function goalText(goal) {
175
+ const objective = goal.objective.length > 48
176
+ ? `${goal.objective.slice(0, 47)}…`
177
+ : goal.objective;
178
+ if (goal.phase === 'blocked') {
179
+ return `目标 · 阻塞 · ${objective}${goal.blockedReason ? ` · ${goal.blockedReason}` : ''}`;
180
+ }
181
+ if (goal.phase === 'paused')
182
+ return `目标 · 暂停 · ${objective}`;
183
+ if (goal.phase === 'complete')
184
+ return `目标 · 完成 · ${objective}`;
185
+ return `目标 · ${objective} · 第 ${goal.turns} 轮`;
186
+ }
167
187
  function todoText(todo) {
168
188
  if (todo.status === 'completed')
169
189
  return `✓ ${todo.content}`;
@@ -31,6 +31,7 @@ export declare class AgentTui implements Component {
31
31
  private chats;
32
32
  private selected;
33
33
  private approval;
34
+ private approvalSubmitting;
34
35
  private ask;
35
36
  private readOnly;
36
37
  private help;
@@ -49,6 +50,10 @@ export declare class AgentTui implements Component {
49
50
  private mode;
50
51
  private permission;
51
52
  private todos;
53
+ private goal;
54
+ private loops;
55
+ private backendTurnActive;
56
+ private loopExitArmed;
52
57
  private liveGeneration;
53
58
  private children;
54
59
  private readonly cwd;
@@ -81,9 +86,12 @@ export declare class AgentTui implements Component {
81
86
  private markSelection;
82
87
  private openChat;
83
88
  private submit;
89
+ private drainQueue;
84
90
  private sendPrepared;
85
91
  private prepareOwned;
86
92
  private slash;
93
+ private manageGoal;
94
+ private manageLoop;
87
95
  private attach;
88
96
  private prepareMessage;
89
97
  private ensureChat;
@@ -94,6 +102,9 @@ export declare class AgentTui implements Component {
94
102
  private toggleFold;
95
103
  private armLive;
96
104
  private pollLive;
105
+ private refreshGoal;
106
+ private refreshLoops;
107
+ private reloadMessages;
97
108
  private showList;
98
109
  private showTasks;
99
110
  private exportChat;
@@ -31,6 +31,7 @@ export class AgentTui {
31
31
  chats = null;
32
32
  selected = 0;
33
33
  approval = null;
34
+ approvalSubmitting = false;
34
35
  ask = null;
35
36
  readOnly = false;
36
37
  help = false;
@@ -49,6 +50,10 @@ export class AgentTui {
49
50
  mode = 'agent';
50
51
  permission = 'ask';
51
52
  todos = [];
53
+ goal = null;
54
+ loops = [];
55
+ backendTurnActive = false;
56
+ loopExitArmed = false;
52
57
  liveGeneration = 0;
53
58
  children = [];
54
59
  cwd;
@@ -128,6 +133,8 @@ export class AgentTui {
128
133
  return;
129
134
  }
130
135
  if (this.approval) {
136
+ if (this.approvalSubmitting)
137
+ return;
131
138
  const kind = approvalKind(data);
132
139
  if (kind)
133
140
  void this.decide(kind);
@@ -349,6 +356,8 @@ export class AgentTui {
349
356
  mode: this.mode,
350
357
  permission: this.permission,
351
358
  todos: this.todos,
359
+ goal: this.goal,
360
+ loops: this.loops,
352
361
  };
353
362
  }
354
363
  searchBar() {
@@ -519,6 +528,8 @@ export class AgentTui {
519
528
  this.children = [];
520
529
  this.picks = null;
521
530
  this.attachments = [];
531
+ await this.refreshGoal(id);
532
+ await this.refreshLoops(id);
522
533
  this.touch();
523
534
  this.armLive(id);
524
535
  }
@@ -530,8 +541,14 @@ export class AgentTui {
530
541
  rememberPrompt(this.prompts, text);
531
542
  if (!text && this.attachments.length === 0)
532
543
  return;
533
- if (text.startsWith('/')) {
534
- await this.slash(text);
544
+ if (text.startsWith('/') && !isSkillFacadeCommand(text)) {
545
+ try {
546
+ await this.slash(text);
547
+ }
548
+ catch (error) {
549
+ this.status = `命令失败:${error instanceof Error ? error.message : String(error)}`;
550
+ this.touch();
551
+ }
535
552
  return;
536
553
  }
537
554
  if (this.readOnly) {
@@ -544,14 +561,18 @@ export class AgentTui {
544
561
  return;
545
562
  }
546
563
  const files = this.attachments.splice(0);
547
- if (this.sending) {
548
- this.queue.push({ text, files });
564
+ this.queue.push({ text, files });
565
+ if (this.sending || this.backendTurnActive) {
549
566
  this.touch();
550
567
  return;
551
568
  }
569
+ await this.drainQueue();
570
+ }
571
+ async drainQueue() {
572
+ if (this.sending || this.backendTurnActive)
573
+ return;
552
574
  this.sending = true;
553
575
  try {
554
- await this.sendPrepared(text, files);
555
576
  while (!this.stopped && this.queue.length > 0) {
556
577
  const next = this.queue.shift();
557
578
  if (!next)
@@ -565,6 +586,7 @@ export class AgentTui {
565
586
  }
566
587
  }
567
588
  async sendPrepared(text, files) {
589
+ this.status = '';
568
590
  const prepared = await this.prepareOwned(text, files);
569
591
  this.lines.push({ kind: 'user', text: prepared.message });
570
592
  this.touch();
@@ -634,11 +656,11 @@ export class AgentTui {
634
656
  return;
635
657
  }
636
658
  if (command === 'skills') {
637
- await this.showList('GET', '/api/v2/chat-agents/skills', 'skills', (row) => `技能 ${textField(row, 'name')}${textField(row, 'description') ? ` ${textField(row, 'description')}` : ''}`, '没有技能');
659
+ await this.showList('GET', '/api/v2/chat-agents/skills', 'skills', (row) => `技能 ${textField(row, 'name')}${textField(row, 'description') ? ` ${textField(row, 'description')}` : ''}`, '没有技能', '技能不可用');
638
660
  return;
639
661
  }
640
662
  if (command === 'mcp') {
641
- await this.showList('GET', '/api/v2/mcp/servers', 'servers', (row) => `MCP ${textField(row, 'name') || textField(row, 'id')} ${numberField(row, 'toolCount')} 个工具`, '没有 MCP');
663
+ await this.showList('GET', '/api/v2/mcp/servers', 'servers', (row) => `MCP ${textField(row, 'name') || textField(row, 'id')} ${numberField(row, 'toolCount')} 个工具`, '没有 MCP', 'MCP 不可用');
642
664
  return;
643
665
  }
644
666
  if (command === 'export') {
@@ -655,6 +677,14 @@ export class AgentTui {
655
677
  this.touch();
656
678
  return;
657
679
  }
680
+ if (command === 'goal') {
681
+ await this.manageGoal(rest);
682
+ return;
683
+ }
684
+ if (command === 'loop') {
685
+ await this.manageLoop(rest);
686
+ return;
687
+ }
658
688
  if (command === 'tasks') {
659
689
  await this.showTasks();
660
690
  return;
@@ -684,6 +714,54 @@ export class AgentTui {
684
714
  this.status = `unknown command /${command}`;
685
715
  this.touch();
686
716
  }
717
+ async manageGoal(parts) {
718
+ if (!this.chatId) {
719
+ this.status = '还没有会话';
720
+ return;
721
+ }
722
+ if (parts.length === 0) {
723
+ await this.refreshGoal(this.chatId);
724
+ this.status = this.goal ? '' : '没有持续目标';
725
+ return;
726
+ }
727
+ const action = parts[0];
728
+ if (!['edit', 'pause', 'resume', 'complete', 'clear'].includes(action)) {
729
+ this.status = '用法 /goal [edit <目标>|pause|resume|complete|clear]';
730
+ return;
731
+ }
732
+ const objective = parts.slice(1).join(' ').trim();
733
+ if (action === 'edit' && !objective) {
734
+ this.status = '用法 /goal edit <目标>';
735
+ return;
736
+ }
737
+ const response = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/goal`, { action, ...(objective ? { objective } : {}) });
738
+ if (response.status !== 200) {
739
+ this.status = `目标操作失败 (${response.status})`;
740
+ return;
741
+ }
742
+ this.goal = response.data.goal ?? null;
743
+ this.status = '';
744
+ }
745
+ async manageLoop(parts) {
746
+ if (!this.chatId) {
747
+ this.status = '还没有会话';
748
+ return;
749
+ }
750
+ if (parts[0] === 'list') {
751
+ await this.refreshLoops(this.chatId);
752
+ this.panel = this.loops.length > 0
753
+ ? this.loops.map((loop) => `Loop ${loop.id} 每 ${loop.intervalSeconds} 秒 ${loop.prompt}`)
754
+ : ['没有运行中的 Loop'];
755
+ return;
756
+ }
757
+ if (parts[0] === 'stop' && parts[1]) {
758
+ const response = await this.client.request('DELETE', `/api/v2/chats/${encodeURIComponent(this.chatId)}/loops/${encodeURIComponent(parts[1])}`, undefined);
759
+ this.status = response.status === 200 ? 'Loop 已停止' : `停止 Loop 失败 (${response.status})`;
760
+ await this.refreshLoops(this.chatId);
761
+ return;
762
+ }
763
+ this.status = '用法 /loop list | /loop stop <id>';
764
+ }
687
765
  async attach(target) {
688
766
  if (this.readOnly) {
689
767
  this.status = '只读 · 另一个进程正在运行';
@@ -758,7 +836,6 @@ export class AgentTui {
758
836
  const controller = new AbortController();
759
837
  this.turnAbort = controller;
760
838
  this.children = [];
761
- this.status = '';
762
839
  try {
763
840
  const payload = { message: text };
764
841
  if (images.length > 0)
@@ -770,6 +847,12 @@ export class AgentTui {
770
847
  this.touch();
771
848
  }
772
849
  }
850
+ catch (error) {
851
+ if (!controller.signal.aborted) {
852
+ this.status = error instanceof Error ? error.message : String(error);
853
+ this.touch();
854
+ }
855
+ }
773
856
  finally {
774
857
  for (const line of this.lines)
775
858
  line.streaming = false;
@@ -863,6 +946,32 @@ export class AgentTui {
863
946
  if (payload.requestId)
864
947
  this.ask = { requestId: payload.requestId, prompt: payload.prompt ?? '' };
865
948
  }
949
+ else if (event.channel === 'goal-changed') {
950
+ const payload = event.payload;
951
+ if (payload.chatId === this.chatId)
952
+ this.goal = payload.goal ?? null;
953
+ }
954
+ else if (event.channel === 'loop-changed') {
955
+ const payload = event.payload;
956
+ if (payload.chatId === this.chatId)
957
+ this.loops = payload.loops ?? [];
958
+ }
959
+ else if (event.channel === 'chat-turn-started') {
960
+ const payload = event.payload;
961
+ if (payload.chatId === this.chatId) {
962
+ this.backendTurnActive = true;
963
+ this.armLive(payload.chatId, true);
964
+ }
965
+ }
966
+ else if (event.channel === 'chat-turn-finished') {
967
+ const payload = event.payload;
968
+ if (payload.chatId === this.chatId) {
969
+ this.backendTurnActive = false;
970
+ await this.reloadMessages(payload.chatId);
971
+ await this.refreshGoal(payload.chatId);
972
+ void this.drainQueue();
973
+ }
974
+ }
866
975
  this.touch();
867
976
  }
868
977
  }
@@ -904,11 +1013,11 @@ export class AgentTui {
904
1013
  }
905
1014
  }
906
1015
  }
907
- armLive(chatId) {
1016
+ armLive(chatId, waitForStart = false) {
908
1017
  const generation = ++this.liveGeneration;
909
- void this.pollLive(chatId, generation);
1018
+ void this.pollLive(chatId, generation, waitForStart ? 10 : 0);
910
1019
  }
911
- async pollLive(chatId, generation) {
1020
+ async pollLive(chatId, generation, startRetries) {
912
1021
  if (this.stopped || this.chatId !== chatId || generation !== this.liveGeneration)
913
1022
  return;
914
1023
  const live = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/live-stream`, undefined);
@@ -918,6 +1027,13 @@ export class AgentTui {
918
1027
  return;
919
1028
  const data = live.data;
920
1029
  if (data.active !== true) {
1030
+ if (startRetries > 0) {
1031
+ await new Promise((resolve) => {
1032
+ setTimeout(resolve, this.options.liveIntervalMs ?? 1000);
1033
+ });
1034
+ await this.pollLive(chatId, generation, startRetries - 1);
1035
+ return;
1036
+ }
921
1037
  if (this.status === '运行中')
922
1038
  this.status = '';
923
1039
  this.touch();
@@ -939,10 +1055,43 @@ export class AgentTui {
939
1055
  await new Promise((resolve) => {
940
1056
  setTimeout(resolve, wait);
941
1057
  });
942
- await this.pollLive(chatId, generation);
1058
+ await this.pollLive(chatId, generation, 0);
1059
+ }
1060
+ async refreshGoal(chatId) {
1061
+ const response = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/goal`, undefined);
1062
+ this.goal = response.status === 200 && response.data && typeof response.data === 'object'
1063
+ ? (response.data.goal ?? null)
1064
+ : null;
1065
+ }
1066
+ async refreshLoops(chatId) {
1067
+ const response = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/loops`, undefined);
1068
+ this.loops = response.status === 200 && response.data && typeof response.data === 'object'
1069
+ ? (response.data.loops ?? [])
1070
+ : [];
1071
+ }
1072
+ async reloadMessages(chatId) {
1073
+ const response = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/messages`, undefined);
1074
+ if (response.status !== 200)
1075
+ return;
1076
+ const records = response.data.messages ?? [];
1077
+ this.todos = [];
1078
+ this.lines = historyRows(records).map((row) => {
1079
+ if (row.kind !== 'tool')
1080
+ return { kind: row.kind, text: row.text };
1081
+ const next = todosFromAction(row.action.tool, row.action.arguments, row.action.result);
1082
+ if (next)
1083
+ this.todos = next;
1084
+ return toolLine(row.action);
1085
+ });
1086
+ this.touch();
943
1087
  }
944
- async showList(method, requestPath, key, format, empty) {
1088
+ async showList(method, requestPath, key, format, empty, unavailable) {
945
1089
  const listed = await this.client.request(method, requestPath, undefined);
1090
+ if (listed.status !== 200) {
1091
+ this.panel = [`${unavailable} (${listed.status})`];
1092
+ this.touch();
1093
+ return;
1094
+ }
946
1095
  const rows = listed.status === 200 && listed.data && typeof listed.data === 'object'
947
1096
  ? listed.data[key]
948
1097
  : [];
@@ -959,6 +1108,11 @@ export class AgentTui {
959
1108
  return;
960
1109
  }
961
1110
  const listed = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(this.chatId)}/tasks`, undefined);
1111
+ if (listed.status !== 200) {
1112
+ this.panel = [`后台任务不可用 (${listed.status})`];
1113
+ this.touch();
1114
+ return;
1115
+ }
962
1116
  const rows = listed.status === 200 && listed.data && typeof listed.data === 'object'
963
1117
  ? listed.data.tasks ?? []
964
1118
  : [];
@@ -1058,11 +1212,24 @@ export class AgentTui {
1058
1212
  }
1059
1213
  async decide(kind) {
1060
1214
  const request = this.approval;
1061
- if (!request)
1215
+ if (!request || this.approvalSubmitting)
1062
1216
  return;
1063
- this.approval = null;
1217
+ this.approvalSubmitting = true;
1064
1218
  this.touch();
1065
- await this.client.decideApproval(request.requestId, kind);
1219
+ try {
1220
+ const accepted = await this.client.decideApproval(request.requestId, kind);
1221
+ if (!accepted)
1222
+ throw new Error('approval unavailable');
1223
+ if (this.approval?.requestId === request.requestId)
1224
+ this.approval = null;
1225
+ }
1226
+ catch (error) {
1227
+ this.status = `审批失败:${error instanceof Error ? error.message : String(error)}`;
1228
+ }
1229
+ finally {
1230
+ this.approvalSubmitting = false;
1231
+ this.touch();
1232
+ }
1066
1233
  }
1067
1234
  touch() {
1068
1235
  if (this.stopped)
@@ -1070,6 +1237,12 @@ export class AgentTui {
1070
1237
  this.options.onChange?.();
1071
1238
  }
1072
1239
  exit() {
1240
+ if (this.loops.length > 0 && !this.loopExitArmed) {
1241
+ this.loopExitArmed = true;
1242
+ this.status = '活动 Loop 会随本进程停止;再按一次退出';
1243
+ this.touch();
1244
+ return;
1245
+ }
1073
1246
  this.stopped = true;
1074
1247
  this.turnAbort?.abort();
1075
1248
  this.options.onExit();
@@ -1144,6 +1317,15 @@ function taskStatus(status) {
1144
1317
  return '等待';
1145
1318
  return status ?? '';
1146
1319
  }
1320
+ function isSkillFacadeCommand(text) {
1321
+ if (/^\/goal(?:\s|$)/.test(text)) {
1322
+ return !/^\/goal(?:\s+(?:edit|pause|resume|complete|clear)(?:\s|$)|\s*$)/.test(text);
1323
+ }
1324
+ if (/^\/loop(?:\s|$)/.test(text)) {
1325
+ return !/^\/loop\s+(?:list|stop)(?:\s|$)/.test(text);
1326
+ }
1327
+ return false;
1328
+ }
1147
1329
  async function editWithEditor(text) {
1148
1330
  const editor = process.env.VISUAL || process.env.EDITOR;
1149
1331
  if (!editor)
@@ -1,8 +1,13 @@
1
+ import { createCli } from '../cli.js';
1
2
  import { runTui } from './run.js';
2
3
  const queue = [];
3
4
  const waiters = [];
4
5
  let closed = false;
5
6
  let decided = () => { };
7
+ let goal = null;
8
+ let loops = [];
9
+ const proactiveMessages = [];
10
+ let liveActive = false;
6
11
  function push(item) {
7
12
  const waiter = waiters.shift();
8
13
  if (waiter)
@@ -19,10 +24,78 @@ const client = {
19
24
  return { status: 200, data: { model: 'demo-model' } };
20
25
  if (method === 'POST' && requestPath === '/api/v2/chats/new')
21
26
  return { status: 200, data: { chatId: 'chat-1' } };
27
+ if (method === 'GET' && requestPath.endsWith('/goal'))
28
+ return { status: 200, data: { goal } };
29
+ if (method === 'GET' && requestPath.endsWith('/loops'))
30
+ return { status: 200, data: { loops } };
31
+ if (method === 'DELETE' && requestPath.includes('/loops/')) {
32
+ const id = requestPath.split('/').pop();
33
+ loops = loops.filter((loop) => loop.id !== id);
34
+ push({ channel: 'loop-changed', payload: { chatId: 'chat-1', loops } });
35
+ return { status: 200, data: { success: true } };
36
+ }
37
+ if (method === 'GET' && requestPath.endsWith('/live-stream')) {
38
+ return {
39
+ status: 200,
40
+ data: liveActive ? { active: true, content: 'goal wake running' } : { active: false },
41
+ };
42
+ }
43
+ if (method === 'GET' && requestPath.endsWith('/messages')) {
44
+ return {
45
+ status: 200,
46
+ data: {
47
+ messages: proactiveMessages,
48
+ },
49
+ };
50
+ }
22
51
  return { status: 200, data: {} };
23
52
  },
24
53
  async *stream(_requestPath, body, signal) {
25
54
  const message = String(body.message ?? '');
55
+ if (message.startsWith('/goal ')) {
56
+ goal = { objective: message.slice('/goal '.length), phase: 'active', turns: 1 };
57
+ push({ channel: 'goal-changed', payload: { chatId: 'chat-1', goal } });
58
+ yield { type: 'content', content: 'goal accepted' };
59
+ setTimeout(() => {
60
+ liveActive = true;
61
+ push({
62
+ channel: 'chat-turn-started',
63
+ payload: { chatId: 'chat-1', trigger: 'goal', sourceId: 'goal-1' },
64
+ });
65
+ setTimeout(() => {
66
+ liveActive = false;
67
+ proactiveMessages.push({
68
+ role: 'user',
69
+ content: '<objective>hidden</objective>',
70
+ messageMetadata: JSON.stringify({
71
+ internal: true,
72
+ trigger: 'goal',
73
+ sourceId: 'goal-1',
74
+ }),
75
+ }, { role: 'assistant', content: 'goal wake completed' });
76
+ goal = { ...goal, turns: 2 };
77
+ push({
78
+ channel: 'chat-turn-finished',
79
+ payload: { chatId: 'chat-1', trigger: 'goal', status: 'completed' },
80
+ });
81
+ }, 250);
82
+ }, 100);
83
+ return;
84
+ }
85
+ if (message.startsWith('/loop ')) {
86
+ const finite = message.includes('until complete');
87
+ const id = finite ? 'loop-finite' : 'loop-recurring';
88
+ loops = [{
89
+ id,
90
+ prompt: finite ? 'monitor demo until complete' : 'recurring demo',
91
+ intervalSeconds: 1,
92
+ }];
93
+ push({ channel: 'loop-changed', payload: { chatId: 'chat-1', loops } });
94
+ yield { type: 'content', content: `loop armed ${id}` };
95
+ if (finite)
96
+ scheduleFiniteLoopWake(id, 1);
97
+ return;
98
+ }
26
99
  if (message.includes('hang')) {
27
100
  yield { type: 'content', content: 'running' };
28
101
  await new Promise((resolve) => signal.addEventListener('abort', () => resolve()));
@@ -65,6 +138,48 @@ const client = {
65
138
  waiter(null);
66
139
  },
67
140
  };
68
- await runTui({ client, product: 'Demo' });
69
- await client.close();
70
- process.exit(0);
141
+ function scheduleFiniteLoopWake(id, attempt) {
142
+ setTimeout(() => {
143
+ liveActive = true;
144
+ push({
145
+ channel: 'chat-turn-started',
146
+ payload: { chatId: 'chat-1', trigger: 'loop', sourceId: id },
147
+ });
148
+ setTimeout(() => {
149
+ liveActive = false;
150
+ proactiveMessages.push({
151
+ role: 'user',
152
+ content: 'hidden loop wake',
153
+ messageMetadata: JSON.stringify({
154
+ internal: true,
155
+ trigger: 'loop',
156
+ sourceId: id,
157
+ }),
158
+ }, {
159
+ role: 'assistant',
160
+ content: attempt === 1 ? 'loop monitoring pending' : 'loop reached terminal state',
161
+ });
162
+ if (attempt === 2) {
163
+ loops = [];
164
+ push({ channel: 'loop-changed', payload: { chatId: 'chat-1', loops } });
165
+ }
166
+ push({
167
+ channel: 'chat-turn-finished',
168
+ payload: { chatId: 'chat-1', trigger: 'loop', sourceId: id, status: 'completed' },
169
+ });
170
+ if (attempt === 1)
171
+ scheduleFiniteLoopWake(id, 2);
172
+ }, 200);
173
+ }, 150);
174
+ }
175
+ const defaultCli = process.argv.includes('--default-cli');
176
+ const code = defaultCli
177
+ ? await createCli({
178
+ argv: [],
179
+ createClient: async () => client,
180
+ installSignals: false,
181
+ })
182
+ : await runTui({ client, product: 'Demo' });
183
+ if (!defaultCli)
184
+ await client.close();
185
+ process.exit(code);
@@ -66,11 +66,16 @@ function clip(text) {
66
66
  export function historyRows(messages) {
67
67
  const rows = [];
68
68
  for (const message of inTimeOrder(messages)) {
69
+ const meta = parseMeta(message.messageMetadata);
69
70
  if (message.role === 'user') {
71
+ if (meta.internal === true) {
72
+ const trigger = meta.trigger === 'loop' ? 'Loop 触发' : '目标续跑';
73
+ rows.push({ kind: 'tree', text: trigger });
74
+ continue;
75
+ }
70
76
  rows.push({ kind: 'user', text: message.content ?? '' });
71
77
  continue;
72
78
  }
73
- const meta = parseMeta(message.messageMetadata);
74
79
  const timeline = Array.isArray(meta.timeline) ? meta.timeline : null;
75
80
  if (timeline) {
76
81
  let sawText = false;
@@ -20,11 +20,21 @@ export declare class OpenTuiView {
20
20
  private bodyKey;
21
21
  private overlayKey;
22
22
  private picksKey;
23
- private readonly syntax;
23
+ private chatListOpen;
24
+ private transcriptPinned;
25
+ private transcriptScroll;
26
+ private revealId;
27
+ private restoreTranscript;
28
+ private readonly onFrame;
24
29
  constructor(renderer: CliRenderer);
25
30
  scrollPage(direction: -1 | 1): void;
26
31
  scrollEdge(edge: 'top' | 'bottom'): void;
27
32
  apply(screen: TuiScreen): void;
33
+ /** Transcript stays pinned to the bottom. The chat list follows the selected row instead. */
34
+ private followChats;
35
+ private atBottom;
36
+ private maxScrollTop;
37
+ private viewportRows;
28
38
  private paintOverlay;
29
39
  private paintPicks;
30
40
  private replaceBody;
package/dist/tui/view.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxRenderable, MarkdownRenderable, ScrollBoxRenderable, SyntaxStyle, TextRenderable, dim as dimText, reverse, t, } from '@opentui/core';
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
4
  import { approvalLines, attachmentText, bannerLines, chatText, composerRows, draftHint, footerText, helpLines, pickText, readOnlyText, reasoningBlock, toolBlock, } from './screen.js';
@@ -34,16 +34,31 @@ export class OpenTuiView {
34
34
  bodyKey = '';
35
35
  overlayKey = '';
36
36
  picksKey = '';
37
- syntax;
37
+ chatListOpen = false;
38
+ transcriptPinned = true;
39
+ transcriptScroll = 0;
40
+ revealId = null;
41
+ restoreTranscript = false;
42
+ onFrame = () => {
43
+ if (this.restoreTranscript) {
44
+ this.restoreTranscript = false;
45
+ this.body.stickyScroll = true;
46
+ const max = this.maxScrollTop();
47
+ this.body.scrollTop = this.transcriptPinned ? max : Math.min(this.transcriptScroll, max);
48
+ return;
49
+ }
50
+ if (!this.revealId)
51
+ return;
52
+ const id = this.revealId;
53
+ this.revealId = null;
54
+ this.body.scrollChildIntoView(id);
55
+ };
38
56
  constructor(renderer) {
39
57
  this.renderer = renderer;
40
- try {
41
- this.syntax = SyntaxStyle.create();
42
- }
43
- catch (error) {
44
- void error;
45
- this.syntax = null;
46
- }
58
+ renderer.on('frame', this.onFrame);
59
+ renderer.once('destroy', () => {
60
+ renderer.off('frame', this.onFrame);
61
+ });
47
62
  const shell = new BoxRenderable(renderer, {
48
63
  width: '100%',
49
64
  height: '100%',
@@ -181,12 +196,44 @@ export class OpenTuiView {
181
196
  this.body.scrollChildIntoView(`line-${screen.searchFocus}`);
182
197
  this.paintOverlay(screen);
183
198
  this.paintPicks(screen);
199
+ this.followChats(screen);
184
200
  const key = bodyKey(screen);
185
201
  if (key === this.bodyKey)
186
202
  return;
187
203
  this.bodyKey = key;
188
204
  this.replaceBody(screen);
189
205
  }
206
+ /** Transcript stays pinned to the bottom. The chat list follows the selected row instead. */
207
+ followChats(screen) {
208
+ const chats = screen.chats;
209
+ if (chats && !this.chatListOpen) {
210
+ this.transcriptPinned = this.atBottom();
211
+ this.transcriptScroll = this.body.scrollTop;
212
+ this.body.stickyScroll = false;
213
+ this.body.scrollTop = 0;
214
+ this.chatListOpen = true;
215
+ }
216
+ else if (!chats && this.chatListOpen) {
217
+ this.chatListOpen = false;
218
+ this.revealId = null;
219
+ this.restoreTranscript = true;
220
+ this.body.stickyScroll = false;
221
+ }
222
+ if (!chats)
223
+ return;
224
+ const index = chats.findIndex((chat) => chat.selected);
225
+ this.revealId = index < 0 ? null : index === 0 ? 'chat-heading' : `chat-${index}`;
226
+ }
227
+ atBottom() {
228
+ return this.body.scrollTop >= this.maxScrollTop() - 1;
229
+ }
230
+ maxScrollTop() {
231
+ return Math.max(0, this.body.scrollHeight - this.viewportRows());
232
+ }
233
+ viewportRows() {
234
+ const height = this.body.viewport.height;
235
+ return typeof height === 'number' ? height : 0;
236
+ }
190
237
  paintOverlay(screen) {
191
238
  const lines = screen.approval
192
239
  ? approvalLines(screen.approval)
@@ -247,9 +294,10 @@ export class OpenTuiView {
247
294
  return;
248
295
  }
249
296
  if (screen.chats) {
250
- this.body.add(text(renderer, dim, '会话'));
251
- for (const chat of screen.chats) {
297
+ this.body.add(text(renderer, dim, '会话', 'chat-heading'));
298
+ screen.chats.forEach((chat, index) => {
252
299
  const row = new BoxRenderable(renderer, {
300
+ id: `chat-${index}`,
253
301
  width: '100%',
254
302
  height: 1,
255
303
  backgroundColor: chat.selected ? selected : canvas,
@@ -257,11 +305,11 @@ export class OpenTuiView {
257
305
  });
258
306
  row.add(text(renderer, chat.selected ? ink : dim, chatText(chat)));
259
307
  this.body.add(row);
260
- }
308
+ });
261
309
  return;
262
310
  }
263
311
  screen.lines.forEach((line, index) => {
264
- this.body.add(transcriptRow(renderer, line, this.syntax, `line-${index}`));
312
+ this.body.add(transcriptRow(renderer, line, `line-${index}`));
265
313
  });
266
314
  for (const line of childLines(screen.children ?? []))
267
315
  this.body.add(text(renderer, tool, line));
@@ -284,7 +332,7 @@ function bodyKey(screen) {
284
332
  children: screen.children ?? [],
285
333
  });
286
334
  }
287
- function transcriptRow(renderer, line, syntax, id) {
335
+ function transcriptRow(renderer, line, id) {
288
336
  if (line.kind === 'reasoning') {
289
337
  const card = new BoxRenderable(renderer, {
290
338
  id,
@@ -316,20 +364,46 @@ function transcriptRow(renderer, line, syntax, id) {
316
364
  if (line.kind === 'user') {
317
365
  return text(renderer, user, `user ${line.text ?? ''}`, id);
318
366
  }
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
- });
367
+ if (line.kind === 'assistant') {
368
+ return markdown(renderer, line.text ?? '', id);
329
369
  }
330
370
  const plain = plainMarkdown(line.text ?? '');
331
371
  return text(renderer, ink, plain.length > 0 ? plain : ' ', id);
332
372
  }
373
+ function markdown(renderer, source, id) {
374
+ const box = new BoxRenderable(renderer, {
375
+ id,
376
+ width: '100%',
377
+ flexDirection: 'column',
378
+ });
379
+ let fenced = false;
380
+ for (const raw of source.replace(/\r\n/g, '\n').split('\n')) {
381
+ if (raw.trim().startsWith('```')) {
382
+ fenced = !fenced;
383
+ continue;
384
+ }
385
+ if (fenced) {
386
+ box.add(text(renderer, tool, raw.length > 0 ? raw : ' '));
387
+ continue;
388
+ }
389
+ const content = plainMarkdown(raw);
390
+ if (/^#{1,6}\s+/.test(raw))
391
+ box.add(text(renderer, user, t `${bold(content)}`));
392
+ else if (/^\s*>/.test(raw))
393
+ box.add(text(renderer, dim, t `${italic(content.replace(/^\s*>\s?/, ''))}`));
394
+ else if (/\*\*[^*\n]+\*\*/.test(raw))
395
+ box.add(text(renderer, ink, t `${bold(content)}`));
396
+ else if (/~~[^~\n]+~~/.test(raw))
397
+ box.add(text(renderer, ink, t `${strikethrough(content)}`));
398
+ else if (/\[[^\]\n]+\]\([^)]+\)/.test(raw))
399
+ box.add(text(renderer, user, t `${underline(content)}`));
400
+ else if (/`[^`\n]+`/.test(raw))
401
+ box.add(text(renderer, tool, content));
402
+ else
403
+ box.add(text(renderer, ink, content.length > 0 ? content : ' '));
404
+ }
405
+ return box;
406
+ }
333
407
  function text(renderer, fg, content = '', id) {
334
408
  return new TextRenderable(renderer, { ...(id ? { id } : {}), content, fg, wrapMode: 'word' });
335
409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-cli",
3
- "version": "0.6.62",
3
+ "version": "0.6.64",
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.62",
42
- "@steerable/agent-protocol": "0.6.62",
43
- "@steerable/agent-shell": "0.6.62"
41
+ "@steerable/agent-client": "0.6.64",
42
+ "@steerable/agent-protocol": "0.6.64",
43
+ "@steerable/agent-shell": "0.6.64"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.10.5",
@@ -51,7 +51,9 @@
51
51
  "gen": "echo agent-cli",
52
52
  "build": "tsc -p tsconfig.json",
53
53
  "test": "vitest run",
54
- "lint": "tsc -p tsconfig.json --noEmit",
54
+ "test:tui": "vitest run tests/tui.test.ts tests/tui-framework.test.ts tests/tui-edge-cases.test.ts tests/tui-open-renderer.test.ts tests/tui-platform.test.ts tests/tui-case-coverage.test.ts",
55
+ "test:tui:pty": "vitest run tests/tui-tmux.test.ts",
56
+ "lint": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
55
57
  "check:drift": "echo ok"
56
58
  }
57
59
  }