@yanglaofish/fishtools 0.2.0 → 0.3.0

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.
Files changed (48) hide show
  1. package/README.md +43 -17
  2. package/manual/README.md +43 -17
  3. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  4. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  5. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  6. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  7. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  8. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  9. package/manual/docs/plugin-development.md +26 -6
  10. package/manual/docs/releases.md +19 -1
  11. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  12. package/manual/docs/roadmap.md +5 -0
  13. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
  14. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  15. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  16. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  17. package/manual/docs/verification.md +83 -0
  18. package/manual/packages/core/src/application.js +211 -0
  19. package/manual/packages/core/src/engine.js +93 -0
  20. package/manual/packages/core/src/host.js +2 -0
  21. package/manual/packages/core/src/runtime.js +135 -0
  22. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  23. package/manual/packages/core/src/terminal/session.js +438 -0
  24. package/manual/packages/core/src/terminal/view.js +283 -0
  25. package/manual/tests/application.test.js +30 -0
  26. package/node_modules/@mytools/core/src/application.js +2 -1
  27. package/node_modules/@mytools/core/src/contracts.js +1 -1
  28. package/node_modules/@mytools/core/src/documentation.js +1 -1
  29. package/node_modules/@mytools/core/src/engine.js +1 -1
  30. package/node_modules/@mytools/core/src/host.js +2 -0
  31. package/node_modules/@mytools/core/src/markdown-log.js +1 -1
  32. package/node_modules/@mytools/core/src/process.js +14 -1
  33. package/node_modules/@mytools/core/src/terminal/model.js +1 -1
  34. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  35. package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
  36. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  37. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  38. package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
  39. package/node_modules/@mytools/core/src/terminal/session.js +118 -50
  40. package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
  41. package/node_modules/@mytools/core/src/terminal/view.js +125 -28
  42. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  43. package/node_modules/@mytools/core/src/validation.js +3 -1
  44. package/node_modules/@mytools/core/src/version.js +1 -1
  45. package/node_modules/@mytools/core/src/workflows.js +11 -0
  46. package/node_modules/@mytools/pack/index.js +4 -2
  47. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +766 -765
  48. package/package.json +1 -1
