@yanglaofish/fishtools 0.5.3 → 0.6.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.
- package/README.md +3 -3
- package/manual/README.md +3 -3
- package/manual/docs/modules.md +2 -0
- package/manual/docs/plans/2026-10-13-effective-inputs-and-tables.md +39 -0
- package/manual/docs/plans/2026-10-13-plugin-defaults.md +45 -0
- package/manual/docs/plans/2026-10-14-issue7-recovery.md +33 -0
- package/manual/docs/plans/2026-10-14-issue8-10-workflows.md +26 -0
- package/manual/docs/plans/2026-10-14-issue9-completion.md +47 -0
- package/manual/docs/plans/2026-10-14-managed-execution.md +58 -0
- package/manual/docs/plans/2026-10-14-plugin-discovery-history.md +41 -0
- package/manual/docs/plans/2026-10-14-structured-reports.md +77 -0
- package/manual/docs/plans/2026-10-14-unreleased-review.md +47 -0
- package/manual/docs/plugin-development.md +161 -8
- package/manual/docs/releases.md +25 -6
- package/manual/docs/superpowers/plans/2026-10-13-tui-readable-sessions.md +2 -2
- package/manual/docs/superpowers/reviews/2026-10-13-tui-sessions-review.md +2 -0
- package/manual/docs/superpowers/reviews/2026-10-14-0.6.0-release-review.md +19 -0
- package/manual/docs/verification.md +17 -0
- package/manual/packages/core/README.md +6 -0
- package/manual/packages/core/src/application.js +15 -9
- package/manual/packages/core/src/plugin-catalog.js +20 -2
- package/manual/packages/core/src/plugin-loading.js +24 -0
- package/manual/packages/core/src/version.js +1 -1
- package/manual/packages/fst-cli/README.md +5 -1
- package/manual/packages/fst-cli/src/terminal/command-menu.js +3 -2
- package/manual/packages/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/manual/packages/fst-cli/src/terminal/log-viewer.js +4 -3
- package/manual/packages/fst-cli/src/terminal/navigation.js +23 -1
- package/manual/packages/fst-cli/src/terminal/presentation.js +2 -1
- package/manual/packages/fst-cli/src/terminal/report-presentation.js +38 -0
- package/manual/packages/fst-cli/src/terminal/result-table.js +66 -0
- package/manual/packages/fst-cli/src/terminal/run-feed.js +42 -17
- package/manual/packages/fst-cli/src/terminal/run-presentation.js +51 -17
- package/manual/packages/fst-cli/src/terminal/run-viewer.js +15 -12
- package/manual/packages/fst-cli/src/terminal/schema.js +114 -4
- package/manual/packages/fst-cli/src/terminal/session-history.js +2 -2
- package/manual/packages/fst-cli/src/terminal/session.js +40 -15
- package/manual/packages/fst-cli/src/terminal/view.js +80 -68
- package/manual/packages/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/manual/packages/fst-devtools/README.md +2 -0
- package/manual/packages/fst-devtools/src/plugin-quality.js +4 -1
- package/manual/packages/fst-devtools/src/plugin-tools.js +1 -0
- package/manual/packages/fst-devtools/src/process-usage.js +62 -0
- package/manual/packages/fst-platform/README.md +5 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/managed-process.js +36 -0
- package/manual/packages/fst-platform/src/process.js +7 -2
- package/manual/packages/fst-plugin/README.md +4 -0
- package/manual/packages/fst-plugin/src/contracts.js +3 -0
- package/manual/packages/fst-plugin/src/host.js +38 -23
- package/manual/packages/fst-plugin/src/runtime.js +14 -8
- package/manual/packages/fst-workflow/src/engine.js +1 -1
- package/manual/packages/fst-workspace/README.md +4 -0
- package/manual/packages/fst-workspace/src/plugin-registry.js +1 -1
- package/manual/packages/fst-workspace/src/plugins.js +5 -3
- package/manual/packages/fst-workspace/src/workflows.js +27 -9
- package/manual/packages/sdk/README.md +10 -0
- package/manual/packages/sdk/inputs.js +53 -0
- package/manual/packages/sdk/package.json +2 -0
- package/manual/packages/sdk/reports.js +53 -0
- package/node_modules/@mytools/core/README.md +6 -0
- package/node_modules/@mytools/core/src/application.js +15 -9
- package/node_modules/@mytools/core/src/plugin-catalog.js +20 -2
- package/node_modules/@mytools/core/src/plugin-loading.js +24 -0
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/README.md +5 -1
- package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +3 -2
- package/node_modules/@mytools/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +4 -3
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +23 -1
- package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +2 -1
- package/node_modules/@mytools/fst-cli/src/terminal/report-presentation.js +38 -0
- package/node_modules/@mytools/fst-cli/src/terminal/result-table.js +66 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +42 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +51 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +15 -12
- package/node_modules/@mytools/fst-cli/src/terminal/schema.js +114 -4
- package/node_modules/@mytools/fst-cli/src/terminal/session-history.js +2 -2
- package/node_modules/@mytools/fst-cli/src/terminal/session.js +40 -15
- package/node_modules/@mytools/fst-cli/src/terminal/view.js +80 -68
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/node_modules/@mytools/fst-devtools/README.md +2 -0
- package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +4 -1
- package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +1 -0
- package/node_modules/@mytools/fst-devtools/src/process-usage.js +62 -0
- package/node_modules/@mytools/fst-platform/README.md +5 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/managed-process.js +36 -0
- package/node_modules/@mytools/fst-platform/src/process.js +7 -2
- package/node_modules/@mytools/fst-plugin/README.md +4 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +3 -0
- package/node_modules/@mytools/fst-plugin/src/host.js +38 -23
- package/node_modules/@mytools/fst-plugin/src/runtime.js +14 -8
- package/node_modules/@mytools/fst-workflow/src/engine.js +1 -1
- package/node_modules/@mytools/fst-workspace/README.md +4 -0
- package/node_modules/@mytools/fst-workspace/src/plugin-registry.js +1 -1
- package/node_modules/@mytools/fst-workspace/src/plugins.js +5 -3
- package/node_modules/@mytools/fst-workspace/src/workflows.js +27 -9
- package/node_modules/@mytools/sdk/README.md +10 -0
- package/node_modules/@mytools/sdk/inputs.js +53 -0
- package/node_modules/@mytools/sdk/package.json +2 -0
- package/node_modules/@mytools/sdk/reports.js +53 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1032 -1032
- package/package.json +1 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { stripVTControlCharacters } from 'node:util';
|
|
2
1
|
import { Text, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
|
|
3
|
-
import {palette} from './theme.js';
|
|
2
|
+
import {palette,cleanText as safe} from './theme.js';
|
|
4
3
|
import {readableValue,readableError,readableTimeline,elapsedTime} from './run-presentation.js';
|
|
4
|
+
import {outputReport} from './report-presentation.js';
|
|
5
5
|
|
|
6
6
|
export const runStatuses={pending:'待执行',unknown:'摘要已省略',running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',skipped:'已跳过',stopped:'已停止'};
|
|
7
|
-
const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
|
|
8
7
|
export const mouseWheel=data=>{const match=/^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);return match&&(Number(match[1])&64)?(Number(match[1])&1?1:-1):0;};
|
|
9
8
|
export const mouseOn='\x1b[?1000h\x1b[?1006h';
|
|
10
9
|
export const mouseOff='\x1b[?1006l\x1b[?1000l';
|
|
@@ -39,15 +38,18 @@ export class RunViewer{
|
|
|
39
38
|
this.requestRender();
|
|
40
39
|
}
|
|
41
40
|
outputSchema(node){return this.catalog.find(action=>action.id===(node?.action??this.record.action??this.record.workflowId))?.output;}
|
|
42
|
-
getRenderedOutput(){
|
|
41
|
+
getRenderedOutput(width=this.terminal.columns,{markdown=false}={}){
|
|
43
42
|
const node=this.view===0?undefined:this.nodes[this.selected],output=node?node.output:this.record.output,schema=this.outputSchema(node);
|
|
44
|
-
if(output&&typeof output==='object'){
|
|
43
|
+
if(output&&typeof output==='object'&&!outputReport(output)){
|
|
45
44
|
if(typeof output.markdown==='string')return output.markdown;
|
|
46
45
|
for(const [key,value] of Object.entries(output))if(typeof value==='string'&&(schema?.properties?.[key]?.format==='markdown'||schema?.properties?.[key]?.contentMediaType==='text/markdown'))return value;
|
|
47
46
|
}
|
|
48
|
-
|
|
47
|
+
const text=readableValue(output,'',schema,{compact:true,width:markdown?Math.max(1,width-2):width,truncated:node?node.eventTruncated:this.record.outputTruncated});
|
|
48
|
+
if(!markdown)return text;
|
|
49
|
+
const fence='`'.repeat(Math.max(3,...[...text.matchAll(/`+/g)].map(match=>match[0].length+1)));
|
|
50
|
+
return `${fence}text\n${text}\n${fence}`;
|
|
49
51
|
}
|
|
50
|
-
content(){
|
|
52
|
+
content(width=this.terminal.columns){
|
|
51
53
|
const node=this.nodes[this.selected],status=runStatuses[this.record.status]??this.record.status;
|
|
52
54
|
const elapsed=elapsedTime(this.record,this.now());
|
|
53
55
|
if(this.view===0)return [
|
|
@@ -55,7 +57,7 @@ export class RunViewer{
|
|
|
55
57
|
elapsed!==undefined?`耗时:${elapsed}`:'',
|
|
56
58
|
this.record.error?readableError(this.record.error):'',
|
|
57
59
|
...(this.record.warnings??[]).map(warning=>`警告:${warning.message}`),
|
|
58
|
-
this.record.output!==undefined?'\n
|
|
60
|
+
this.record.output!==undefined?'\n最终输出(紧凑显示)\n'+readableValue(this.record.output,'',this.outputSchema(),{compact:true,width,truncated:this.record.outputTruncated}):'',
|
|
59
61
|
this.record.resultDir?'\n生成文件:'+this.record.resultDir:'',
|
|
60
62
|
'\n步骤 · Enter 查看详情',
|
|
61
63
|
...this.nodes.map((n,i)=>`${i===this.selected?'›':' '} ${runStatuses[n.status]??n.status} · ${n.title??n.callPath}${n.durationMs!==undefined?` · ${n.durationMs} ms`:''}${n.reason?' · '+n.reason:''}`),
|
|
@@ -63,19 +65,20 @@ export class RunViewer{
|
|
|
63
65
|
].filter(Boolean).join('\n');
|
|
64
66
|
if(this.view===1){
|
|
65
67
|
const action=this.catalog.find(action=>action.id===(node?.action??this.record.action??this.record.workflowId));
|
|
66
|
-
const format=(value,schema)=>this.raw?JSON.stringify(value??null,null,2):readableValue(value,'',schema);
|
|
68
|
+
const format=(value,schema,{compact=false,decodeStrings=true}={})=>this.raw?JSON.stringify(value??null,null,2):readableValue(value,'',schema,{compact,decodeStrings,width,truncated:node?node.eventTruncated:compact?this.record.outputTruncated:this.record.inputTruncated});
|
|
67
69
|
const error=node?node.error:this.record.error;
|
|
70
|
+
const input=node?node.input:this.record.input!==undefined?this.record.input:this.record.events?.find(event=>event.type==='run.input')?.input;
|
|
68
71
|
return [`步骤:${node?.title??node?.callPath??'整个运行'}`,node?.title&&node.callPath?'调用路径:'+node.callPath:'',node?.action?'能力:'+node.action:'',error?'\n错误\n'+(this.raw?format(error):readableError(error)):'',
|
|
69
|
-
'\n输入\n'+format(
|
|
72
|
+
'\n输入\n'+format(input,action?.input,{decodeStrings:false}), '\n输出\n'+format(node?node.output:this.record.output,action?.output,{compact:true}),
|
|
70
73
|
!error&&(node?.reason||node?.message)?'\n说明:'+(node.reason??node.message):'',this.raw?'\n原始 JSON · 包含完整错误详情与调用栈':'\nj 查看原始 JSON 与完整诊断',
|
|
71
74
|
this.onReadOutput?'m 格式化阅读输出':'',
|
|
72
75
|
].filter(Boolean).join('\n');
|
|
73
76
|
}
|
|
74
|
-
return (this.record.eventsTruncated?'当前为近期日志摘要,按 p 分页查看完整落盘日志。\n\n':'')+readableTimeline(this.record.events,{callPath:this.allLogs?undefined:node?.callPath,catalog:this.catalog});
|
|
77
|
+
return (this.record.eventsTruncated?'当前为近期日志摘要,按 p 分页查看完整落盘日志。\n\n':'')+readableTimeline(this.record.events,{callPath:this.allLogs?undefined:node?.callPath,catalog:this.catalog,width});
|
|
75
78
|
}
|
|
76
79
|
layout(width){
|
|
77
80
|
const height=Math.max(1,this.terminal.rows),pageSize=Math.max(1,height-4);
|
|
78
|
-
const lines=new Text(safe(this.content()),0,0).render(Math.max(1,width));
|
|
81
|
+
const lines=new Text(safe(this.content(width)),0,0).render(Math.max(1,width));
|
|
79
82
|
const maxOffset=Math.max(0,lines.length-pageSize);
|
|
80
83
|
this.offset=this.follow&&this.view===2?maxOffset:Math.max(0,Math.min(this.offset,maxOffset));
|
|
81
84
|
return {height,pageSize,lines,maxOffset};
|
|
@@ -1,18 +1,128 @@
|
|
|
1
|
-
import { validate } from '@mytools/sdk/schema';
|
|
1
|
+
import { validate, assertSchema } from '@mytools/sdk/schema';
|
|
2
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
2
3
|
import { fileReference } from './model.js';
|
|
4
|
+
import {readableValue} from './run-presentation.js';
|
|
5
|
+
import {cleanText,fitLine} from './theme.js';
|
|
6
|
+
import {visibleWidth} from '@mariozechner/pi-tui';
|
|
7
|
+
import {hasInputRules,resolveInput} from '@mytools/sdk/inputs';
|
|
3
8
|
|
|
4
9
|
export const needsJsonEditor=schema=>['oneOf','anyOf','allOf','if','$ref','patternProperties'].some(key=>schema?.[key]!==undefined);
|
|
5
10
|
|
|
11
|
+
// 与执行时共用校验器;即使尚缺必填字段,也保留已能确定的默认值供编辑。
|
|
12
|
+
function editableDefaults(schema,value){
|
|
13
|
+
const copy=structuredClone(value);
|
|
14
|
+
if(schema!=null)assertSchema(schema,'参数')(copy);
|
|
15
|
+
return copy;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inputEntries(schema,value,fields={}){
|
|
19
|
+
if(schema?.type!=='object'||!schema.properties||needsJsonEditor(schema))return [readableValue(value,'',schema,{decodeStrings:false})];
|
|
20
|
+
const names=new Set([...Object.keys(schema.properties),...Object.keys(value??{})]);
|
|
21
|
+
return [...names].map(name=>{
|
|
22
|
+
const field=fields[name],title=schema.properties[name]?.title??name;
|
|
23
|
+
if(value?.[name]===undefined)return `${title}:${schema.required?.includes(name)?'未填写(必填)':field?.unsetLabel??'未提供(插件未声明缺省行为)'}`;
|
|
24
|
+
const text=readableValue({[name]:value[name]},'',schema,{decodeStrings:false});
|
|
25
|
+
if(!field)return text;
|
|
26
|
+
const source=field?.source==='config'?'继承插件配置:':field?.source==='default'?'默认值:':field?.source==='input'?'本次输入:':'';
|
|
27
|
+
return `${title}:${source}${readableValue(value[name],'',schema.properties[name],{decodeStrings:false})}${field?.unit?' '+field.unit:''}${field?.path?'('+field.path+')':''}`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parameterPreview(entries,label,screenWidth=80,screenHeight=24){
|
|
32
|
+
const limit=Math.max(1,Math.min(4,screenHeight-10)),width=Math.max(8,screenWidth-6);
|
|
33
|
+
const values=entries.map(entry=>cleanText(entry).replace(/[\r\n\t]/g,' '));
|
|
34
|
+
let columns=width>=110?3:width>=60?2:1;
|
|
35
|
+
// 来源说明较长时减少列数,优先显示完整含义,仍保持预览行数有界。
|
|
36
|
+
while(columns>1&&values.some(value=>visibleWidth(value)>Math.floor((width-(columns-1)*3)/columns)))columns--;
|
|
37
|
+
const cellWidth=Math.floor((width-(columns-1)*3)/columns);
|
|
38
|
+
const cells=values.slice(0,limit*columns).map(entry=>fitLine(entry,cellWidth));
|
|
39
|
+
const rows=[];
|
|
40
|
+
for(let index=0;index<cells.length;index+=columns){
|
|
41
|
+
rows.push(cells.slice(index,index+columns).map((cell,i)=>i<columns-1?cell+' '.repeat(cellWidth-visibleWidth(cell)):cell).join(' '));
|
|
42
|
+
}
|
|
43
|
+
const preview=rows.join('\n')||'无需参数';
|
|
44
|
+
return {text:`${fitLine(cleanText(label??'插件能力').replace(/\s+/g,' '),width)}\n当前参数(含默认值):\n${preview}`,truncated:values.length>cells.length||values.some(value=>visibleWidth(value)>cellWidth)||entries.some(entry=>/[\r\n\t]/.test(entry))};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function commandInput(ui,schema,value,{baseDir,label,preview}={}){
|
|
48
|
+
const rules=hasInputRules(schema),project=preview??(value=>resolveInput(schema,value,{}, {check:false}));
|
|
49
|
+
let current=rules?structuredClone(value):editableDefaults(schema,value);
|
|
50
|
+
while(true){
|
|
51
|
+
const resolved=rules?project(current):{value:current};
|
|
52
|
+
const entries=inputEntries(schema,resolved.value,resolved.fields),summary=entries.join('\n')||'无需参数';
|
|
53
|
+
const layout=()=>parameterPreview(entries,label,ui.terminal?.columns,ui.terminal?.rows);
|
|
54
|
+
const message=(width,height)=>parameterPreview(entries,label,width,height).text;
|
|
55
|
+
const choice=await ui.select(ui.terminal?message:message(),[
|
|
56
|
+
{value:'run',label:'执行'},
|
|
57
|
+
{value:'edit',label:'配置参数'},
|
|
58
|
+
...(ui.viewMarkdown?[{value:'view',label:'展开未显示完整的参数',description:`共 ${entries.length} 项`,hidden:()=>!layout().truncated}]:[]),
|
|
59
|
+
{value:'cancel',label:'返回'},
|
|
60
|
+
]);
|
|
61
|
+
if(choice==='cancel')return {cancelled:true};
|
|
62
|
+
if(choice==='view'){
|
|
63
|
+
// 保留换行和引号,参数里的 Markdown 符号不能改变显示含义。
|
|
64
|
+
let fenceLength=3;for(const match of summary.matchAll(/`+/g))fenceLength=Math.max(fenceLength,match[0].length+1);
|
|
65
|
+
const fence='`'.repeat(fenceLength);
|
|
66
|
+
await ui.viewMarkdown('当前参数(含默认值)',`${fence}text\n${summary}\n${fence}`);continue;
|
|
67
|
+
}
|
|
68
|
+
if(choice==='edit')current=await editInput(ui,schema,current,{baseDir,label,preview:project,required:true});
|
|
69
|
+
else if(choice==='run'){
|
|
70
|
+
try{
|
|
71
|
+
if(rules){validate(schema,project(current).value,label??'插件参数',{useDefaults:false});return {value:structuredClone(current)};}
|
|
72
|
+
return {value:validate(schema,current,label??'插件参数')};
|
|
73
|
+
}
|
|
74
|
+
catch(error){if(error.code!=='VALIDATION')throw error;ui.show(`暂不能执行,请配置参数:${error.message}`);}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 能力调用和快捷名称保存使用同一编辑入口,省略值不因打开表单而固化。
|
|
80
|
+
export async function editInput(ui,schema,value,options={}){
|
|
81
|
+
if(!hasInputRules(schema))return editValue(ui,schema,value,options);
|
|
82
|
+
return editInputRules(ui,schema,value,{...options,preview:options.preview??(value=>resolveInput(schema,value,{}, {check:false}))});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function editInputRules(ui,schema,value,{baseDir,label,preview}){
|
|
86
|
+
const current=structuredClone(value);
|
|
87
|
+
while(true){
|
|
88
|
+
const resolved=preview(current),entries=inputEntries(schema,resolved.value,resolved.fields);
|
|
89
|
+
const names=Object.keys(schema.properties);
|
|
90
|
+
const choice=await ui.select(`${label??'参数'} · 选择字段`,[
|
|
91
|
+
...names.map((name,index)=>({value:`field:${name}`,label:schema.properties[name].title??name,description:entries[index]})),
|
|
92
|
+
{value:'__done',label:'完成填写'},
|
|
93
|
+
]);
|
|
94
|
+
if(choice==='__done'){
|
|
95
|
+
try{validate(schema,preview(current).value,label??'参数',{useDefaults:false});return current;}
|
|
96
|
+
catch(error){ui.show(error.message);continue;}
|
|
97
|
+
}
|
|
98
|
+
const name=choice.slice(6),field=schema.properties[name],rule=field['x-fst'];
|
|
99
|
+
if(rule){
|
|
100
|
+
const action=await ui.select(field.title??name,[{value:'set',label:'设置本次输入'},{value:'inherit',label:rule.fromConfig?'恢复继承插件配置':'恢复省略参数'},{value:'back',label:'返回'}]);
|
|
101
|
+
if(action==='back')continue;
|
|
102
|
+
if(action==='inherit'){delete current[name];continue;}
|
|
103
|
+
}
|
|
104
|
+
const next=await editValue(ui,field,resolved.value[name],{baseDir,label:field.title??name,required:schema.required?.includes(name)});
|
|
105
|
+
if(next===undefined)delete current[name];else Object.defineProperty(current,name,{value:next,enumerable:true,writable:true,configurable:true});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
6
109
|
// 值只在整个编辑过程完成后返回,Escape 不会留下半个修改。
|
|
7
110
|
export async function editValue(ui,schema={},value,{baseDir,required=false,label=schema.title??'值'}={}){
|
|
8
111
|
if(value===undefined&&schema.default!==undefined)value=structuredClone(schema.default);
|
|
9
112
|
if(schema.enum&&!needsJsonEditor(schema)){
|
|
10
|
-
const
|
|
113
|
+
const choices=schema.enum.map((v,i)=>({value:String(i),label:JSON.stringify(v)}));
|
|
114
|
+
const selected=schema.enum.findIndex(item=>isDeepStrictEqual(item,value));
|
|
115
|
+
if(selected>0)choices.unshift(...choices.splice(selected,1));
|
|
116
|
+
const index=await ui.select(label,choices);
|
|
11
117
|
return schema.enum[Number(index)];
|
|
12
118
|
}
|
|
13
|
-
if(schema.type==='boolean'&&!needsJsonEditor(schema))
|
|
119
|
+
if(schema.type==='boolean'&&!needsJsonEditor(schema)){
|
|
120
|
+
const choices=[{value:'true',label:'是 / true'},{value:'false',label:'否 / false'}];
|
|
121
|
+
if(value===false)choices.reverse();
|
|
122
|
+
return (await ui.select(label,choices))==='true';
|
|
123
|
+
}
|
|
14
124
|
if(schema.type==='object'&&schema.properties&&!needsJsonEditor(schema)){
|
|
15
|
-
const result=
|
|
125
|
+
const result=editableDefaults(schema,value??{}),props=Object.entries(schema.properties);
|
|
16
126
|
while(true){
|
|
17
127
|
const key=await ui.select(`${label} · 选择字段`,[
|
|
18
128
|
...props.map(([key,s])=>({value:`field:${key}`,label:`${s.title??key}${schema.required?.includes(key)?' *':''}`,description:JSON.stringify(result[key])??'未填写'})),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import {randomUUID} from 'node:crypto';
|
|
3
3
|
import {createSessionHistory} from '@mytools/fst-logs/sessions';
|
|
4
|
-
import {readableValue,readableError,runCategory,runCategoryNames} from './run-presentation.js';
|
|
4
|
+
import {readableValue,readableError,runCategory,runCategoryNames,presentationCatalog} from './run-presentation.js';
|
|
5
5
|
|
|
6
6
|
// 完整交互追加落盘,快照只保存可立即恢复的近期窗口,不重复保存运行日志全文。
|
|
7
7
|
export class TerminalHistory{
|
|
@@ -59,7 +59,7 @@ export class TerminalHistory{
|
|
|
59
59
|
if(this.app.listRuns().some(run=>run.status==='running'))throw new Error('当前有运行中的任务,完成后再恢复历史会话。');
|
|
60
60
|
await this.flush();this.resumedFrom=record.id;
|
|
61
61
|
this.ui.commandHistory=[...record.commandHistory];this.ui.commandHistoryIndex=null;
|
|
62
|
-
this.ui.feed.restoreState(record.feed,{catalog:this.app
|
|
62
|
+
this.ui.feed.restoreState(record.feed,{catalog:presentationCatalog(this.app)});
|
|
63
63
|
this.append({type:'restored',sessionId:record.id});await this.flush();
|
|
64
64
|
}
|
|
65
65
|
async close(){await this.flush();this.closed=true;this.ui.feed.onEntry=null;this.ui.onHistoryChange=null;}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
|
|
5
6
|
import { editCondition } from './conditions.js';
|
|
6
7
|
import { runStatuses } from './run-viewer.js';
|
|
7
|
-
import { editValue, needsJsonEditor } from './schema.js';
|
|
8
|
+
import { editValue, needsJsonEditor, commandInput, inputEntries } from './schema.js';
|
|
9
|
+
import {hasInputRules} from '@mytools/sdk/inputs';
|
|
8
10
|
import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
|
|
9
11
|
import {readableValue,readableError,runCategory,runCategoryNames} from './run-presentation.js';
|
|
10
|
-
import {commandMenu} from './navigation.js';
|
|
12
|
+
import {commandMenu,commandCompletions} from './navigation.js';
|
|
11
13
|
import {workflowMenu} from './workflow-menu.js';
|
|
12
14
|
import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
|
|
13
15
|
import {validate} from '@mytools/sdk/schema';
|
|
@@ -30,6 +32,7 @@ export class TerminalSession{
|
|
|
30
32
|
loadWorkflow(flow){this.flow=structuredClone(flow);this.savedFlow=structuredClone(flow);this.dirty=false;}
|
|
31
33
|
updateWorkflow(flow){this.flow=flow;this.dirty=!isDeepStrictEqual(flow,this.savedFlow);}
|
|
32
34
|
commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
|
|
35
|
+
completions(){return commandCompletions(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
|
|
33
36
|
async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
|
|
34
37
|
async mayExit(){
|
|
35
38
|
if(!await this.discard())return false;
|
|
@@ -42,13 +45,15 @@ export class TerminalSession{
|
|
|
42
45
|
}
|
|
43
46
|
async run(){
|
|
44
47
|
await this.startHistory();
|
|
45
|
-
|
|
48
|
+
const workflows=await this.app.listWorkflows();
|
|
49
|
+
this.ui.show(gettingStarted(workflows.filter(flow=>flow.status!=='invalid'),this.app.project),{kind:'welcome'});
|
|
50
|
+
for(const flow of workflows.filter(flow=>flow.status==='invalid'))this.ui.show(`工作流不可用:${flow.diagnostic.message}`);
|
|
46
51
|
for(const warning of this.app.host?.warnings??[])this.ui.show(`声明提示:${warning.message}`);
|
|
47
52
|
try{
|
|
48
53
|
while(!this.exiting){
|
|
49
54
|
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?'未保存':'已保存'}`:''}`);
|
|
50
55
|
let text;
|
|
51
|
-
try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.
|
|
56
|
+
try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.completions(),workflows:await this.app.listWorkflows(),multiline:true});}
|
|
52
57
|
catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
|
|
53
58
|
if(!text.trim())continue;
|
|
54
59
|
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}`);}
|
|
@@ -100,7 +105,7 @@ export class TerminalSession{
|
|
|
100
105
|
const [command,...args]=parseCommand(text);
|
|
101
106
|
const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
|
|
102
107
|
if(shortcut)return runShortcut(this,shortcut,args);
|
|
103
|
-
const entry=this.
|
|
108
|
+
const entry=this.completions().find(c=>c.name===command&&c.plugin);
|
|
104
109
|
if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
|
|
105
110
|
const pluginCommand=this.app.listCommands().find(c=>c.name===command);
|
|
106
111
|
if(pluginCommand)return this.pluginCommand(pluginCommand,args);
|
|
@@ -166,17 +171,31 @@ export class TerminalSession{
|
|
|
166
171
|
default:throw new Error(`未知操作 /${command},输入 /help 查看可用操作`);
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
|
-
async pluginCommand(command,args){
|
|
174
|
+
async pluginCommand(command,args,{fromMenu=false}={}){
|
|
170
175
|
if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
|
|
171
176
|
let input;
|
|
172
177
|
if(args.length)input=await readRunInput(args[0],this.baseDir);
|
|
173
178
|
else{
|
|
174
179
|
const initial=structuredClone(command.shortcutInput??{});
|
|
180
|
+
const target=command.actionId??`/${command.targetName??command.name}`;
|
|
181
|
+
const preview=this.app.previewInput?value=>this.app.previewInput(target,value):undefined;
|
|
175
182
|
const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
|
|
176
|
-
if(command.shortcutInput!==undefined||empty){
|
|
177
|
-
try{
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
if(command.shortcutInput!==undefined||empty||!command.input){
|
|
184
|
+
try{
|
|
185
|
+
if(hasInputRules(command.input)&&preview){validate(command.input,preview(initial).value,command.description??command.name,{useDefaults:false});input=initial;}
|
|
186
|
+
else input=validate(command.input,initial,command.description??command.name);
|
|
187
|
+
}
|
|
188
|
+
catch{
|
|
189
|
+
if(hasInputRules(command.input)){
|
|
190
|
+
const result=await commandInput(this.ui,command.input,initial,{baseDir:this.baseDir,label:command.description,preview});
|
|
191
|
+
if(result.cancelled)return;input=result.value;
|
|
192
|
+
}else input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});
|
|
193
|
+
}
|
|
194
|
+
}else{
|
|
195
|
+
const result=await commandInput(this.ui,command.input,initial,{baseDir:this.baseDir,label:command.description??command.name,preview});
|
|
196
|
+
if(result.cancelled)return;
|
|
197
|
+
input=result.value;
|
|
198
|
+
}
|
|
180
199
|
}
|
|
181
200
|
const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
|
|
182
201
|
const definition=this.app.host.definitions.get(command.plugin);
|
|
@@ -188,6 +207,7 @@ export class TerminalSession{
|
|
|
188
207
|
const targetName=command.targetName??command.name;
|
|
189
208
|
if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&¤t.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
|
|
190
209
|
const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
|
|
210
|
+
if(fromMenu)this.ui.recordCommand?.(`/${targetName}`,{display:true});
|
|
191
211
|
if(this.ui.trackRun){
|
|
192
212
|
this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input,done:run.done});
|
|
193
213
|
if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
|
|
@@ -330,8 +350,10 @@ export class TerminalSession{
|
|
|
330
350
|
}
|
|
331
351
|
const props=Object.entries(meta.input?.properties??{});if(!props.length)return;
|
|
332
352
|
while(true){
|
|
353
|
+
const effective=step.action&&hasInputRules(meta.input)&&this.app.previewInput?this.app.previewInput(step.action,step.with??{}):undefined;
|
|
354
|
+
const descriptions=effective?inputEntries(meta.input,effective.value,effective.fields):undefined;
|
|
333
355
|
const selection=await this.ui.select(`${meta.title} · 选择要设置的参数`,[
|
|
334
|
-
...props.map(([key,s])=>({value:`field:${key}`,label:`${s.title??key}${meta.input.required?.includes(key)?' *':''}`,description:JSON.stringify(step.with?.[key]??s.default)??'未填写'})),
|
|
356
|
+
...props.map(([key,s],index)=>({value:`field:${key}`,label:`${s.title??key}${meta.input.required?.includes(key)?' *':''}`,description:step.with?.[key]?.$ref?`运行时绑定:${step.with[key].$ref}`:descriptions?.[index]??JSON.stringify(step.with?.[key]??s.default)??'未填写'})),
|
|
335
357
|
{value:'done',label:'完成参数设置'},
|
|
336
358
|
]);
|
|
337
359
|
if(selection==='done')return;
|
|
@@ -340,7 +362,7 @@ export class TerminalSession{
|
|
|
340
362
|
const choices=outputSources(flow,index,catalog,workflows).filter(s=>!schema.type||!s.schema?.type||schema.type===s.schema.type);
|
|
341
363
|
const mode=await this.ui.select(`${schema.title??key} · 输入来源`,[
|
|
342
364
|
{value:'fixed',label:'填写固定值 / @本地文件路径'},{value:'runtime',label:'每次运行时填写'},
|
|
343
|
-
...choices.map(({value,label})=>({value,label})),...(!required?[{value:'unset',label:`不传此参数${schema.default!==undefined?',使用已声明的默认值':''}`}]:[]),
|
|
365
|
+
...choices.map(({value,label})=>({value,label})),...(!required||schema.default!==undefined||schema['x-fst']?.fromConfig?[{value:'unset',label:schema['x-fst']?.fromConfig?'恢复继承插件配置':`不传此参数${schema.default!==undefined?',使用已声明的默认值':''}`}]:[]),
|
|
344
366
|
]);
|
|
345
367
|
if(mode==='unset')setBinding(flow,index,key,'fixed',schema,undefined);
|
|
346
368
|
else if(mode==='fixed'){
|
|
@@ -429,7 +451,7 @@ export class TerminalSession{
|
|
|
429
451
|
const choice=await this.ui.select(`[插件] ${plugin?.title??id} · 选择能力`,[
|
|
430
452
|
...commands.map(c=>({value:`command:${c.name}`,label:c.description??c.name,description:`/${c.name}`})),{value:'back',label:'返回主面板'},
|
|
431
453
|
]);
|
|
432
|
-
if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[]);
|
|
454
|
+
if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[],{fromMenu:true});
|
|
433
455
|
}
|
|
434
456
|
async toolConfig(){
|
|
435
457
|
while(true){
|
|
@@ -599,7 +621,7 @@ export class TerminalSession{
|
|
|
599
621
|
if(choice==='back')return;
|
|
600
622
|
if(choice==='details')await this.ui.viewMarkdown(plugin.title??id,pluginDetails(plugin,actions,declaredCommands));
|
|
601
623
|
else if(choice==='configure')await this.configure(id);
|
|
602
|
-
else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[]);
|
|
624
|
+
else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[],{fromMenu:true});
|
|
603
625
|
else if(choice==='remove'){
|
|
604
626
|
this.writable();if(await this.ui.confirm(`${plugin.scope==='global'?'全局卸载(影响所有工作区)':'移除'} ${id}?`)){
|
|
605
627
|
let removed;
|
|
@@ -672,5 +694,8 @@ export async function startTerminal(app,options={}){
|
|
|
672
694
|
const ui=new TerminalView({baseDir:options.baseDir??process.cwd(),previewLabel:options.previewLabel}),session=new TerminalSession(app,ui,options);
|
|
673
695
|
const terminate=()=>{session.exiting=true;ui.pending?.reject(Object.assign(new Error('退出'),{code:'PROMPT_CANCELLED'}));for(const r of app.listRuns())if(r.status==='running')app.cancelRun(r.id);};
|
|
674
696
|
process.once('SIGTERM',terminate);
|
|
675
|
-
try{ui.start();await session.run();}finally{
|
|
697
|
+
try{ui.start();await session.run();}finally{
|
|
698
|
+
process.removeListener('SIGTERM',terminate);const cleanup=await ui.close();
|
|
699
|
+
if(cleanup?.incomplete)throw new FlowError(`终端已关闭,但仍有 ${cleanup.unreaped} 个文件探测进程未回收`,{code:'FS_CLEANUP_INCOMPLETE',details:cleanup});
|
|
700
|
+
}
|
|
676
701
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
|
|
1
|
+
import {FileAutocomplete,createAutocompleteProbe} from './file-autocomplete.js';
|
|
2
|
+
export {FileAutocomplete} from './file-autocomplete.js';
|
|
3
|
+
import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
|
|
5
4
|
import { MarkdownLogViewer } from './log-viewer.js';
|
|
6
5
|
import { RunViewer, mouseOn, mouseOff, mouseWheel } from './run-viewer.js';
|
|
7
6
|
import {RunFeed} from './run-feed.js';
|
|
7
|
+
import {presentationCatalog} from './run-presentation.js';
|
|
8
8
|
import { logo } from './presentation.js';
|
|
9
9
|
import { VERSION } from '@mytools/core/version';
|
|
10
10
|
import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
|
|
@@ -13,44 +13,9 @@ export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'
|
|
|
13
13
|
// 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
|
|
14
14
|
function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
|
|
15
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
16
|
export class TerminalView{
|
|
53
|
-
constructor({baseDir,previewLabel,terminal=new ProcessTerminal()}={}){
|
|
17
|
+
constructor({baseDir,previewLabel,terminal=new ProcessTerminal(),fileAutocompleteOptions={}}={}){
|
|
18
|
+
this.fileAutocompleteOptions=fileAutocompleteOptions;this.fileProviders=new Set();
|
|
54
19
|
this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];this.runViews=new Map();
|
|
55
20
|
this.historyOffset=0;this.commandHistory=[];this.commandHistoryIndex=null;this.feed=new RunFeed(()=>this.refreshTranscript());
|
|
56
21
|
const originalStart=terminal.start.bind(terminal);
|
|
@@ -69,6 +34,7 @@ export class TerminalView{
|
|
|
69
34
|
}
|
|
70
35
|
if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
|
|
71
36
|
if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
|
|
37
|
+
this.editor?.autocompleteProvider?.cancelPending?.();
|
|
72
38
|
// 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
|
|
73
39
|
if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
|
|
74
40
|
this.pending.reject(cancelled());return;
|
|
@@ -87,10 +53,11 @@ export class TerminalView{
|
|
|
87
53
|
}
|
|
88
54
|
lines.push(line);
|
|
89
55
|
}
|
|
90
|
-
lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}
|
|
56
|
+
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
57
|
return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
|
|
92
58
|
};
|
|
93
|
-
const
|
|
59
|
+
const renderText=this.transcript.render.bind(this.transcript);
|
|
60
|
+
const renderTranscript=width=>{if(this.transcriptWidth!==width)this.updateTranscript(width);return renderText(width);};
|
|
94
61
|
this.headingLines=(width,mark='FISHtools')=>{
|
|
95
62
|
const room=Math.max(1,width-2),lines=[''];
|
|
96
63
|
const summary=(this.summaryText??'').split('\n');
|
|
@@ -151,14 +118,31 @@ export class TerminalView{
|
|
|
151
118
|
this.tui.requestRender();return true;
|
|
152
119
|
}
|
|
153
120
|
async close(){
|
|
154
|
-
if(this.
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
121
|
+
if(this.closing)return this.closing;this.closed=true;
|
|
122
|
+
return this.closing=(async()=>{
|
|
123
|
+
try{
|
|
124
|
+
await Promise.all([...this.fileProviders].map(provider=>provider.close()));this.fileProviders.clear();
|
|
125
|
+
return await this.fileProbe?.close();
|
|
126
|
+
}finally{
|
|
127
|
+
this.feed.close();
|
|
128
|
+
// Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
|
|
129
|
+
await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
|
|
130
|
+
if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
|
|
131
|
+
if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
160
134
|
}
|
|
161
135
|
summary(text){this.summaryText=safe(text);this.tui.requestRender();}
|
|
136
|
+
recordCommand(text,{display=false}={}){
|
|
137
|
+
// 表单参数不写入可召回命令;Shell 原文仍保留末尾换行。
|
|
138
|
+
const command=text.trimStart().startsWith('!')?text:text.trim();
|
|
139
|
+
if(!command)return;
|
|
140
|
+
if(this.commandHistory.at(-1)!==command){
|
|
141
|
+
this.commandHistory.push(command);if(this.commandHistory.length>100)this.commandHistory.shift();
|
|
142
|
+
this.commandHistoryIndex=null;this.onHistoryChange?.();
|
|
143
|
+
}
|
|
144
|
+
if(display)this.feed.message('› '+safe(command));
|
|
145
|
+
}
|
|
162
146
|
beginCommand(text){
|
|
163
147
|
const name=text.trim().split(/\s+/)[0].replace(/^\//,'');
|
|
164
148
|
const labels={workflows:'工作流',run:'运行工作流',plugin:'插件管理',plugins:'插件管理',config:'工具配置',pack:'打包',logs:'执行记录',runs:'执行记录',tools:'工具',help:'帮助',exit:'退出'};
|
|
@@ -168,12 +152,15 @@ export class TerminalView{
|
|
|
168
152
|
endCommand(){
|
|
169
153
|
this.commandLocation=null;this.tui.requestRender();
|
|
170
154
|
}
|
|
171
|
-
transcriptText(){return [...this.logs,this.feed.text()].filter(Boolean).join('\n\n');}
|
|
155
|
+
transcriptText(width=this.terminal.columns){return [...this.logs,this.feed.text(Math.max(1,width-2))].filter(Boolean).join('\n\n');}
|
|
172
156
|
refreshTranscript(){
|
|
173
157
|
const width=this.terminal.columns,previous=this.transcript.render===undefined?0:new Text(safe(this.lastTranscript??''),1,0).render(width).length;
|
|
174
|
-
const text=safe(this.transcriptText());
|
|
158
|
+
const text=safe(this.transcriptText(width));
|
|
175
159
|
if(this.historyOffset>0)this.historyOffset=Math.max(0,this.historyOffset+new Text(text,1,0).render(width).length-previous);
|
|
176
|
-
this.
|
|
160
|
+
this.updateTranscript(width,text);this.tui.requestRender();this.onHistoryChange?.();
|
|
161
|
+
}
|
|
162
|
+
updateTranscript(width,text=safe(this.transcriptText(width))){
|
|
163
|
+
this.transcriptWidth=width;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'));
|
|
177
164
|
}
|
|
178
165
|
trackRun(options){this.feed.track(options);}
|
|
179
166
|
clearMessages(){this.logs=[];this.refreshTranscript();}
|
|
@@ -190,7 +177,7 @@ export class TerminalView{
|
|
|
190
177
|
try{
|
|
191
178
|
await new Promise((resolve,reject)=>{
|
|
192
179
|
this.pending={resolve,reject};
|
|
193
|
-
this.logViewer=new MarkdownLogViewer({title:safe(title),text
|
|
180
|
+
this.logViewer=new MarkdownLogViewer({title:safe(title),text,terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
|
|
194
181
|
this.terminal.write(mouseOn);
|
|
195
182
|
// Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
|
|
196
183
|
this.tui.children=[];
|
|
@@ -217,7 +204,7 @@ export class TerminalView{
|
|
|
217
204
|
try{
|
|
218
205
|
await new Promise((resolve,reject)=>{
|
|
219
206
|
this.pending={resolve,reject};
|
|
220
|
-
const viewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:app
|
|
207
|
+
const viewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:presentationCatalog(app),terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),onReadLogs:app.readLogPage?()=>{readPages=true;resolve();}:undefined,onReadOutput:()=>{renderedOutput=width=>viewer.getRenderedOutput(width,{markdown:true});resolve();},requestRender:()=>this.tui.requestRender()});
|
|
221
208
|
this.runViewer=viewer;Object.assign(viewer,this.runViews.get(runId)??{});
|
|
222
209
|
if(initialView!==undefined){viewer.view=initialView;viewer.offset=0;}
|
|
223
210
|
const stopElapsed=()=>{if(elapsedTimer){clearInterval(elapsedTimer);elapsedTimer=null;}};
|
|
@@ -264,18 +251,16 @@ export class TerminalView{
|
|
|
264
251
|
}
|
|
265
252
|
async ask(message,component){
|
|
266
253
|
fit(component);
|
|
267
|
-
const title=new Text(
|
|
268
|
-
|
|
269
|
-
title.render=width=>
|
|
270
|
-
|
|
254
|
+
const title=new Text('',1,0);
|
|
255
|
+
// 参数预览可随窗口尺寸重新排版;普通字符串标题保持原有行为。
|
|
256
|
+
title.render=width=>{
|
|
257
|
+
const text=typeof message==='function'?message(width,this.terminal.rows):message;
|
|
258
|
+
return [fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...new Text(palette.brand('› ')+palette.text(safe(text)),1,0).render(width)];
|
|
259
|
+
};
|
|
260
|
+
if(typeof message==='string'&&message.startsWith('fst >'))title.render=()=>[];
|
|
271
261
|
this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
|
|
272
262
|
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
|
|
273
|
-
|
|
274
|
-
const historyText=text.trimStart().startsWith('!')?text:text.trim();
|
|
275
|
-
if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
|
|
276
|
-
this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
|
|
277
|
-
this.onHistoryChange?.();
|
|
278
|
-
}
|
|
263
|
+
if(this.homeInput)this.recordCommand(text);
|
|
279
264
|
resolve(text);
|
|
280
265
|
};component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
|
|
281
266
|
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();}
|
|
@@ -321,14 +306,41 @@ export class TerminalView{
|
|
|
321
306
|
if(this.homeInput)this.terminal.write(mouseOn);
|
|
322
307
|
this.editor=editor;editor.setText(this.homeInput?(this.homeDraft??value):value);
|
|
323
308
|
if(this.homeInput)this.homeDraft=undefined;
|
|
324
|
-
|
|
325
|
-
|
|
309
|
+
let provider;
|
|
310
|
+
if(files||commands.length){
|
|
311
|
+
// 预算属于整个终端;未退出的 worker 不能因切换输入框而被遗忘。
|
|
312
|
+
this.fileProbe??=createAutocompleteProbe(this.fileAutocompleteOptions);
|
|
313
|
+
provider=new FileAutocomplete(commands,this.baseDir,workflows,{...this.fileAutocompleteOptions,probe:this.fileProbe,ownsProbe:false,files,onUpdate:snapshot=>{
|
|
314
|
+
const cursor=editor.getCursor();
|
|
315
|
+
if(this.closed||this.editor!==editor||!this.pending||this.logViewer||this.runViewer||editor.getText()!==snapshot.lines.join('\n')||cursor.line!==snapshot.line||cursor.col!==snapshot.col)return;
|
|
316
|
+
// pi-tui 的 updateAutocomplete 只更新已打开的菜单;冷缓存要用此入口重新建立候选。
|
|
317
|
+
// 此调用只更新菜单,绝不调用 applyCompletion 或合成 Tab 按键。
|
|
318
|
+
editor.tryTriggerAutocomplete();this.tui.requestRender();
|
|
319
|
+
}});
|
|
320
|
+
this.fileProviders.add(provider);editor.setAutocompleteProvider(provider);
|
|
321
|
+
const input=editor.handleInput.bind(editor);
|
|
322
|
+
editor.handleInput=data=>{
|
|
323
|
+
const before=editor.getText();provider.cancelPending();input(data);
|
|
324
|
+
const cursor=editor.getCursor(),text=editor.getLines()[cursor.line].slice(0,cursor.col);
|
|
325
|
+
// 冷缓存时上游已收起菜单,继续输入不会走 updateAutocomplete,需再次触发查询。
|
|
326
|
+
if(editor.getText()!==before&&text&&!/\s$/.test(text)&&!editor.isShowingAutocomplete())editor.tryTriggerAutocomplete();
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return this.ask(message,editor).finally(async()=>{if(provider){await provider.close();this.fileProviders.delete(provider);}});
|
|
326
330
|
}
|
|
327
331
|
async select(message,items){
|
|
328
332
|
if(!items.length)throw new Error('当前没有可选择的项目');
|
|
329
|
-
const
|
|
333
|
+
const visibleItems=()=>items.filter(item=>!item.hidden?.()).map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined}));
|
|
334
|
+
const list=new SelectList(visibleItems(),8,theme);
|
|
330
335
|
list.render=width=>{
|
|
331
|
-
|
|
336
|
+
// 缩放窗口后保留当前选项;只显示此刻确实需要的展开入口。
|
|
337
|
+
const selected=list.getSelectedItem()?.value;
|
|
338
|
+
const previousIndex=list.selectedIndex;
|
|
339
|
+
list.filteredItems=visibleItems();
|
|
340
|
+
const selectedIndex=list.filteredItems.findIndex(item=>item.value===selected);
|
|
341
|
+
list.selectedIndex=selectedIndex>=0?selectedIndex:Math.min(previousIndex,list.filteredItems.length-1);
|
|
342
|
+
const text=typeof message==='function'?message(width,this.terminal.rows):message;
|
|
343
|
+
const reserved=this.headingLines(width).length+new Text(palette.text(safe(text)),1,0).render(width).length+this.hint.render(width).length+2;
|
|
332
344
|
list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
|
|
333
345
|
const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
|
|
334
346
|
const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
|
|
@@ -3,7 +3,7 @@ export async function workflowMenu(session){
|
|
|
3
3
|
while(true){
|
|
4
4
|
const workflows=await app.listWorkflows();
|
|
5
5
|
const choice=await ui.select('工作流 · 选择后编辑或执行',[
|
|
6
|
-
...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
|
|
6
|
+
...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:flow.status==='invalid'?flow.diagnostic.message:`${flow.id} · ${flow.steps.length} 步`})),
|
|
7
7
|
...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
|
|
8
8
|
...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
|
|
9
9
|
{value:'back',label:'返回主面板'},
|
|
@@ -11,6 +11,8 @@ export async function workflowMenu(session){
|
|
|
11
11
|
if(choice==='back')return;
|
|
12
12
|
try{
|
|
13
13
|
const id=choice.startsWith('workflow:')?choice.slice(9):null;
|
|
14
|
+
const selected=workflows.find(flow=>flow.id===id);
|
|
15
|
+
if(selected?.status==='invalid'){ui.show(`工作流不可用:${selected.diagnostic.message}`);continue;}
|
|
14
16
|
if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
15
17
|
else if(choice!=='draft'&&session.flow?.id!==id){
|
|
16
18
|
if(!await session.discard())continue;
|