@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.
Files changed (104) hide show
  1. package/README.md +3 -3
  2. package/manual/README.md +3 -3
  3. package/manual/docs/modules.md +2 -0
  4. package/manual/docs/plans/2026-10-13-effective-inputs-and-tables.md +39 -0
  5. package/manual/docs/plans/2026-10-13-plugin-defaults.md +45 -0
  6. package/manual/docs/plans/2026-10-14-issue7-recovery.md +33 -0
  7. package/manual/docs/plans/2026-10-14-issue8-10-workflows.md +26 -0
  8. package/manual/docs/plans/2026-10-14-issue9-completion.md +47 -0
  9. package/manual/docs/plans/2026-10-14-managed-execution.md +58 -0
  10. package/manual/docs/plans/2026-10-14-plugin-discovery-history.md +41 -0
  11. package/manual/docs/plans/2026-10-14-structured-reports.md +77 -0
  12. package/manual/docs/plans/2026-10-14-unreleased-review.md +47 -0
  13. package/manual/docs/plugin-development.md +161 -8
  14. package/manual/docs/releases.md +25 -6
  15. package/manual/docs/superpowers/plans/2026-10-13-tui-readable-sessions.md +2 -2
  16. package/manual/docs/superpowers/reviews/2026-10-13-tui-sessions-review.md +2 -0
  17. package/manual/docs/superpowers/reviews/2026-10-14-0.6.0-release-review.md +19 -0
  18. package/manual/docs/verification.md +17 -0
  19. package/manual/packages/core/README.md +6 -0
  20. package/manual/packages/core/src/application.js +15 -9
  21. package/manual/packages/core/src/plugin-catalog.js +20 -2
  22. package/manual/packages/core/src/plugin-loading.js +24 -0
  23. package/manual/packages/core/src/version.js +1 -1
  24. package/manual/packages/fst-cli/README.md +5 -1
  25. package/manual/packages/fst-cli/src/terminal/command-menu.js +3 -2
  26. package/manual/packages/fst-cli/src/terminal/file-autocomplete.js +106 -0
  27. package/manual/packages/fst-cli/src/terminal/log-viewer.js +4 -3
  28. package/manual/packages/fst-cli/src/terminal/navigation.js +23 -1
  29. package/manual/packages/fst-cli/src/terminal/presentation.js +2 -1
  30. package/manual/packages/fst-cli/src/terminal/report-presentation.js +38 -0
  31. package/manual/packages/fst-cli/src/terminal/result-table.js +66 -0
  32. package/manual/packages/fst-cli/src/terminal/run-feed.js +42 -17
  33. package/manual/packages/fst-cli/src/terminal/run-presentation.js +51 -17
  34. package/manual/packages/fst-cli/src/terminal/run-viewer.js +15 -12
  35. package/manual/packages/fst-cli/src/terminal/schema.js +114 -4
  36. package/manual/packages/fst-cli/src/terminal/session-history.js +2 -2
  37. package/manual/packages/fst-cli/src/terminal/session.js +40 -15
  38. package/manual/packages/fst-cli/src/terminal/view.js +80 -68
  39. package/manual/packages/fst-cli/src/terminal/workflow-menu.js +3 -1
  40. package/manual/packages/fst-devtools/README.md +2 -0
  41. package/manual/packages/fst-devtools/src/plugin-quality.js +4 -1
  42. package/manual/packages/fst-devtools/src/plugin-tools.js +1 -0
  43. package/manual/packages/fst-devtools/src/process-usage.js +62 -0
  44. package/manual/packages/fst-platform/README.md +5 -0
  45. package/manual/packages/fst-platform/package.json +1 -0
  46. package/manual/packages/fst-platform/src/managed-process.js +36 -0
  47. package/manual/packages/fst-platform/src/process.js +7 -2
  48. package/manual/packages/fst-plugin/README.md +4 -0
  49. package/manual/packages/fst-plugin/src/contracts.js +3 -0
  50. package/manual/packages/fst-plugin/src/host.js +38 -23
  51. package/manual/packages/fst-plugin/src/runtime.js +14 -8
  52. package/manual/packages/fst-workflow/src/engine.js +1 -1
  53. package/manual/packages/fst-workspace/README.md +4 -0
  54. package/manual/packages/fst-workspace/src/plugin-registry.js +1 -1
  55. package/manual/packages/fst-workspace/src/plugins.js +5 -3
  56. package/manual/packages/fst-workspace/src/workflows.js +27 -9
  57. package/manual/packages/sdk/README.md +10 -0
  58. package/manual/packages/sdk/inputs.js +53 -0
  59. package/manual/packages/sdk/package.json +2 -0
  60. package/manual/packages/sdk/reports.js +53 -0
  61. package/node_modules/@mytools/core/README.md +6 -0
  62. package/node_modules/@mytools/core/src/application.js +15 -9
  63. package/node_modules/@mytools/core/src/plugin-catalog.js +20 -2
  64. package/node_modules/@mytools/core/src/plugin-loading.js +24 -0
  65. package/node_modules/@mytools/core/src/version.js +1 -1
  66. package/node_modules/@mytools/fst-cli/README.md +5 -1
  67. package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +3 -2
  68. package/node_modules/@mytools/fst-cli/src/terminal/file-autocomplete.js +106 -0
  69. package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +4 -3
  70. package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +23 -1
  71. package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +2 -1
  72. package/node_modules/@mytools/fst-cli/src/terminal/report-presentation.js +38 -0
  73. package/node_modules/@mytools/fst-cli/src/terminal/result-table.js +66 -0
  74. package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +42 -17
  75. package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +51 -17
  76. package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +15 -12
  77. package/node_modules/@mytools/fst-cli/src/terminal/schema.js +114 -4
  78. package/node_modules/@mytools/fst-cli/src/terminal/session-history.js +2 -2
  79. package/node_modules/@mytools/fst-cli/src/terminal/session.js +40 -15
  80. package/node_modules/@mytools/fst-cli/src/terminal/view.js +80 -68
  81. package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +3 -1
  82. package/node_modules/@mytools/fst-devtools/README.md +2 -0
  83. package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +4 -1
  84. package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +1 -0
  85. package/node_modules/@mytools/fst-devtools/src/process-usage.js +62 -0
  86. package/node_modules/@mytools/fst-platform/README.md +5 -0
  87. package/node_modules/@mytools/fst-platform/package.json +1 -0
  88. package/node_modules/@mytools/fst-platform/src/managed-process.js +36 -0
  89. package/node_modules/@mytools/fst-platform/src/process.js +7 -2
  90. package/node_modules/@mytools/fst-plugin/README.md +4 -0
  91. package/node_modules/@mytools/fst-plugin/src/contracts.js +3 -0
  92. package/node_modules/@mytools/fst-plugin/src/host.js +38 -23
  93. package/node_modules/@mytools/fst-plugin/src/runtime.js +14 -8
  94. package/node_modules/@mytools/fst-workflow/src/engine.js +1 -1
  95. package/node_modules/@mytools/fst-workspace/README.md +4 -0
  96. package/node_modules/@mytools/fst-workspace/src/plugin-registry.js +1 -1
  97. package/node_modules/@mytools/fst-workspace/src/plugins.js +5 -3
  98. package/node_modules/@mytools/fst-workspace/src/workflows.js +27 -9
  99. package/node_modules/@mytools/sdk/README.md +10 -0
  100. package/node_modules/@mytools/sdk/inputs.js +53 -0
  101. package/node_modules/@mytools/sdk/package.json +2 -0
  102. package/node_modules/@mytools/sdk/reports.js +53 -0
  103. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1032 -1032
  104. 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