@@ -0,0 +1,283 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
5
+ import { MarkdownLogViewer } from './log-viewer.js';
6
+ import { RunViewer, mouseOn, mouseOff, mouseWheel } from './run-viewer.js';
7
+ import {RunFeed} from './run-feed.js';
8
+ import { logo } from './presentation.js';
9
+ import { VERSION } from '../version.js';
10
+ import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
11
+
12
+ export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'PROMPT_CANCELLED'});
13
+ // 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
14
+ function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
15
+
16
+ // Pi 的 @ 模糊搜索依赖额外的 fd 程序。这里按目录补全,离线目标机无需安装 fd。
17
+ export class FileAutocomplete extends CombinedAutocompleteProvider{
18
+ constructor(commands,baseDir,workflows=[]){super(commands,baseDir);this.baseDir=baseDir;this.workflows=workflows;}
19
+ getSuggestions(lines,line,col){
20
+ const run=lines[line].slice(0,col).match(/^\/run\s+([^@"\s]*)$/);
21
+ if(run){
22
+ const query=run[1].toLowerCase(),items=this.workflows.filter(flow=>`${flow.id} ${flow.title??''}`.toLowerCase().includes(query)).map(flow=>({value:flow.id,label:flow.title??flow.id,description:flow.id,workflow:true}));
23
+ return items.length?{prefix:run[1],items}:null;
24
+ }
25
+ const prefix=lines[line].slice(0,col).match(/(?:^|\s)(@"[^"\n]*|@[^\s]*)$/)?.[1];
26
+ if(!prefix)return super.getSuggestions(lines,line,col);
27
+ const raw=prefix.slice(1).replace(/^"/,'').replaceAll('\\','/');
28
+ const expanded=raw.startsWith('~/')?path.join(homedir(),raw.slice(2)):raw;
29
+ const absolute=path.resolve(this.baseDir,expanded||'.');
30
+ const directory=!raw||raw.endsWith('/')?absolute:path.dirname(absolute);
31
+ const query=!raw||raw.endsWith('/')?'':path.basename(absolute).toLowerCase();
32
+ try{
33
+ const items=readdirSync(directory,{withFileTypes:true}).filter(e=>e.name.toLowerCase().startsWith(query)).map(e=>{
34
+ let dir=e.isDirectory();if(e.isSymbolicLink()){try{dir=statSync(path.join(directory,e.name)).isDirectory();}catch{}}
35
+ const head=raw.slice(0,raw.lastIndexOf('/')+1),file=head+e.name+(dir?'/':'');
36
+ return {value:`@${/[\s"]/.test(file)?JSON.stringify(file):file}`,label:safe(e.name)+(dir?'/':''),description:dir?'目录':'文件',directory:dir};
37
+ }).sort((a,b)=>(b.description==='目录')-(a.description==='目录')||a.label.localeCompare(b.label)).slice(0,100);
38
+ return items.length?{prefix,items}:null;
39
+ }catch{return null;}
40
+ }
41
+ getForceFileSuggestions(lines,line,col){return this.getSuggestions(lines,line,col);}
42
+ applyCompletion(lines,line,col,item,prefix){
43
+ if(item.workflow){const next=[...lines],before=lines[line].slice(0,col-prefix.length),text=item.value+' ';next[line]=before+text+lines[line].slice(col);return {lines:next,cursorLine:line,cursorCol:before.length+text.length};}
44
+ if(!prefix.startsWith('@'))return super.applyCompletion(lines,line,col,item,prefix);
45
+ const before=lines[line].slice(0,col-prefix.length);
46
+ let after=lines[line].slice(col);if(after.startsWith('"'))after=after.slice(1);
47
+ const completed=item.value+(item.directory?'':' '),next=[...lines];next[line]=before+completed+after;
48
+ return {lines:next,cursorLine:line,cursorCol:before.length+completed.length-(item.directory&&item.value.endsWith('"')?1:0)};
49
+ }
50
+ }
51
+
52
+ export class TerminalView{
53
+ constructor({baseDir,previewLabel,terminal=new ProcessTerminal()}={}){
54
+ this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];this.runViews=new Map();
55
+ this.historyOffset=0;this.commandHistory=[];this.commandHistoryIndex=null;this.feed=new RunFeed(()=>this.refreshTranscript());
56
+ const originalStart=terminal.start.bind(terminal);
57
+ terminal.start=(input,resize)=>originalStart(data=>{
58
+ if(this.homeInput&&!this.runViewer&&!this.logViewer){
59
+ if(this.browseCommands(data))return;
60
+ if(matchesKey(data,'ctrl+o')){this.feed.expanded=!this.feed.expanded;this.historyOffset=0;this.refreshTranscript();return;}
61
+ const wheel=mouseWheel(data),page=Math.max(1,Math.floor(this.terminal.rows/2));
62
+ if(wheel||matchesKey(data,'pageUp')||matchesKey(data,'pageDown')){
63
+ this.historyOffset=Math.max(0,this.historyOffset+(wheel?-wheel*3:matchesKey(data,'pageUp')?page:-page));this.tui.requestRender();return;
64
+ }
65
+ }
66
+ if(matchesKey(data,'f4')&&this.homeInput&&this.pending&&!this.runViewer&&!this.logViewer){
67
+ // 快捷入口不提交或丢弃当前草稿,返回首页时继续编辑。
68
+ this.homeDraft=this.editor.getText();this.pending.resolve('/logs');return;
69
+ }
70
+ if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
71
+ if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
72
+ // 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
73
+ if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
74
+ this.pending.reject(cancelled());return;
75
+ }
76
+ input(data);
77
+ },resize);
78
+ this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
79
+ this.hint.render=width=>['',fitLine(' '+palette.muted(this.homeInput?(width<45?`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出')),width)];
80
+ const renderTranscript=this.transcript.render.bind(this.transcript);
81
+ this.headingLines=(width,mark='FISHtools')=>{
82
+ const room=Math.max(1,width-2),lines=[''];
83
+ const summary=(this.summaryText??'').split('\n');
84
+ const workspace=summary.find(line=>line.startsWith('当前工作区:'));
85
+ const details=summary.filter(line=>line!==workspace&&!line.startsWith('[F4')&&!line.startsWith('欢迎使用'));
86
+ if(mark===logo){
87
+ const side=[palette.text('FISHtools')+' '+palette.muted(`v${VERSION}`),palette.text('欢迎使用 · 插件编排工作台'),palette.muted(details[0]??'')];
88
+ lines.push(...logo.split('\n').map((line,i)=>' '+palette.brand(line)+' '+side[i]));
89
+ }else lines.push(' '+palette.brand('FISH')+palette.text('tools')+' '+palette.muted(`v${VERSION}`));
90
+ if(previewLabel)lines.push(' '+palette.brand(safe(previewLabel)));
91
+ if(workspace){
92
+ const label='当前工作区:',folder=shortenPath(workspace.slice(label.length),Math.max(1,room-visibleWidth(label)));
93
+ lines.push(' '+palette.muted(label)+palette.text(folder));
94
+ }
95
+ lines.push(' '+palette.muted('位置:')+palette.brand('主面板')+(this.commandLocation?palette.muted(' › ')+palette.text(this.commandLocation):''));
96
+ if(this.activeRuns?.())lines.push(' '+palette.muted(`${this.activeRuns()} 个运行中 · /logs 查看详情`));
97
+ lines.push('');return lines.map(line=>fitLine(line,width));
98
+ };
99
+ // 先保证输入与菜单可见,空间充足才展示三行字标。
100
+ this.header.render=width=>{
101
+ const full=this.headingLines(width,logo),other=renderTranscript(width).length+this.prompt.render(width).length+this.hint.render(width).length;
102
+ let lines=width>=55&&this.terminal.rows>=18&&full.length+other<=this.terminal.rows&&!this.feed.entries.length?full:this.headingLines(width);
103
+ const budget=Math.max(0,this.terminal.rows-this.prompt.render(width).length-this.hint.render(width).length-1);
104
+ if(lines.length>budget)lines=lines.filter(line=>safe(line).trim());
105
+ if(lines.length>budget)lines=lines.slice(0,budget);
106
+ return lines;
107
+ };
108
+ this.transcript.render=width=>{
109
+ const lines=renderTranscript(width),budget=Math.max(0,this.terminal.rows-this.header.render(width).length-this.prompt.render(width).length-this.hint.render(width).length);
110
+ if(lines.length<=budget){this.historyOffset=0;return lines;}
111
+ if(!budget)return [];
112
+ if(this.feed.entries.length){
113
+ const count=Math.max(0,budget-1),max=Math.max(0,lines.length-count);this.historyOffset=Math.min(this.historyOffset,max);
114
+ const end=lines.length-this.historyOffset,start=Math.max(0,end-count);
115
+ return [...lines.slice(start,end),fitLine(' '+palette.muted(`PgUp/PgDn/滚轮 查看过程 · ${this.historyOffset?'历史位置':'跟随最新'}`),width)];
116
+ }
117
+ const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
118
+ return [...excerpt,fitLine(' '+palette.muted('… /help 完整帮助 · /logs 运行日志'),width)];
119
+ };
120
+ this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
121
+ }
122
+ start(){
123
+ if(this.started)return;this.started=true;
124
+ // 独立屏幕避免全屏阅读与主终端的滚动历史混在一起。
125
+ this.terminal.write('\x1b[?1049h\x1b[2J\x1b[H');this.tui.start();
126
+ }
127
+ browseCommands(data){
128
+ const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
129
+ // 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
130
+ if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getText().includes('\n')||!this.commandHistory.length)return false;
131
+ const current=this.commandHistoryIndex??this.commandHistory.length;
132
+ const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
133
+ if(next===current)return true;
134
+ if(this.commandHistoryIndex===null)this.commandHistoryDraft=this.editor.getText();
135
+ this.commandHistoryIndex=next;
136
+ this.editor.setText(next===this.commandHistory.length?this.commandHistoryDraft:this.commandHistory[next]);
137
+ if(next===this.commandHistory.length)this.commandHistoryIndex=null;
138
+ this.tui.requestRender();return true;
139
+ }
140
+ async close(){
141
+ if(this.closed)return;this.closed=true;
142
+ this.feed.close();
143
+ // Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
144
+ await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
145
+ if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
146
+ if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
147
+ }
148
+ summary(text){this.summaryText=safe(text);this.tui.requestRender();}
149
+ beginCommand(text){
150
+ const name=text.trim().split(/\s+/)[0].replace(/^\//,'');
151
+ const labels={workflows:'工作流',run:'运行工作流',plugin:'插件管理',plugins:'插件管理',config:'工具配置',pack:'打包',logs:'执行记录',runs:'执行记录',tools:'工具',help:'帮助',exit:'退出'};
152
+ this.commandLocation=labels[name]??'/'+name;this.historyOffset=0;
153
+ this.clearMessages();this.feed.message('› '+safe(text));
154
+ }
155
+ endCommand(){
156
+ this.commandLocation=null;this.tui.requestRender();
157
+ }
158
+ transcriptText(){return [...this.logs,this.feed.text()].filter(Boolean).join('\n\n');}
159
+ refreshTranscript(){
160
+ const width=this.terminal.columns,previous=this.transcript.render===undefined?0:new Text(safe(this.lastTranscript??''),1,0).render(width).length;
161
+ const text=safe(this.transcriptText());
162
+ if(this.historyOffset>0)this.historyOffset=Math.max(0,this.historyOffset+new Text(text,1,0).render(width).length-previous);
163
+ this.lastTranscript=text;this.transcript.setText(text.split('\n').map(line=>line.startsWith('✗ ')?palette.error(line):/^[›✓●] /.test(line)?palette.brand(line):line.startsWith('↩ ')?palette.muted(line):line).join('\n'));this.tui.requestRender();
164
+ }
165
+ trackRun(options){this.feed.track(options);}
166
+ clearMessages(){this.logs=[];this.refreshTranscript();}
167
+ show(message,{kind='message'}={}){
168
+ const text=safe(message).slice(0,6000);
169
+ const formatted=kind==='welcome'?text.split('\n').map((line,i)=>i===0?palette.text(line):palette.muted(line)).join('\n'):text;
170
+ if(kind!=='welcome'){this.feed.message(formatted);return;}
171
+ this.logs.push(formatted);this.logs=this.logs.slice(-6);this.refreshTranscript();
172
+ }
173
+ async viewMarkdown(title,text){
174
+ const previousPending=this.pending??null,previousViewer=this.logViewer;
175
+ const children=this.tui.children;
176
+ let overlay;
177
+ try{
178
+ await new Promise((resolve,reject)=>{
179
+ this.pending={resolve,reject};
180
+ this.logViewer=new MarkdownLogViewer({title:safe(title),text:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
181
+ this.terminal.write(mouseOn);
182
+ // Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
183
+ this.tui.children=[];
184
+ overlay=this.tui.showOverlay(this.logViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});
185
+ this.tui.requestRender(true);
186
+ });
187
+ }finally{
188
+ this.tui.children=children;this.pending=previousPending;this.logViewer=previousViewer;
189
+ overlay?.hide();this.tui.requestRender(true);
190
+ this.terminal.write(mouseOff);
191
+ }
192
+ }
193
+ async observeRun({app,runId,record,flow,workflows=[],initialView}){
194
+ const previousPending=this.pending??null,children=this.tui.children;let overlay,unsubscribe;
195
+ const initial=app.getRun(runId)??record??await app.readRun(runId);
196
+ if(!initial)throw new Error('这次运行记录已不存在');
197
+ try{
198
+ await new Promise((resolve,reject)=>{
199
+ this.pending={resolve,reject};
200
+ this.runViewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:app.catalog?.()??[],terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),requestRender:()=>this.tui.requestRender()});
201
+ Object.assign(this.runViewer,this.runViews.get(runId)??{});
202
+ if(initialView!==undefined){this.runViewer.view=initialView;this.runViewer.offset=0;}
203
+ unsubscribe=app.subscribe(event=>{if(event.runId===runId)this.runViewer?.update(app.getRun(runId));});
204
+ this.tui.children=[];this.terminal.write(mouseOn);
205
+ overlay=this.tui.showOverlay(this.runViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});this.tui.requestRender(true);
206
+ });
207
+ }finally{
208
+ if(this.runViewer){const {view,selected,offset,follow,allLogs,raw}=this.runViewer;this.runViews.set(runId,{view,selected,offset,follow,allLogs,raw});}
209
+ if(this.runViews.size>30)this.runViews.delete(this.runViews.keys().next().value);
210
+ unsubscribe?.();this.runViewer=null;this.tui.children=children;this.pending=previousPending;overlay?.hide();this.terminal.write(mouseOff);this.tui.requestRender(true);
211
+ }
212
+ }
213
+ async ask(message,component){
214
+ fit(component);
215
+ const title=new Text(palette.brand('› ')+palette.text(safe(message)),1,0);
216
+ const renderTitle=title.render.bind(title);
217
+ title.render=width=>[fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...renderTitle(width)];
218
+ if(message.startsWith('fst >'))title.render=()=>[];
219
+ this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
220
+ try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
221
+ if(this.homeInput&&text.trim()&&this.commandHistory.at(-1)!==text.trim()){
222
+ this.commandHistory.push(text.trim());if(this.commandHistory.length>100)this.commandHistory.shift();
223
+ }
224
+ resolve(text);
225
+ };component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
226
+ finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
227
+ }
228
+ input(message,{value='',commands=[],files=true,workflows=[]}={}){
229
+ const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
230
+ const renderEditor=editor.render.bind(editor);
231
+ editor.render=width=>{
232
+ if(width<8)return renderEditor(width);
233
+ const inner=width-4,lines=renderEditor(inner);let closed=false;
234
+ return lines.map((line,i)=>{
235
+ if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
236
+ if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
237
+ if(closed)return ' '+line;
238
+ return ' '+palette.border('│')+line+' '.repeat(Math.max(0,inner-visibleWidth(line)))+palette.border('│');
239
+ });
240
+ };
241
+ this.homeInput=message.startsWith('fst >');
242
+ this.commandHistoryIndex=null;
243
+ if(this.homeInput)editor.onChange=text=>{
244
+ if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
245
+ };
246
+ if(this.homeInput)this.terminal.write(mouseOn);
247
+ this.editor=editor;editor.setText(this.homeInput?(this.homeDraft??value):value);
248
+ if(this.homeInput)this.homeDraft=undefined;
249
+ if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir,workflows));
250
+ return this.ask(message,editor);
251
+ }
252
+ async select(message,items){
253
+ if(!items.length)throw new Error('当前没有可选择的项目');
254
+ const list=new SelectList(items.map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined})),8,theme);
255
+ list.render=width=>{
256
+ const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+2;
257
+ list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
258
+ const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
259
+ const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
260
+ for(let i=start;i<end;i++){
261
+ const item=list.filteredItems[i],selected=i===list.selectedIndex,available=Math.max(1,width-4);
262
+ const labelWidth=item.description&&width>=56?Math.min(32,Math.floor(available*0.45)):available;
263
+ const label=fitLine(item.label||item.value,labelWidth),prefix=selected?'› ':' ';
264
+ let line=' '+(selected?palette.brand(prefix+label):palette.text(prefix+label));
265
+ if(item.description&&width>=56)line+=' '.repeat(Math.max(2,labelWidth-visibleWidth(label)+2))+palette.muted(fitLine(item.description,Math.max(1,available-labelWidth-2)));
266
+ lines.push(fitLine(line,width));
267
+ }
268
+ if(start>0||end<list.filteredItems.length)lines.push(palette.muted(` ${list.selectedIndex+1} / ${list.filteredItems.length}`));
269
+ return lines.length?lines:[palette.muted(' 没有匹配项')];
270
+ };
271
+ const value=await this.ask(message,list);
272
+ if(this.commandLocation){
273
+ const item=items.find(item=>item.value===value);
274
+ if(item&&value!=='back'&&value!=='cancel')this.feed.message(`· ${safe(item.label??item.value)}`);
275
+ }
276
+ return value;
277
+ }
278
+ async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
279
+ busy(message,cancel){
280
+ this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(palette.text(safe(message)),1,0));this.tui.setFocus(this.prompt);this.tui.requestRender();
281
+ return ()=>{this.cancelRun=null;this.prompt.clear();this.tui.requestRender();};
282
+ }
283
+ }
@@ -4,6 +4,36 @@ import { mkdir, writeFile, readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { createApplication } from '../packages/core/src/application.js';
6
6
  import { temporary, testPlugin, object } from './helpers.js';
7
+ import {validate} from '../packages/core/src/validation.js';
8
+
9
+ test('同 ID schema 隔离编译,保留内嵌引用且不串用旧约束',()=>{
10
+ const schema=type=>({$id:'https://fishtools.test/review/config',type:'object',definitions:{value:{type}},properties:{value:{$ref:'#/definitions/value'}},required:['value']});
11
+ assert.deepEqual(validate(schema('string'),{value:'old'},'旧配置'),{value:'old'});
12
+ assert.deepEqual(validate(schema('integer'),{value:2},'新配置'),{value:2});
13
+ assert.throws(()=>validate(schema('integer'),{value:'old'},'新配置'),/integer/);
14
+ });
15
+
16
+ test('外部带 $id 配置插件可升级,后续安装与卸载不被旧 schema 阻塞',async t=>{
17
+ const root=await temporary(t),projectDir=path.join(root,'project');
18
+ const fixture=async(version)=>{
19
+ const directory=path.join(root,'plugin-'+version);await mkdir(directory);
20
+ await writeFile(path.join(directory,'package.json'),JSON.stringify({name:'review-schema-plugin',version,type:'module',main:'index.js'}));
21
+ const config={$id:'https://fishtools.test/external/config',type:'object',properties:{version:{type:'string',const:version,default:version}}};
22
+ await writeFile(path.join(directory,'index.js'),`export default {id:'review-schema-plugin',version:${JSON.stringify(version)},config:${JSON.stringify(config)},commands:{'review.version':{action:'version'}},actions:{version:{input:{type:'object'},output:{type:'object'},async run(_, {config}){return config;}}}};`);
23
+ return directory;
24
+ };
25
+ const first=await fixture('1.0.0'),second=await fixture('1.0.1'),extra=path.join(root,'unrelated');await mkdir(extra);
26
+ await writeFile(path.join(extra,'package.json'),JSON.stringify({name:'review-unrelated',version:'1.0.0',type:'module',main:'index.js'}));
27
+ await writeFile(path.join(extra,'index.js'),"export default {id:'review-unrelated',version:'1.0.0'};");
28
+ const app=await createApplication({projectDir,plugins:[]});t.after(()=>app.close());
29
+ await app.installPlugin(first);await app.installPlugin(extra);await app.removePlugin('review-unrelated');await app.installPlugin(second);
30
+ assert.equal((await (await app.startCommand('review.version')).done).output.version,'1.0.1');
31
+ await assert.rejects(app.configurePlugin('review-schema-plugin',{version:'1.0.0'}),/配置/);
32
+ await app.close();
33
+ const restarted=await createApplication({projectDir});t.after(()=>restarted.close());
34
+ assert.equal((await (await restarted.startCommand('review.version')).done).output.version,'1.0.1');
35
+ await restarted.removePlugin('review-schema-plugin');assert.equal(restarted.listPlugins().length,0);
36
+ });
7
37
 
8
38
  test('运行快照独立于之后保存,状态与日志持久化',async t=>{
9
39
  const projectDir=await temporary(t);
@@ -34,6 +34,7 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
34
34
  listCommands:()=>host.commands(),
35
35
  listWorkflows:()=>store.list(),
36
36
  async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
37
+ async removeWorkflow(id){if(project.readOnly)throw new FlowError('只读项目不能删除工作流');return store.remove(id);},
37
38
  checkWorkflow:id=>store.resolve([id],host),
38
39
  subscribe(fn){emitter.on('event',fn);return ()=>emitter.off('event',fn);},
39
40
  async startRun(id,input={}) {
@@ -176,7 +177,7 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
176
177
  const external=[];
177
178
  for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
178
179
  host=await build(external);
179
- for(const name of ['startRun','startCommand','startWeb','saveWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
180
+ for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
180
181
  const original=app[name],isRun=['startRun','startCommand'].includes(name);
181
182
  app[name]=async(...args)=>{
182
183
  if(closing)throw new FlowError('应用已关闭');
@@ -4,7 +4,7 @@ import {conditionOperators} from './control.js';
4
4
  export const builtinCommands=[
5
5
  ['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
6
6
  ['steps','设置步骤参数、排序或删除'],['outputs','设置最终输出'],['save','保存工作流'],
7
- ['run','运行工作流,可附带 @参数.json'],['runs','观察运行中的步骤、输入输出与实时日志'],['logs','查看运行日志与保留设置'],['plugins','管理插件'],['config','设置插件配置'],
7
+ ['run','运行工作流,可附带 @参数.json'],['runs','运行记录:选择任意一次执行,查看进度、结果和日志'],['logs','查看运行日志'],['plugin','管理插件'],['plugins','管理插件(兼容旧入口)'],['config','配置 Fishtools'],['tools','Web 与离线文档'],
8
8
  ['pack','选择工作流打包'],['web','启动 Web 工作台'],['docs','阅读离线开发与使用文档'],['help','查看操作说明'],['exit','退出终端工作台'],
9
9
  ].map(([name,description])=>({name,description}));
10
10
 
@@ -12,7 +12,7 @@ const topics=[
12
12
  ['readme','Fishtools 使用说明','README.md'],
13
13
  ];
14
14
  // 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
15
- const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','packages/sdk','packages/core/src/host.js','packages/core/LICENSE','tests/application.test.js'];
15
+ const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','packages/sdk','packages/core/src/host.js','packages/core/src/runtime.js','packages/core/src/engine.js','packages/core/src/application.js','packages/core/src/terminal/view.js','packages/core/src/terminal/run-feed.js','packages/core/src/terminal/session.js','packages/core/LICENSE','tests/application.test.js'];
16
16
  const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
17
17
  const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
18
18
 
@@ -57,7 +57,7 @@ export async function runWorkflow({store,host,id,input={},signal,onEvent=()=>{},
57
57
  let output;
58
58
  if(step.action)output=await host.invoke(step.action,args,{
59
59
  ...context,signal:currentSignal,runId,stepId:step.id,callPath,workflow:w,
60
- log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...base,message,data}),
60
+ log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...(meta.audience==='user'?{audience:'user'}:{}),...base,message,data}),
61
61
  });
62
62
  else if(step.workflow)output=await execute(store.childId(step.workflow),args,callPath,currentSignal,reportFailure);
63
63
  else if(step.branch){
@@ -16,6 +16,8 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
16
16
  const commandNames=new Set(),commands=new Map();
17
17
  function bindContext(context){
18
18
  const bound={...services,...context},execute=bound.runProcess;
19
+ // 面向用户的输出与诊断日志共用持久化通道,只区分默认可见性。
20
+ bound.print=(message,data)=>Promise.resolve(bound.log?.(message,data,{audience:'user'}));
19
21
  if(typeof execute==='function')bound.runProcess=(file,args,options={})=>{
20
22
  const signal=context.signal&&options.signal?AbortSignal.any([context.signal,options.signal]):context.signal??options.signal;
21
23
  return execute(file,args,{...options,signal,onOutput:async({stream,message})=>{
@@ -32,7 +32,7 @@ export function markdownHeader(run){
32
32
  }
33
33
 
34
34
  export function markdownEvent(event){
35
- let output=`### ${names[event.type]??'事件'}\n\n`;
35
+ let output=`### ${event.type==='log'&&event.audience==='user'?'用户输出':names[event.type]??'事件'}\n\n`;
36
36
  const lines=[`时间:${event.time}`];
37
37
  for(const [key,label] of [['callPath','调用路径'],['action','动作'],['plugin','插件'],['command','命令'],['stream','输出流'],['durationMs','耗时(毫秒)']]){
38
38
  if(event[key]!==undefined)lines.push(`${label}:${event[key]}`);
@@ -3,11 +3,24 @@ import treeKill from 'tree-kill';
3
3
  import which from 'which';
4
4
  import { FlowError } from '@mytools/sdk';
5
5
  import { StringDecoder } from 'node:string_decoder';
6
+ import path from 'node:path';
6
7
 
7
8
  export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024,onOutput}={}) {
8
9
  if(signal?.aborted) throw new FlowError('操作已取消',{code:'CANCELLED'});
9
10
  let executable;
10
- try {executable=await which(file,{path:env?.PATH??process.env.PATH});}
11
+ try {
12
+ // 不改变宿主 cwd;程序路径和 PATH 中相对目录都以子进程 cwd 为基准。
13
+ const directory=path.resolve(cwd??process.cwd()),windows=process.platform==='win32';
14
+ const hasPath=path.isAbsolute(file)||file.includes('/')||(windows&&file.includes('\\'));
15
+ const search=hasPath?[path.resolve(directory,file)]:[
16
+ ...(windows?[directory]:[]),...(env?.PATH??(windows?env?.Path:undefined)??process.env.PATH??'').split(path.delimiter),
17
+ ].map(entry=>path.resolve(directory,entry.replace(/^"(.*)"$/,'$1'),file));
18
+ for(const candidate of search){
19
+ executable=await which(candidate,{nothrow:true,pathExt:env?.PATHEXT??process.env.PATHEXT});
20
+ if(executable)break;
21
+ }
22
+ if(!executable)throw Object.assign(new Error(`not found: ${file}`),{code:'ENOENT'});
23
+ }
11
24
  catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
12
25
  const child=execa(executable,args,{cwd,env,reject:false,windowsHide:true,maxBuffer,timeout:0,shell:false});
13
26
  let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
@@ -15,7 +15,7 @@ export function parseCommand(text){
15
15
  }
16
16
  if(quote)throw new Error('文件路径的引号未闭合');
17
17
  if(started)args.push(token);
18
- if(!args[0]?.startsWith('/'))throw new Error('请输入 / 选择操作,例如 /workflows 或 /run');
18
+ if(!args[0]?.startsWith('/'))throw Object.assign(new Error('这不是斜杠命令。输入 / 选择操作;执行工作流请用 /run 工作流ID。'),{code:'COMMAND_INPUT'});
19
19
  args[0]=args[0].slice(1);return args;
20
20
  }
21
21
 
@@ -0,0 +1,17 @@
1
+ import {builtinCommands} from '../contracts.js';
2
+
3
+ const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
4
+ // 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
5
+ export function commandMenu(plugins,commands){
6
+ const entries=builtinCommands.filter(command=>visible.includes(command.name)).map(command=>({...command,description:`[内置] ${command.description}`}));
7
+ const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name)]);
8
+ const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
9
+ for(const plugin of groups){
10
+ const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
11
+ let name=/^[a-zA-Z]/.test(leaf)?leaf:`plugin-${leaf}`;
12
+ if(groups.filter(p=>p.id.split('/').at(-1)===leaf).length>1)name=plugin.id.replace(/^@/,'').replace(/[^\w.-]/g,'-');
13
+ while(used.has(name))name='plugin-'+name;
14
+ used.add(name);entries.push({name,plugin:plugin.id,description:`[插件] ${plugin.title??plugin.id} · ${plugin.id}`});
15
+ }
16
+ return entries;
17
+ }
@@ -11,8 +11,8 @@ export function gettingStarted(flows,{readOnly=false}={}){
11
11
  return [
12
12
  sample?`试运行 /run ${sample.id}\n${sample.description??sample.title??'按提示填写参数,执行后查看结果。'}`:'当前工作区还没有工作流。',
13
13
  '',
14
- `/workflows 工作流 /plugins 插件`,
15
- `${readOnly?'只读运行包': '/new 新建'} /runs 运行观察 /help 帮助`,
14
+ `/workflows ${readOnly?'浏览工作流':'管理工作流'} /plugin 插件 /config 工具配置`,
15
+ `/logs 回看执行记录、输入输出与日志 /help 帮助`,
16
16
  ].join('\n');
17
17
  }
18
18
 
@@ -37,6 +37,6 @@ export function pluginDetails(plugin,actions,commands){
37
37
  '## 环境声明',
38
38
  ...[plugin,...actions].flatMap(item=>Object.entries(item.environments??{}).map(([name,e])=>`- ${item.id} / ${name}:${({host:'使用宿主机环境',bundled:'随包携带',tool:'使用 Fishtools 自带环境'})[e.source]??e.source}`)),
39
39
  '环境与平台由插件作者声明;具体执行结果请在 /logs 查看。',
40
- '编排用法:/new 新建工作流 → /add 选择上述能力 → 填写参数 → /save → /run。',
40
+ '编排用法:/workflows 新建工作流 → 添加步骤并选择上述能力 → 填写参数 → 保存 → 运行。也可在主面板用 /run 选择已保存的工作流。',
41
41
  ].join('\n\n');
42
42
  }
@@ -0,0 +1,78 @@
1
+ import {readableValue,readableError,readableTimeline} from './run-presentation.js';
2
+ import {runStatuses} from './run-viewer.js';
3
+
4
+ // 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
5
+ export class RunFeed{
6
+ constructor(refresh){this.refresh=refresh;this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;}
7
+ track({app,runId,title,input}){
8
+ if(this.runs.has(runId))return;
9
+ const record=app.getRun(runId),item={app,record,title,input,count:0,ended:false,steps:new Map(),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
10
+ this.runs.set(runId,item);
11
+ this.entries.push({type:'start',item});
12
+ if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
13
+ const item=this.runs.get(event.runId);if(!item||item.app!==app)return;
14
+ this.update(item,event);this.release(app);
15
+ if(event.type==='log'&&!item.ended)this.scheduleRefresh();
16
+ else this.flushRefresh();
17
+ }));
18
+ this.update(item);this.release(app);this.refresh();
19
+ }
20
+ scheduleRefresh(){
21
+ if(this.refreshTimer)return;
22
+ this.refreshTimer=setTimeout(()=>{this.refreshTimer=null;this.refresh();},100);
23
+ }
24
+ flushRefresh(){
25
+ if(this.refreshTimer){clearTimeout(this.refreshTimer);this.refreshTimer=null;}
26
+ this.refresh();
27
+ }
28
+ update(item,event){
29
+ if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type))item.record.events.push(event);
30
+ else{
31
+ const record=item.app.getRun(item.record.id);if(!record)return;
32
+ item.record={...record,events:[...(record.events??[])]};
33
+ }
34
+ const {record}=item,events=record.events.slice(item.count);item.count=record.events.length;
35
+ for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
36
+ if(events.length){
37
+ const last=this.entries.at(-1);
38
+ if(last?.type==='events'&&last.item===item)last.events.push(...events);
39
+ else this.entries.push({type:'events',item,events});
40
+ }
41
+ if(record.status!=='running'&&!item.ended){item.ended=true;this.entries.push({type:'result',item});}
42
+ }
43
+ release(app){
44
+ if([...this.runs.values()].some(item=>item.app===app&&!item.ended))return;
45
+ this.subscriptions.get(app)?.();this.subscriptions.delete(app);
46
+ }
47
+ message(text){this.entries.push({type:'message',text});this.refresh();}
48
+ compactEvents(entry,seen){
49
+ const {item}=entry,rows=[];
50
+ for(const event of entry.events){
51
+ const key=`${item.record.id}\0${event.callPath}`;
52
+ if(event.type.startsWith('step.')&&!seen.has(key)){
53
+ seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
54
+ const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
55
+ const name=action?.title??latest.action??latest.stepId??latest.callPath;
56
+ rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
57
+ }
58
+ if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
59
+ if(event.type==='step.failed'&&![...item.steps.values()].some(step=>step.type==='step.failed'&&step.callPath?.startsWith(event.callPath+'/')))rows.push(`✗ ${event.callPath}\n${readableError(event.error??{message:event.message})}`);
60
+ if(event.type==='step.stopped'||event.type==='step.skipped')rows.push(`${event.callPath}:${event.reason??event.message??''}`);
61
+ }
62
+ return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
63
+ }
64
+ text(){const seen=new Set();return this.entries.map(entry=>{
65
+ if(entry.type==='message')return entry.text;
66
+ const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
67
+ if(entry.type==='start')return [
68
+ `› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
69
+ `状态:${runStatuses[record.status]??record.status}`,
70
+ this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
71
+ ].filter(Boolean).join('\n');
72
+ if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
73
+ return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
74
+ this.expanded&&record.output!==undefined?readableValue(record.output):'',record.error&&(this.expanded||![...entry.item.steps.values()].some(step=>step.type==='step.failed'))?readableError(record.error,{details:this.expanded}):'',record.reason??'',
75
+ ].filter(Boolean).join('\n');
76
+ }).filter(Boolean).join('\n\n');}
77
+ close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
78
+ }
@@ -0,0 +1,60 @@
1
+ // 阅读层只处理展示;原始事件、输入输出与错误仍保留在运行记录中。
2
+ export function readableValue(value,indent=''){
3
+ if(value===undefined)return '暂无记录';
4
+ if(value===null)return 'null';
5
+ if(typeof value!=='object')return String(value);
6
+ const entries=Array.isArray(value)?value.map((v,i)=>[`[${i+1}]`,v]):Object.entries(value);
7
+ if(!entries.length)return Array.isArray(value)?'(空列表)':'(空对象)';
8
+ return entries.map(([key,item])=>{
9
+ const nested=item!==null&&typeof item==='object';
10
+ return `${indent}${key}:${nested?'\n'+readableValue(item,indent+' '):String(item).replaceAll('\n','\n'+indent+' ')}`;
11
+ }).join('\n');
12
+ }
13
+ export function readableError(error,{details=false}={}){
14
+ if(!error)return '';
15
+ const chain=[];for(let current=error;current;current=current.cause)chain.push(current);
16
+ const program=chain.find(e=>e.details?.executable)?.details.executable;
17
+ const code=chain.find(e=>e.code==='ENOENT')?.code??error.code;
18
+ const summary=[`原因:${code==='ENOENT'&&program?'未找到所需程序':error.message??'未知错误'}`,error.plugin&&`插件:${error.plugin}`,code&&`错误码:${code}`,program&&`程序:${program}`,code==='ENOENT'&&'请检查程序是否安装、路径是否正确以及 PATH 配置。'].filter(Boolean).join('\n');
19
+ if(!details)return summary;
20
+ const diagnostic=chain.flatMap((item,index)=>[
21
+ `${index===0?'错误':'上游原因 '+index}:${item.message??'未知错误'}`,
22
+ item.code&&`错误码:${item.code}`,
23
+ item.details!==undefined&&`细节:\n${readableValue(item.details)}`,
24
+ item.stack&&`调用栈:\n${item.stack}`,
25
+ ].filter(Boolean));
26
+ return `${summary}\n\n${diagnostic.join('\n')}`;
27
+ }
28
+ const eventNames={'step.started':'步骤开始','step.succeeded':'步骤成功','step.failed':'步骤失败','step.cancelled':'步骤取消','step.skipped':'步骤跳过','step.stopped':'步骤停止','run.started':'运行开始','run.succeeded':'运行成功','run.failed':'运行失败','run.cancelled':'运行取消','run.stopped':'提前结束'};
29
+ const time=value=>{const date=new Date(value);return Number.isFinite(+date)?date.toLocaleTimeString('zh-CN',{hour12:false}):'--:--:--';};
30
+ export function readableTimeline(events,{callPath,values=false}={}){
31
+ const rows=[];let pending;
32
+ const push=(event,message)=>{
33
+ const prefix=`${time(event.time)} [${event.callPath??'运行'}${event.stream?' · '+event.stream:''}]`;
34
+ const lines=String(message).replaceAll('\r\n','\n').replaceAll('\r','\n').split('\n');
35
+ if(lines.at(-1)==='')lines.pop();
36
+ for(const line of lines)rows.push(`${prefix} ${line}`);
37
+ };
38
+ const flush=()=>{if(pending){if(pending.text)push(pending.event,pending.text);pending=null;}};
39
+ for(const event of events??[]){
40
+ if(callPath&&event.callPath!==callPath)continue;
41
+ if(event.type==='log'&&event.stream){
42
+ const key=`${event.callPath}\0${event.stream}`;
43
+ if(pending?.key!==key){flush();pending={key,event,text:''};}
44
+ pending.text+=event.message??'';
45
+ const end=pending.text.lastIndexOf('\n');
46
+ if(end>=0){push(pending.event,pending.text.slice(0,end+1));pending.text=pending.text.slice(end+1);pending.event=event;}
47
+ continue;
48
+ }
49
+ if(event.type==='log'){flush();push(event,`${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);continue;}
50
+ if(eventNames[event.type]){
51
+ const detail=event.error?readableError(event.error,{details:values}):event.reason??event.message;
52
+ flush();push(event,`${eventNames[event.type]}${event.action?' · '+event.action:''}${event.durationMs!==undefined?` · ${event.durationMs} ms`:''}${detail?'\n'+detail:''}`);
53
+ if(values&&event.type.startsWith('step.')){
54
+ if(event.input!==undefined&&Object.keys(event.input??{}).length)push(event,'输入\n'+readableValue(event.input));
55
+ if(event.output!==undefined)push(event,'输出\n'+readableValue(event.output));
56
+ }
57
+ }
58
+ }
59
+ flush();return rows.join('\n')||'等待执行记录。插件通过 log 或 runProcess 输出的内容会显示在这里。';
60
+ }