@yanglaofish/fishtools 0.5.0 → 0.5.2
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 +4 -2
- package/manual/README.md +4 -2
- package/manual/docs/deployment.md +2 -2
- package/manual/docs/modules.md +2 -0
- package/manual/docs/plugin-development.md +3 -1
- package/manual/docs/releases.md +44 -6
- package/manual/docs/superpowers/plans/2026-10-13-command-shortcuts.md +107 -0
- package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +70 -0
- package/manual/docs/superpowers/reviews/2026-10-13-command-shortcuts-review.md +63 -0
- package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +1 -1
- package/manual/docs/superpowers/specs/2026-10-13-command-shortcuts-design.md +56 -0
- package/manual/docs/superpowers/specs/2026-10-13-issue-4-workflow-menu.md +28 -0
- package/manual/docs/terminal-commands.md +64 -0
- package/manual/docs/verification.md +39 -1
- package/manual/packages/core/README.md +3 -1
- package/manual/packages/core/package.json +1 -0
- package/manual/packages/core/src/application.js +126 -20
- package/manual/packages/core/src/export-snapshot.js +20 -1
- package/manual/packages/core/src/version.js +1 -1
- package/manual/packages/fst-cli/README.md +8 -1
- package/manual/packages/fst-cli/package.json +1 -0
- package/manual/packages/fst-cli/src/terminal/command-menu.js +186 -0
- package/manual/packages/fst-cli/src/terminal/navigation.js +4 -3
- package/manual/packages/fst-cli/src/terminal/run-feed.js +24 -9
- package/manual/packages/fst-cli/src/terminal/session.js +35 -4
- package/manual/packages/fst-cli/src/terminal/view.js +43 -8
- package/manual/packages/fst-cli/src/terminal/workflow-menu.js +1 -6
- package/manual/packages/fst-devtools/src/documentation.js +2 -1
- package/manual/packages/fst-logs/README.md +11 -0
- package/manual/packages/fst-logs/package.json +2 -0
- package/manual/packages/fst-logs/src/run-logs.js +8 -23
- package/manual/packages/fst-logs/src/run-preview.js +18 -0
- package/manual/packages/fst-logs/src/step-summaries.js +28 -0
- package/manual/packages/fst-platform/README.md +17 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/process.js +12 -8
- package/manual/packages/fst-platform/src/shell.js +58 -0
- package/manual/packages/fst-plugin/src/contracts.js +6 -2
- package/manual/packages/fst-plugin/src/host.js +7 -4
- package/manual/packages/fst-plugin/src/runtime.js +8 -5
- package/manual/packages/fst-workspace/README.md +3 -0
- package/manual/packages/fst-workspace/package.json +1 -0
- package/manual/packages/fst-workspace/src/shortcuts.js +119 -0
- package/manual/packages/pack/index.js +1 -1
- package/manual/plugins/commands/LICENSE +21 -0
- package/manual/plugins/commands/README.md +41 -0
- package/manual/plugins/commands/index.js +94 -0
- package/manual/plugins/commands/package.json +17 -0
- package/node_modules/@mytools/commands/LICENSE +21 -0
- package/node_modules/@mytools/commands/README.md +41 -0
- package/node_modules/@mytools/commands/index.js +94 -0
- package/node_modules/@mytools/commands/package.json +17 -0
- package/node_modules/@mytools/core/README.md +3 -1
- package/node_modules/@mytools/core/package.json +1 -0
- package/node_modules/@mytools/core/src/application.js +126 -20
- package/node_modules/@mytools/core/src/export-snapshot.js +20 -1
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/README.md +8 -1
- package/node_modules/@mytools/fst-cli/package.json +1 -0
- package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +186 -0
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +4 -3
- package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +24 -9
- package/node_modules/@mytools/fst-cli/src/terminal/session.js +35 -4
- package/node_modules/@mytools/fst-cli/src/terminal/view.js +43 -8
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +1 -6
- package/node_modules/@mytools/fst-devtools/src/documentation.js +2 -1
- package/node_modules/@mytools/fst-logs/README.md +11 -0
- package/node_modules/@mytools/fst-logs/package.json +2 -0
- package/node_modules/@mytools/fst-logs/src/run-logs.js +8 -23
- package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
- package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
- package/node_modules/@mytools/fst-platform/README.md +17 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/process.js +12 -8
- package/node_modules/@mytools/fst-platform/src/shell.js +58 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +6 -2
- package/node_modules/@mytools/fst-plugin/src/host.js +7 -4
- package/node_modules/@mytools/fst-plugin/src/runtime.js +8 -5
- package/node_modules/@mytools/fst-workspace/README.md +3 -0
- package/node_modules/@mytools/fst-workspace/package.json +1 -0
- package/node_modules/@mytools/fst-workspace/src/shortcuts.js +119 -0
- package/node_modules/@mytools/pack/index.js +1 -1
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1212 -1187
- package/package.json +3 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {isCommandName} from '@mytools/fst-plugin/contracts';
|
|
3
|
+
import {editValue} from './schema.js';
|
|
4
|
+
|
|
5
|
+
export async function runShortcut(session,entry,args=[]){
|
|
6
|
+
if(!entry.available)throw new Error(`快捷名称 /${entry.name} 已失效:${entry.reason??'目标不存在'}`);
|
|
7
|
+
const {app}=session,target=entry.target;
|
|
8
|
+
if(target.command){
|
|
9
|
+
const command=app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
|
|
10
|
+
if(!command)throw new Error(`快捷名称 /${entry.name} 的目标命令不存在`);
|
|
11
|
+
return session.pluginCommand({...command,name:entry.name,targetName:command.name,shortcutInput:entry.input??{}},args);
|
|
12
|
+
}
|
|
13
|
+
const action=app.catalog().find(action=>action.id===target.action);
|
|
14
|
+
if(!action)throw new Error(`快捷名称 /${entry.name} 的目标动作不存在`);
|
|
15
|
+
const plugin=action.plugin??action.id.slice(0,action.id.lastIndexOf(':'));
|
|
16
|
+
return session.pluginCommand({...action,name:entry.name,plugin,action:action.id.slice(plugin.length+1),actionId:action.id,shortcutInput:entry.input??{}},args);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function parameters(session,value={}){
|
|
20
|
+
const result=structuredClone(value),{ui}=session;
|
|
21
|
+
for(;;){
|
|
22
|
+
const choice=await ui.select('命令参数(脚本通过 FST_INPUT_参数名 环境变量读取)',[
|
|
23
|
+
...Object.entries(result).map(([name,field])=>({value:`field:${name}`,label:field.title??name,description:`${name} · ${field.type}`})),
|
|
24
|
+
{value:'new',label:'添加参数'},{value:'back',label:'完成参数设置'},
|
|
25
|
+
]);
|
|
26
|
+
if(choice==='back')return result;
|
|
27
|
+
const existing=choice.startsWith('field:')?choice.slice(6):undefined;
|
|
28
|
+
if(existing){
|
|
29
|
+
const operation=await ui.select(`参数 ${existing}`,[{value:'edit',label:'编辑'},{value:'remove',label:'删除'},{value:'back',label:'返回'}]);
|
|
30
|
+
if(operation==='back')continue;
|
|
31
|
+
if(operation==='remove'){delete result[existing];continue;}
|
|
32
|
+
}
|
|
33
|
+
const name=existing??(await ui.input('参数名(英文字母、数字、下划线,不能以数字开头)',{files:false})).trim();
|
|
34
|
+
if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)||['__proto__','constructor','prototype'].includes(name)){ui.show('参数名无效,请重新填写。');continue;}
|
|
35
|
+
if(!existing&&Object.hasOwn(result,name)){ui.show('参数名已经存在。');continue;}
|
|
36
|
+
const title=await ui.input('参数显示名称',{value:result[name]?.title??name,files:false});
|
|
37
|
+
const type=await ui.select('参数类型',[{value:'string',label:'文本'},{value:'number',label:'数字'},{value:'boolean',label:'是 / 否'}]);
|
|
38
|
+
const field={title,type,required:await ui.confirm('这个参数必须填写吗?')};
|
|
39
|
+
if(await ui.confirm('设置默认值吗?'))field.default=await editValue(ui,{type},result[name]?.default,{baseDir:session.baseDir,required:true,label:'默认值'});
|
|
40
|
+
result[name]=field;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function editPreset(session,id,current){
|
|
45
|
+
const {ui,app}=session,preset=structuredClone(current??{shell:'auto'});
|
|
46
|
+
preset.title=(await ui.input('常用命令名称',{value:preset.title??'',files:false})).trim();
|
|
47
|
+
preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script??'',files:false,multiline:true});
|
|
48
|
+
const action=`@mytools/commands:${id}`;
|
|
49
|
+
const oldName=Object.keys(app.project.shortcuts??{}).find(name=>app.project.shortcuts[name].action===action);
|
|
50
|
+
let shortcut=oldName??(isCommandName(preset.title)?preset.title:'');
|
|
51
|
+
for(;;){
|
|
52
|
+
const choice=await ui.select('常用命令设置',[
|
|
53
|
+
{value:'save',label:'保存',description:shortcut?`快捷执行 /${shortcut}`:'只注册工作流动作'},
|
|
54
|
+
{value:'title',label:'修改名称'},{value:'script',label:'编辑多行命令'},
|
|
55
|
+
{value:'shortcut',label:'快捷名称',description:shortcut||'未设置'},
|
|
56
|
+
{value:'shell',label:'命令解释器',description:preset.shell??'auto'},
|
|
57
|
+
{value:'timeout',label:'超时时间',description:preset.timeout===0?'不限时':`${(preset.timeout??30000)/1000} 秒`},
|
|
58
|
+
{value:'parameters',label:'参数',description:`${Object.keys(preset.parameters??{}).length} 项,可不设置`},
|
|
59
|
+
{value:'root',label:'Linux root 权限要求',description:preset.requireRoot?'需要 root':'按当前用户权限执行'},
|
|
60
|
+
{value:'back',label:'取消,返回'},
|
|
61
|
+
]);
|
|
62
|
+
if(choice==='back')return;
|
|
63
|
+
if(choice==='title')preset.title=(await ui.input('常用命令名称',{value:preset.title,files:false})).trim();
|
|
64
|
+
else if(choice==='script')preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script,files:false,multiline:true});
|
|
65
|
+
else if(choice==='shortcut')shortcut=(await ui.input('快捷名称(支持中文,不含空格或 /;留空不设置)',{value:shortcut,files:false})).trim();
|
|
66
|
+
else if(choice==='shell')preset.shell=await ui.select('命令解释器',[
|
|
67
|
+
{value:'auto',label:'按系统选择(Windows PowerShell / Linux sh)'},
|
|
68
|
+
{value:'powershell',label:'Windows PowerShell'},{value:'pwsh',label:'PowerShell 7(pwsh)'},
|
|
69
|
+
{value:'sh',label:'sh'},{value:'bash',label:'bash'},
|
|
70
|
+
]);
|
|
71
|
+
else if(choice==='timeout'){
|
|
72
|
+
const seconds=Number(await ui.input('超时时间(秒,0 表示不限时)',{value:String((preset.timeout??30000)/1000),files:false}));
|
|
73
|
+
if(!Number.isFinite(seconds)||!Number.isSafeInteger(seconds*1000)||seconds<0||seconds*1000>2147483647){ui.show('超时时间无效。');continue;}
|
|
74
|
+
preset.timeout=seconds*1000;
|
|
75
|
+
}else if(choice==='parameters')preset.parameters=await parameters(session,preset.parameters);
|
|
76
|
+
else if(choice==='root')preset.requireRoot=await ui.confirm('这个命令在 Linux 上必须使用 root 吗?执行前会检查,不会自动提权。');
|
|
77
|
+
else if(choice==='save'){
|
|
78
|
+
const next={...app.project.commandPresets,[id]:preset},shortcuts=structuredClone(app.project.shortcuts??{});
|
|
79
|
+
if(oldName)delete shortcuts[oldName];
|
|
80
|
+
if(shortcut){
|
|
81
|
+
if(Object.hasOwn(shortcuts,shortcut)){ui.show(`快捷名称 /${shortcut} 已存在,请选择其他名称。`);continue;}
|
|
82
|
+
shortcuts[shortcut]={...(oldName?app.project.shortcuts[oldName]:{}),action};
|
|
83
|
+
}
|
|
84
|
+
try{
|
|
85
|
+
const result=await session.withMaintenance(options=>app.configureCommandPresets(next,{...options,shortcuts}));
|
|
86
|
+
if(result===null){ui.show('已取消保存。');continue;}
|
|
87
|
+
ui.show(`常用命令已保存,修改前配置已备份。${shortcut?`输入 /${shortcut} 可直接运行。`:''}也可在工作流中添加此动作。`);
|
|
88
|
+
return;
|
|
89
|
+
}catch(error){ui.show(`保存失败:${error.message}`);}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function commandPresetsMenu(session){
|
|
95
|
+
const {app,ui}=session;
|
|
96
|
+
for(;;){
|
|
97
|
+
const choice=await ui.select('常用命令',[
|
|
98
|
+
...(!app.project.readOnly?[{value:'new',label:'注册常用命令'}]:[]),
|
|
99
|
+
...Object.entries(app.project.commandPresets??{}).map(([id,preset])=>({value:`preset:${id}`,label:preset.title,description:id})),
|
|
100
|
+
{value:'back',label:'返回主面板'},
|
|
101
|
+
]);
|
|
102
|
+
if(choice==='back')return;
|
|
103
|
+
if(choice==='new'){session.writable();await editPreset(session,`command-${randomUUID().slice(0,8)}`);continue;}
|
|
104
|
+
const id=choice.slice(7),preset=app.project.commandPresets?.[id];if(!preset)continue;
|
|
105
|
+
const operation=await ui.select(preset.title,[{value:'run',label:'运行'},
|
|
106
|
+
...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除注册项'}]:[]),{value:'back',label:'返回'}]);
|
|
107
|
+
if(operation==='run')await runShortcut(session,{name:preset.title,target:{action:`@mytools/commands:${id}`},input:{},available:true});
|
|
108
|
+
else if(operation==='edit'){session.writable();await editPreset(session,id,preset);}
|
|
109
|
+
else if(operation==='remove'){
|
|
110
|
+
session.writable();if(!await ui.confirm(`删除 ${preset.title}?指向它的快捷名称会显示失效;引用中的工作流需先调整。`))continue;
|
|
111
|
+
const next=structuredClone(app.project.commandPresets);delete next[id];
|
|
112
|
+
try{const result=await session.withMaintenance(options=>app.configureCommandPresets(next,options));if(result!==null)ui.show('注册项已删除,修改前配置已备份。');}
|
|
113
|
+
catch(error){ui.show(`删除失败:${error.message}`);}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function targets(app){
|
|
119
|
+
return [...app.catalog().map(action=>({value:`action:${action.id}`,label:`动作:${action.title??action.id}`,description:action.id})),
|
|
120
|
+
...app.listCommands().map(command=>({value:`command:${command.name}`,label:`命令:/${command.name}`,description:command.description}))];
|
|
121
|
+
}
|
|
122
|
+
function shortcutTarget(app,selected){
|
|
123
|
+
if(selected.startsWith('action:'))return {action:selected.slice(7)};
|
|
124
|
+
const name=selected.slice(8),command=app.listCommands().find(command=>command.name===name);
|
|
125
|
+
if(!command)throw new Error('目标命令已失效,请重新选择');
|
|
126
|
+
return {command:name,plugin:command.plugin};
|
|
127
|
+
}
|
|
128
|
+
async function editShortcut(session,oldName){
|
|
129
|
+
const {app,ui}=session,old=app.project.shortcuts?.[oldName];
|
|
130
|
+
let name=(await ui.input('快捷名称(支持中文,不含空格或 /)',{value:oldName??'',files:false})).trim();
|
|
131
|
+
let target=old?structuredClone(old):{};
|
|
132
|
+
if(!old){
|
|
133
|
+
const choices=targets(app);if(!choices.length){ui.show('当前没有可选择的插件动作或命令。');return;}
|
|
134
|
+
const selected=await ui.select('选择快捷名称指向的能力',choices);
|
|
135
|
+
target=shortcutTarget(app,selected);
|
|
136
|
+
}
|
|
137
|
+
for(;;){
|
|
138
|
+
const choice=await ui.select(`快捷名称 /${name}`,[
|
|
139
|
+
{value:'save',label:'保存'},{value:'name',label:'修改快捷名称'},
|
|
140
|
+
{value:'target',label:'选择目标',description:target.action??target.command},
|
|
141
|
+
{value:'input',label:'设置默认参数',description:'可留空,运行时按需要填写'},
|
|
142
|
+
{value:'back',label:'取消,返回'},
|
|
143
|
+
]);
|
|
144
|
+
if(choice==='back')return;
|
|
145
|
+
if(choice==='name')name=(await ui.input('快捷名称',{value:name,files:false})).trim();
|
|
146
|
+
else if(choice==='target'){
|
|
147
|
+
const selected=await ui.select('选择目标',targets(app));
|
|
148
|
+
target=shortcutTarget(app,selected);
|
|
149
|
+
}else if(choice==='input'){
|
|
150
|
+
const metadata=target.action?app.catalog().find(action=>action.id===target.action):app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
|
|
151
|
+
if(!metadata){ui.show('目标已失效,请先重新选择目标。');continue;}
|
|
152
|
+
target.input=await editValue(ui,metadata.input??{type:'object',properties:{},additionalProperties:false},target.input??{},{baseDir:session.baseDir,required:true,label:'快捷名称默认参数'});
|
|
153
|
+
}else if(choice==='save'){
|
|
154
|
+
const next=structuredClone(app.project.shortcuts??{});if(oldName)delete next[oldName];
|
|
155
|
+
if(Object.hasOwn(next,name)){ui.show('快捷名称已经存在,请选择其他名称。');continue;}
|
|
156
|
+
next[name]=target;
|
|
157
|
+
try{await app.configureShortcuts(next);ui.show('快捷名称已保存,修改前配置已备份。');return;}
|
|
158
|
+
catch(error){ui.show(`保存失败:${error.message}`);}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function shortcutsMenu(session){
|
|
164
|
+
const {app,ui}=session;
|
|
165
|
+
for(;;){
|
|
166
|
+
const entries=app.listShortcuts(),choice=await ui.select('自定义快捷名称',[
|
|
167
|
+
...(!app.project.readOnly?[{value:'new',label:'添加快捷名称'}]:[]),
|
|
168
|
+
...entries.map(entry=>({value:`shortcut:${entry.name}`,label:`/${entry.name}${entry.available?'':' [失效]'}`,description:entry.available?entry.description:entry.reason})),
|
|
169
|
+
{value:'back',label:'返回主面板'},
|
|
170
|
+
]);
|
|
171
|
+
if(choice==='back')return;
|
|
172
|
+
if(choice==='new'){session.writable();await editShortcut(session);continue;}
|
|
173
|
+
const name=choice.slice(9),entry=entries.find(entry=>entry.name===name);if(!entry)continue;
|
|
174
|
+
const operation=await ui.select(`/${name}`,[
|
|
175
|
+
...(entry.available?[{value:'run',label:'运行'}]:[]),
|
|
176
|
+
...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除快捷名称'}]:[]),{value:'back',label:'返回'},
|
|
177
|
+
]);
|
|
178
|
+
if(operation==='run')await runShortcut(session,entry);
|
|
179
|
+
else if(operation==='edit'){session.writable();await editShortcut(session,name);}
|
|
180
|
+
else if(operation==='remove'){
|
|
181
|
+
session.writable();if(!await ui.confirm(`删除快捷名称 /${name}?`))continue;
|
|
182
|
+
const next=structuredClone(app.project.shortcuts??{});delete next[name];
|
|
183
|
+
await app.configureShortcuts(next);ui.show('快捷名称已删除,插件能力仍可使用。');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {builtinCommands} from '@mytools/fst-plugin/contracts';
|
|
2
2
|
|
|
3
|
-
const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
|
|
3
|
+
const visible=['workflows','run','pack','logs','plugin','commands','shortcuts','config','tools','help','exit'];
|
|
4
4
|
// 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
|
|
5
|
-
export function commandMenu(plugins,commands){
|
|
5
|
+
export function commandMenu(plugins,commands,shortcuts=[]){
|
|
6
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)]);
|
|
7
|
+
const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name),...shortcuts.map(entry=>entry.name)]);
|
|
8
|
+
entries.push(...shortcuts.map(entry=>({name:entry.name,shortcut:true,description:`[快捷] ${entry.available?entry.description:`已失效:${entry.reason}`}`})));
|
|
8
9
|
const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
|
|
9
10
|
for(const plugin of groups){
|
|
10
11
|
const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import {readableValue,readableError,readableTimeline} from './run-presentation.js';
|
|
2
2
|
import {runStatuses} from './run-viewer.js';
|
|
3
|
+
import {createStepSummaries} from '@mytools/fst-logs/steps';
|
|
4
|
+
import {previewRun} from '@mytools/fst-logs/preview';
|
|
5
|
+
|
|
6
|
+
const feedRecord=(record,maxBytes)=>{
|
|
7
|
+
const {stepSummaries,stepSummaryBytes,stepsTruncated,...state}=record;
|
|
8
|
+
return previewRun({...state,events:[...(state.events??[])]},{maxValueBytes:Math.min(maxBytes,65536)});
|
|
9
|
+
};
|
|
3
10
|
|
|
4
11
|
// 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
|
|
5
12
|
export class RunFeed{
|
|
6
13
|
constructor(refresh,{maxEvents=1000,maxBytes=1048576,maxRuns=50}={}){Object.assign(this,{refresh,maxEvents,maxBytes,maxRuns});this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;this.dropped=0;}
|
|
7
14
|
track({app,runId,title,input}){
|
|
8
15
|
if(this.runs.has(runId))return;
|
|
9
|
-
const
|
|
16
|
+
const raw=app.getRun(runId),record=feedRecord(raw,this.maxBytes),bounded=previewRun({input},{maxValueBytes:Math.min(this.maxBytes,65536)});
|
|
17
|
+
const item={app,record,title,input:bounded.input,inputTruncated:bounded.inputTruncated,count:0,ended:false,steps:createStepSummaries({maxEvents:this.maxEvents,maxMemoryBytes:this.maxBytes}),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
|
|
18
|
+
for(const summary of raw.stepSummaries??[])item.steps.append(summary);
|
|
10
19
|
this.runs.set(runId,item);
|
|
11
20
|
this.entries.push({type:'start',item});
|
|
12
21
|
if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
|
|
@@ -30,12 +39,13 @@ export class RunFeed{
|
|
|
30
39
|
if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type)){item.record.events.push(event);events=[event];item.count++;}
|
|
31
40
|
else{
|
|
32
41
|
const record=item.app.getRun(item.record.id);if(!record)return;
|
|
33
|
-
item.record=
|
|
42
|
+
item.record=feedRecord(record,this.maxBytes);
|
|
43
|
+
for(const summary of record.stepSummaries??[])item.steps.append(summary);
|
|
34
44
|
events=record.events.filter((e,index)=>e.seq!==undefined?e.seq>item.count:index>=item.count);
|
|
35
45
|
item.count=record.eventCount??record.events.length;
|
|
36
46
|
}
|
|
37
47
|
const {record}=item;
|
|
38
|
-
for(const event of events)
|
|
48
|
+
for(const event of events)item.steps.append(event);
|
|
39
49
|
if(events.length){
|
|
40
50
|
const last=this.entries.at(-1);
|
|
41
51
|
if(last?.type==='events'&&last.item===item)last.events.push(...events);
|
|
@@ -59,7 +69,6 @@ export class RunFeed{
|
|
|
59
69
|
this.entries=this.entries.filter(entry=>!entry.events||entry.events.length);
|
|
60
70
|
for(const item of this.runs.values()){
|
|
61
71
|
item.record.events=this.entries.filter(e=>e.item===item&&e.events).flatMap(e=>e.events);
|
|
62
|
-
while(item.steps.size>this.maxEvents)item.steps.delete(item.steps.keys().next().value);
|
|
63
72
|
}
|
|
64
73
|
const ended=[...this.runs.values()].filter(item=>item.ended);
|
|
65
74
|
for(const item of ended.slice(0,Math.max(0,ended.length-this.maxRuns))){this.runs.delete(item.record.id);this.entries=this.entries.filter(e=>e.item!==item);this.dropped++;}
|
|
@@ -75,10 +84,14 @@ export class RunFeed{
|
|
|
75
84
|
for(const event of entry.events){
|
|
76
85
|
const key=`${item.record.id}\0${event.callPath}`;
|
|
77
86
|
if(event.type.startsWith('step.')&&!seen.has(key)){
|
|
78
|
-
seen.add(key);const latest=item.steps.get(event.callPath)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
seen.add(key);const latest=item.steps.get(event.callPath);
|
|
88
|
+
if(!latest)rows.push(`· ${event.action??event.stepId??event.callPath} · 摘要已省略 · ${event.callPath}`);
|
|
89
|
+
else{
|
|
90
|
+
const action=item.catalog.get(latest.action);
|
|
91
|
+
const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
|
|
92
|
+
const name=action?.title??latest.action??latest.stepId??latest.callPath;
|
|
93
|
+
rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
|
|
94
|
+
}
|
|
82
95
|
}
|
|
83
96
|
if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
|
|
84
97
|
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})}`);
|
|
@@ -86,18 +99,20 @@ export class RunFeed{
|
|
|
86
99
|
}
|
|
87
100
|
return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
|
|
88
101
|
}
|
|
89
|
-
text(){const seen=new Set();return (this.dropped?'
|
|
102
|
+
text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;超出预算的事件和步骤摘要已省略。完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
|
|
90
103
|
if(entry.type==='message')return entry.text;
|
|
91
104
|
const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
|
|
92
105
|
if(entry.type==='start')return [
|
|
93
106
|
`› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
|
|
94
107
|
`状态:${runStatuses[record.status]??record.status}`,
|
|
95
108
|
this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
|
|
109
|
+
this.expanded&&entry.item.inputTruncated?'输入较大,主面板仅显示摘要。':'',
|
|
96
110
|
].filter(Boolean).join('\n');
|
|
97
111
|
if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
|
|
98
112
|
return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
|
|
99
113
|
...(record.warnings??[]).map(warning=>`警告:${warning.message}`),
|
|
100
114
|
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??'',
|
|
115
|
+
this.expanded&&record.outputTruncated?'结果较大,主面板仅显示摘要;完整结果请在 /logs 中按 p 分页阅读。':'',
|
|
101
116
|
].filter(Boolean).join('\n');
|
|
102
117
|
}).filter(Boolean).join('\n\n');}
|
|
103
118
|
close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
|
|
@@ -9,6 +9,8 @@ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
|
|
|
9
9
|
import {readableValue,readableError} from './run-presentation.js';
|
|
10
10
|
import {commandMenu} from './navigation.js';
|
|
11
11
|
import {workflowMenu} from './workflow-menu.js';
|
|
12
|
+
import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
|
|
13
|
+
import {validate} from '@mytools/sdk/schema';
|
|
12
14
|
import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
|
|
13
15
|
|
|
14
16
|
export class TerminalSession{
|
|
@@ -24,7 +26,7 @@ export class TerminalSession{
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
|
|
27
|
-
commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
|
|
29
|
+
commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
|
|
28
30
|
async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
|
|
29
31
|
async mayExit(){
|
|
30
32
|
if(!await this.discard())return false;
|
|
@@ -41,7 +43,7 @@ export class TerminalSession{
|
|
|
41
43
|
while(!this.exiting){
|
|
42
44
|
this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
|
|
43
45
|
let text;
|
|
44
|
-
try{text=await this.ui.input('fst >
|
|
46
|
+
try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
|
|
45
47
|
catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
|
|
46
48
|
if(!text.trim())continue;
|
|
47
49
|
try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
|
|
@@ -57,19 +59,25 @@ export class TerminalSession{
|
|
|
57
59
|
finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
|
|
58
60
|
}
|
|
59
61
|
async executeCommand(text){
|
|
62
|
+
if(text.trimStart().startsWith('!'))return this.shellCommand(text.trimStart().slice(1));
|
|
60
63
|
const [command,...args]=parseCommand(text);
|
|
64
|
+
const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
|
|
65
|
+
if(shortcut)return runShortcut(this,shortcut,args);
|
|
61
66
|
const entry=this.commands().find(c=>c.name===command&&c.plugin);
|
|
62
67
|
if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
|
|
63
68
|
const pluginCommand=this.app.listCommands().find(c=>c.name===command);
|
|
64
69
|
if(pluginCommand)return this.pluginCommand(pluginCommand,args);
|
|
65
70
|
if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
|
|
66
71
|
switch(command){
|
|
72
|
+
case 'commands':return commandPresetsMenu(this);
|
|
73
|
+
case 'shortcuts':return shortcutsMenu(this);
|
|
67
74
|
case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
|
|
68
75
|
'# Fishtools',
|
|
69
76
|
'将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
|
|
70
77
|
`当前工作区:${this.app.projectDir}`,
|
|
71
78
|
`文件路径起点:${this.baseDir}`,
|
|
72
79
|
'## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
|
|
80
|
+
'## 系统命令与快捷名称','输入 ! 后接系统命令可直接执行,输出和状态保留在主面板;/commands 注册常用命令供快捷执行与工作流编排,/shortcuts 给任意插件动作或命令设置中文快捷名称。主输入框和命令编辑框使用 Shift+Enter 换行、Enter 提交,Alt+Enter 为备用换行;支持多行粘贴。系统命令按当前 Shell 语法执行,目标机器需要已安装相应外部程序。',
|
|
73
81
|
'## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
|
|
74
82
|
'## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
|
|
75
83
|
'## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
|
|
@@ -118,14 +126,26 @@ export class TerminalSession{
|
|
|
118
126
|
}
|
|
119
127
|
async pluginCommand(command,args){
|
|
120
128
|
if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
|
|
121
|
-
|
|
129
|
+
let input;
|
|
130
|
+
if(args.length)input=await readRunInput(args[0],this.baseDir);
|
|
131
|
+
else{
|
|
132
|
+
const initial=structuredClone(command.shortcutInput??{});
|
|
133
|
+
const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
|
|
134
|
+
if(command.shortcutInput!==undefined||empty){
|
|
135
|
+
try{input=validate(command.input,initial,command.description??command.name);}
|
|
136
|
+
catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
|
|
137
|
+
}else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
|
|
138
|
+
}
|
|
122
139
|
const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
|
|
123
140
|
const definition=this.app.host.definitions.get(command.plugin);
|
|
141
|
+
if(!definition)throw new Error('目标插件已移除,请重新选择目标后执行');
|
|
124
142
|
const requirement=resolvePrivilegeRequirement(definition,command.action);
|
|
125
143
|
const unknown=requirement.declared&&checkStaticPrivilege(requirement).status==='unknown';
|
|
126
144
|
const allowUnknownPrivileges=unknown?await this.ui.confirm(`/${command.name} 的权限需要运行时核查。若检查结果仍无法确认,是否允许本次继续?\n${requirement.reason??''}\n${requirement.remediation??''}`):false;
|
|
127
145
|
if(unknown&&!allowUnknownPrivileges)return;
|
|
128
|
-
const
|
|
146
|
+
const targetName=command.targetName??command.name;
|
|
147
|
+
if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&¤t.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
|
|
148
|
+
const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
|
|
129
149
|
if(this.ui.trackRun){
|
|
130
150
|
this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
|
|
131
151
|
if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
|
|
@@ -135,6 +155,17 @@ export class TerminalSession{
|
|
|
135
155
|
try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
|
|
136
156
|
finally{unsubscribe();done();}
|
|
137
157
|
}
|
|
158
|
+
async shellCommand(script){
|
|
159
|
+
const run=await this.app.startShell(script);
|
|
160
|
+
if(this.ui.trackRun){
|
|
161
|
+
this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:'系统命令'});
|
|
162
|
+
void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;
|
|
163
|
+
}
|
|
164
|
+
const unsubscribe=this.app.subscribe(event=>{if(event.runId===run.id&&event.type==='log')this.ui.show(event.message);});
|
|
165
|
+
const done=this.ui.busy('正在执行系统命令 · Ctrl+C 取消',()=>this.app.cancelRun(run.id));
|
|
166
|
+
try{const result=await run.done;this.ui.show(`命令${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看完整日志`);}
|
|
167
|
+
finally{unsubscribe();done();}
|
|
168
|
+
}
|
|
138
169
|
async configure(selectedId){
|
|
139
170
|
this.writable();
|
|
140
171
|
const plugins=this.app.listPlugins().filter(p=>p.config);
|
|
@@ -76,7 +76,20 @@ export class TerminalView{
|
|
|
76
76
|
input(data);
|
|
77
77
|
},resize);
|
|
78
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=>
|
|
79
|
+
this.hint.render=width=>{
|
|
80
|
+
const lines=[''];
|
|
81
|
+
if(this.editor&&(this.homeInput||this.multilineInput)){
|
|
82
|
+
// 按完整按键分组换行,窄屏也能看清主键和备用键。
|
|
83
|
+
let line='';
|
|
84
|
+
for(const hint of ['Shift+Enter 换行','Enter 提交','Alt+Enter 备用换行']){
|
|
85
|
+
const next=line?line+' · '+hint:hint;
|
|
86
|
+
if(line&&visibleWidth(next)>width-1){lines.push(line);line=hint;}else line=next;
|
|
87
|
+
}
|
|
88
|
+
lines.push(line);
|
|
89
|
+
}
|
|
90
|
+
lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
|
|
91
|
+
return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
|
|
92
|
+
};
|
|
80
93
|
const renderTranscript=this.transcript.render.bind(this.transcript);
|
|
81
94
|
this.headingLines=(width,mark='FISHtools')=>{
|
|
82
95
|
const room=Math.max(1,width-2),lines=[''];
|
|
@@ -127,7 +140,7 @@ export class TerminalView{
|
|
|
127
140
|
browseCommands(data){
|
|
128
141
|
const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
|
|
129
142
|
// 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
|
|
130
|
-
if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.
|
|
143
|
+
if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getExpandedText().includes('\n')||!this.commandHistory.length)return false;
|
|
131
144
|
const current=this.commandHistoryIndex??this.commandHistory.length;
|
|
132
145
|
const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
|
|
133
146
|
if(next===current)return true;
|
|
@@ -235,19 +248,40 @@ export class TerminalView{
|
|
|
235
248
|
if(message.startsWith('fst >'))title.render=()=>[];
|
|
236
249
|
this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
|
|
237
250
|
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
// Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
|
|
252
|
+
const historyText=text.trimStart().startsWith('!')?text:text.trim();
|
|
253
|
+
if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
|
|
254
|
+
this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
|
|
240
255
|
}
|
|
241
256
|
resolve(text);
|
|
242
257
|
};component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
|
|
243
|
-
finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
|
|
258
|
+
finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.multilineInput=false;this.prompt.clear();this.tui.requestRender();}
|
|
244
259
|
}
|
|
245
|
-
input(message,{value='',commands=[],files=true,workflows=[]}={}){
|
|
260
|
+
input(message,{value='',commands=[],files=true,workflows=[],multiline=false}={}){
|
|
246
261
|
const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
|
|
262
|
+
const handleInput=editor.handleInput.bind(editor);
|
|
263
|
+
editor.handleInput=data=>{
|
|
264
|
+
// ProcessTerminal 已把分段粘贴合并为完整事件;公开插入接口保留脚本制表符和换行。
|
|
265
|
+
if(data.startsWith('\x1b[200~')&&data.endsWith('\x1b[201~')){
|
|
266
|
+
const text=safe(data.slice(6,-6).replace(/\r\n?/g,'\n'));
|
|
267
|
+
if(this.homeInput||multiline||text.includes('\n')){editor.insertTextAtCursor(text);return;}
|
|
268
|
+
}
|
|
269
|
+
if(matchesKey(data,'shift+enter')||matchesKey(data,'alt+enter')||data==='\x1b[13;2~'){
|
|
270
|
+
editor.insertTextAtCursor('\n');return;
|
|
271
|
+
}
|
|
272
|
+
// 反斜杠属于命令原文,不能触发上游的“反斜杠后 Enter 换行”兼容键位。
|
|
273
|
+
if(data==='\\'){editor.insertTextAtCursor(data);return;}
|
|
274
|
+
if(matchesKey(data,'enter')&&!editor.isShowingAutocomplete()){
|
|
275
|
+
if(!editor.disableSubmit){const text=editor.getExpandedText();editor.setText('');editor.onSubmit?.(text);}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
handleInput(data);
|
|
279
|
+
};
|
|
247
280
|
const renderEditor=editor.render.bind(editor);
|
|
248
281
|
editor.render=width=>{
|
|
249
|
-
|
|
250
|
-
|
|
282
|
+
// 上游 visibleWidth 按三个空格计算 Tab;只扩展显示,提交仍保留原始 Tab。
|
|
283
|
+
if(width<8)return renderEditor(width).map(line=>line.replaceAll('\t',' '));
|
|
284
|
+
const inner=width-4,lines=renderEditor(inner).map(line=>line.replaceAll('\t',' '));let closed=false;
|
|
251
285
|
return lines.map((line,i)=>{
|
|
252
286
|
if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
|
|
253
287
|
if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
|
|
@@ -256,6 +290,7 @@ export class TerminalView{
|
|
|
256
290
|
});
|
|
257
291
|
};
|
|
258
292
|
this.homeInput=message.startsWith('fst >');
|
|
293
|
+
this.multilineInput=multiline;
|
|
259
294
|
this.commandHistoryIndex=null;
|
|
260
295
|
if(this.homeInput)editor.onChange=text=>{
|
|
261
296
|
if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
|
|
@@ -4,7 +4,6 @@ export async function workflowMenu(session){
|
|
|
4
4
|
const workflows=await app.listWorkflows();
|
|
5
5
|
const choice=await ui.select('工作流 · 选择后编辑或执行',[
|
|
6
6
|
...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
|
|
7
|
-
...(!app.project.readOnly?workflows.map(flow=>({value:`duplicate:${flow.id}`,label:`复制 ${flow.title??flow.id} 为新工作流`,description:`从已保存版本 ${flow.id} 创建副本` })):[]),
|
|
8
7
|
...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
|
|
9
8
|
...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
|
|
10
9
|
{value:'back',label:'返回主面板'},
|
|
@@ -12,11 +11,7 @@ export async function workflowMenu(session){
|
|
|
12
11
|
if(choice==='back')return;
|
|
13
12
|
try{
|
|
14
13
|
const id=choice.startsWith('workflow:')?choice.slice(9):null;
|
|
15
|
-
if(choice.
|
|
16
|
-
if(!await session.discard())continue;
|
|
17
|
-
await duplicateIntoSession(session,workflows.find(flow=>flow.id===choice.slice(10)));
|
|
18
|
-
}
|
|
19
|
-
else if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
14
|
+
if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
20
15
|
else if(choice!=='draft'&&session.flow?.id!==id){
|
|
21
16
|
if(!await session.discard())continue;
|
|
22
17
|
session.flow=structuredClone(workflows.find(flow=>flow.id===id));session.dirty=false;
|
|
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
3
3
|
import {access,readFile,readdir,lstat,mkdir,copyFile,realpath} from 'node:fs/promises';
|
|
4
4
|
|
|
5
5
|
const topics=[
|
|
6
|
+
['commands','系统命令与快捷名称','docs/terminal-commands.md'],
|
|
6
7
|
['plugins','插件开发规范','docs/plugin-development.md'],
|
|
7
8
|
['deployment','安装与部署','docs/deployment.md'],
|
|
8
9
|
['control','控制流与运行观察规范','docs/superpowers/specs/2026-10-11-control-observation.md'],
|
|
@@ -14,7 +15,7 @@ const topics=[
|
|
|
14
15
|
['readme','Fishtools 使用说明','README.md'],
|
|
15
16
|
];
|
|
16
17
|
// 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
|
|
17
|
-
const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
|
|
18
|
+
const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/commands','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
|
|
18
19
|
const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
|
|
19
20
|
const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
|
|
20
21
|
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| `@mytools/fst-logs` | `./src/run-logs.js` |
|
|
15
15
|
| `@mytools/fst-logs/markdown` | `./src/markdown-log.js` |
|
|
16
|
+
| `@mytools/fst-logs/steps` | `./src/step-summaries.js` |
|
|
17
|
+
| `@mytools/fst-logs/preview` | `./src/run-preview.js` |
|
|
16
18
|
| `@mytools/fst-logs/package.json` | `./package.json` |
|
|
17
19
|
|
|
18
20
|
仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
|
|
@@ -35,6 +37,15 @@
|
|
|
35
37
|
|
|
36
38
|
快照与历史元数据另有 `stepSummaries`、`stepSummaryBytes`、`stepsTruncated`。步骤摘要按 callPath(缺少时按 stepId)保留最新状态,仅包含类型、定位、动作、时间、序号、耗时及有界原因/错误,不缓存 input/output。步骤摘要使用独立的 maxEvents/maxMemoryBytes 预算,因此两份缓存合计最多为两倍 maxMemoryBytes;单条步骤摘要仍受 maxEventBytes 限制。普通日志淘汰不影响步骤状态;步骤本身超预算淘汰时 stepsTruncated 为 true,展示端应把无法确认的步骤显示为未知状态。
|
|
37
39
|
|
|
40
|
+
`createStepSummaries(policy)` 从 `/steps` 入口提供同一套摘要缓存,日志写入器与 TUI 共用。
|
|
41
|
+
`append(event)` 更新状态并刷新淘汰顺序;`get(callPath)`、`values()` 和 `snapshot()` 返回副本。
|
|
42
|
+
`snapshot()` 包含上述三个步骤摘要字段,`size` 为保留条数。
|
|
43
|
+
|
|
44
|
+
`previewRun(record,{maxValueBytes=65536})` 从 `/preview` 入口提供展示与历史元数据共用的结果投影。
|
|
45
|
+
input/output、error、reason 等字段转为有界 JSON 预览,过大时标记 `<字段>Truncated:true`;业务数据对象不变。
|
|
46
|
+
定位字段与 events/stepSummaries 保留,后两者由各自缓存预算负责;展示端应裁去不使用的缓存副本。
|
|
47
|
+
maxValueBytes 至少为 2,连预览本身也容不下时省略字段值并保留截断标记。
|
|
48
|
+
|
|
38
49
|
队列预算在追加时检查。未等待 append 的插件同样受到预算约束;超限返回 `LOG_QUEUE_LIMIT` 拒绝,并使 finish 失败。拒绝已安装内部处理,调用方仍可 await 观察失败,不会产生未处理拒绝。`LOG_DISK_LIMIT` 表示可选磁盘预算不足,拒绝完整追加而不静默截断。磁盘预算与队列预算都计算 JSONL 和 Markdown。建议插件等待 append,以便磁盘写入形成背压。
|
|
39
50
|
|
|
40
51
|
`read`/`list` 仅读取受限的 result.json 元数据,运行中的 read 回退到归属元数据;不加载 JSONL 或 Markdown 正文。巨大旧 result.json 超过读取预算时,read 明确返回 `LOG_METADATA_LIMIT`,list 跳过该记录。旧版本缺少归属信息时仅在 `allowLegacy:true` 下可读。
|