- return readableValue(output,'',schema);
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最终输出\n'+readableValue(this.record.output,'',this.outputSchema()):'',
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(node?node.input:this.record.input??this.record.events?.find(event=>event.type==='run.input')?.input,action?.input), '\n输出\n'+format(node?node.output:this.record.output,action?.output),
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 index=await ui.select(label,schema.enum.map((v,i)=>({value:String(i),label:JSON.stringify(v)})));
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))return (await ui.select(label,[{value:'true',label:'是 / true'},{value:'false',label:'否 / false'}]))==='true';
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=structuredClone(value??{}),props=Object.entries(schema.properties);
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.catalog?.()??[]});
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
- this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
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.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
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.commands().find(c=>c.name===command&&c.plugin);
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{input=validate(command.input,initial,command.description??command.name);}
178
- catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
179
- }else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
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&&current.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{process.removeListener('SIGTERM',terminate);await ui.close();}
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 { readdirSync, statSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
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?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
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 renderTranscript=this.transcript.render.bind(this.transcript);
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.closed)return;this.closed=true;
155
- this.feed.close();
156
- // Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
157
- await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
158
- if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
159
- if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
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.lastTranscript=text;this.transcript.setText(text.split('\n').map(line=>line.startsWith('✗ ')?palette.error(line):/^[›✓●] /.test(line)?palette.brand(line):line.startsWith('↩ ')?palette.muted(line):line).join('\n'));this.tui.requestRender();this.onHistoryChange?.();
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:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
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.catalog?.()??[],terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),onReadLogs:app.readLogPage?()=>{readPages=true;resolve();}:undefined,onReadOutput:()=>{renderedOutput=viewer.getRenderedOutput();resolve();},requestRender:()=>this.tui.requestRender()});
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(palette.brand('› ')+palette.text(safe(message)),1,0);
268
- const renderTitle=title.render.bind(title);
269
- title.render=width=>[fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...renderTitle(width)];
270
- if(message.startsWith('fst >'))title.render=()=>[];
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
- // Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
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
- if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir,workflows));
325
- return this.ask(message,editor);
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 list=new SelectList(items.map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined})),8,theme);
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
- const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+2;
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;