@yanglaofish/fishtools 0.1.4 → 0.2.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 (77) hide show
  1. package/README.md +46 -18
  2. package/manual/LICENSE +21 -0
  3. package/manual/README.md +173 -0
  4. package/manual/THIRD_PARTY_NOTICES.md +32 -0
  5. package/manual/docs/examples/action-plugin/LICENSE +21 -0
  6. package/manual/docs/examples/action-plugin/README.md +38 -0
  7. package/manual/docs/examples/action-plugin/index.js +18 -0
  8. package/manual/docs/examples/action-plugin/input.json +1 -0
  9. package/manual/docs/examples/action-plugin/package.json +9 -0
  10. package/manual/docs/plans/2026-10-10-workbench-ui.md +27 -0
  11. package/{docs → manual/docs}/plugin-development.md +151 -19
  12. package/{docs → manual/docs}/releases.md +13 -1
  13. package/manual/docs/roadmap.md +85 -0
  14. package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +115 -0
  15. package/manual/docs/superpowers/plans/2026-10-10-cordis-services.md +34 -0
  16. package/manual/docs/superpowers/plans/2026-10-10-fst-terminal.md +33 -0
  17. package/manual/docs/superpowers/plans/2026-10-10-portable-plugin-contract.md +35 -0
  18. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +77 -0
  19. package/manual/docs/superpowers/specs/2026-10-09-workflow-cli-design.md +377 -0
  20. package/manual/docs/superpowers/specs/2026-10-10-cordis-services-design.md +88 -0
  21. package/manual/docs/superpowers/specs/2026-10-10-portable-plugin-contract.md +31 -0
  22. package/manual/docs/superpowers/specs/2026-10-10-run-logs.md +28 -0
  23. package/manual/docs/superpowers/specs/2026-10-11-control-observation.md +37 -0
  24. package/{docs → manual/docs}/verification.md +14 -1
  25. package/manual/examples/service-plugins/README.md +20 -0
  26. package/manual/examples/service-plugins/consumer/LICENSE +21 -0
  27. package/manual/examples/service-plugins/consumer/index.js +14 -0
  28. package/manual/examples/service-plugins/consumer/package.json +1 -0
  29. package/manual/examples/service-plugins/provider/LICENSE +21 -0
  30. package/manual/examples/service-plugins/provider/index.js +19 -0
  31. package/manual/examples/service-plugins/provider/package.json +1 -0
  32. package/manual/packages/core/LICENSE +21 -0
  33. package/manual/packages/core/src/host.js +122 -0
  34. package/manual/packages/sdk/LICENSE +21 -0
  35. package/manual/packages/sdk/index.js +14 -0
  36. package/manual/packages/sdk/package.json +1 -0
  37. package/manual/plugins/environment/LICENSE +21 -0
  38. package/manual/plugins/environment/index.js +92 -0
  39. package/manual/plugins/environment/package.json +1 -0
  40. package/manual/plugins/examples/LICENSE +21 -0
  41. package/manual/plugins/examples/index.js +23 -0
  42. package/manual/plugins/examples/package.json +1 -0
  43. package/manual/plugins/examples/scripts/echo.py +5 -0
  44. package/manual/tests/application.test.js +45 -0
  45. package/node_modules/@mytools/core/bin/fst.js +12 -4
  46. package/node_modules/@mytools/core/src/application.js +9 -4
  47. package/node_modules/@mytools/core/src/cli-run.js +6 -0
  48. package/node_modules/@mytools/core/src/contracts.js +15 -2
  49. package/node_modules/@mytools/core/src/control.js +63 -0
  50. package/node_modules/@mytools/core/src/documentation.js +65 -0
  51. package/node_modules/@mytools/core/src/engine.js +80 -26
  52. package/node_modules/@mytools/core/src/host.js +13 -2
  53. package/node_modules/@mytools/core/src/markdown-log.js +8 -5
  54. package/node_modules/@mytools/core/src/process.js +35 -14
  55. package/node_modules/@mytools/core/src/project.js +1 -1
  56. package/node_modules/@mytools/core/src/run-logs.js +1 -1
  57. package/node_modules/@mytools/core/src/terminal/conditions.js +32 -0
  58. package/node_modules/@mytools/core/src/terminal/log-viewer.js +7 -8
  59. package/node_modules/@mytools/core/src/terminal/model.js +32 -3
  60. package/node_modules/@mytools/core/src/terminal/presentation.js +7 -9
  61. package/node_modules/@mytools/core/src/terminal/run-viewer.js +86 -0
  62. package/node_modules/@mytools/core/src/terminal/session.js +109 -22
  63. package/node_modules/@mytools/core/src/terminal/theme.js +28 -0
  64. package/node_modules/@mytools/core/src/terminal/view.js +74 -19
  65. package/node_modules/@mytools/core/src/validation.js +15 -4
  66. package/node_modules/@mytools/core/src/version.js +1 -1
  67. package/node_modules/@mytools/core/src/workflows.js +30 -19
  68. package/node_modules/@mytools/core/templates/control-demo.yaml +47 -0
  69. package/node_modules/@mytools/environment/index.js +14 -2
  70. package/node_modules/@mytools/pack/index.js +2 -0
  71. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +215 -216
  72. package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +70 -0
  73. package/node_modules/@mytools/web/public/assets/{index-BeV6IqTW.css → index-vuZvrUTT.css} +1 -1
  74. package/node_modules/@mytools/web/public/index.html +2 -2
  75. package/package.json +2 -2
  76. package/node_modules/@mytools/web/public/assets/index-TgsBnZgG.js +0 -70
  77. /package/{docs → manual/docs}/deployment.md +0 -0
@@ -38,8 +38,21 @@ export async function readRunInput(reference,baseDir){
38
38
 
39
39
  export const newWorkflow=(title,description)=>({schemaVersion:1,id:`flow-${randomUUID().slice(0,8)}`,title,description,inputs:{type:'object',properties:{}},steps:[],outputs:{}});
40
40
 
41
+ // 路径从根 steps 开始,例如 [1, 'branch', 'then', 0]。
42
+ export const stepAt=(flow,index)=>[...(Array.isArray(index)?index:[index])].reduce((node,key)=>node[key],flow.steps);
43
+ export const sequenceAt=(flow,parts=[])=>parts.reduce((node,key)=>node[key],flow.steps);
44
+ export function precedingSteps(flow,index){
45
+ const parts=Array.isArray(index)?index:[index];let sequence=flow.steps;const result=[];
46
+ for(let i=0;i<parts.length;i+=3){
47
+ // 并行子项同属一批,不能把前面的兄弟当成已完成步骤。
48
+ if(i===0||parts[i-2]!=='parallel')result.push(...sequence.slice(0,parts[i]));
49
+ if(i+1<parts.length)sequence=sequence[parts[i]][parts[i+1]][parts[i+2]];
50
+ }
51
+ return result;
52
+ }
53
+
41
54
  export function setBinding(flow,index,key,mode,schema,value){
42
- const step=flow.steps[index],old=step.with?.[key];step.with??={};
55
+ const step=stepAt(flow,index),old=step.with?.[key];step.with??={};
43
56
  if(mode==='runtime'){
44
57
  flow.inputs??={type:'object',properties:{}};flow.inputs.properties??={};
45
58
  const name=`fst_${step.id}_${key}`;
@@ -60,15 +73,31 @@ export function setBinding(flow,index,key,mode,schema,value){
60
73
  }
61
74
  }
62
75
 
76
+ export function pruneRuntimeInputs(flow){
77
+ const refs=new Set();
78
+ const scan=value=>{if(value&&typeof value==='object'){if(typeof value.$ref==='string')refs.add(value.$ref);Object.values(value).forEach(scan);}};
79
+ scan(flow.steps);scan(flow.outputs);
80
+ for(const name of Object.keys(flow.inputs?.properties??{})){
81
+ if(name.startsWith('fst_')&&!refs.has(`inputs.${name}`)){
82
+ delete flow.inputs.properties[name];flow.inputs.required=(flow.inputs.required??[]).filter(key=>key!==name);
83
+ }
84
+ }
85
+ }
86
+
63
87
  export function stepMetadata(step,catalog,workflows){
88
+ if(step.branch||step.parallel){
89
+ const children=step.parallel?.steps??[...(step.branch.then??[]),...(step.branch.else??[])];
90
+ return {title:step.title??(step.branch?'条件分支':'并行组'),output:{type:'object',properties:Object.fromEntries(children.map(child=>[child.id,stepMetadata(child,catalog,workflows)?.output??{}]))}};
91
+ }
92
+ if(step.stop)return {title:step.title??'提前结束',output:{type:'object',properties:{}}};
64
93
  if(step.action)return catalog.find(a=>a.id===step.action);
65
- const flow=workflows.find(w=>`./${w.id}.yaml`===step.workflow);
94
+ const flow=workflows.find(w=>`${w.id}.yaml`===step.workflow?.replace(/^\.\//,''));
66
95
  return flow?{title:flow.title??flow.id,description:flow.description,input:flow.inputs,output:{type:'object',properties:Object.fromEntries(Object.keys(flow.outputs??{}).map(k=>[k,{}]))}}:null;
67
96
  }
68
97
 
69
98
  export function outputSources(flow,index,catalog,workflows){
70
99
  const flatten=(schema,prefix='')=>Object.entries(schema?.properties??{}).flatMap(([key,s])=>{const name=prefix?`${prefix}.${key}`:key;return [{name,schema:s},...flatten(s,name)];});
71
- return flow.steps.slice(0,index).flatMap((step,i)=>{
100
+ return precedingSteps(flow,index).flatMap((step,i)=>{
72
101
  const meta=stepMetadata(step,catalog,workflows),base=`steps.${step.id}.output`,label=`${i+1}. ${meta?.title??step.id}`;
73
102
  return [{value:base,label:`${label} → 完整输出`,schema:meta?.output},...flatten(meta?.output).map(f=>({value:`${base}.${f.name}`,label:`${label} → ${f.schema.title??f.name}`,schema:f.schema}))];
74
103
  });
@@ -1,9 +1,7 @@
1
1
  export const logo=[
2
- 'FFFFF IIIII SSSS H H t l',
3
- 'F I S H H ttt oo oo l sss',
4
- 'FFFF I SSS HHHHH t o o o o l ss',
5
- 'F I S H H t o o o o l ss',
6
- 'F IIIII SSSS H H tt oo oo l sss',
2
+ '█▀▀ ▀█▀ █▀▀ █ █',
3
+ '█▀ █ ▀▀█ █▀█',
4
+ '▀ ▀▀▀ ▀▀▀ ▀ ▀',
7
5
  ].join('\n');
8
6
 
9
7
  export const pluginStatus=plugin=>({ready:'可用',pending:'等待服务',failed:'失败',stopped:'已停止'})[plugin.runtime?.status]??'未知';
@@ -11,10 +9,10 @@ export const pluginStatus=plugin=>({ready:'可用',pending:'等待服务',failed
11
9
  export function gettingStarted(flows,{readOnly=false}={}){
12
10
  const sample=flows.find(w=>w.id==='hello')??flows[0];
13
11
  return [
14
- sample?`先试一次:/run ${sample.id} → ${sample.title??sample.id}\n${sample.description??'按提示填写参数,执行后可用 /logs 查看结果和日志。'}`:'当前工作区还没有工作流。',
15
- `/workflows 浏览工作流 /plugins 查看已安装插件`,
16
- ...(!readOnly?['自己编排:/new 新建 → /add 添加步骤 → /save 保存 → /run 执行']:['这是只读运行包:可选择工作流、填写运行参数并执行。']),
17
- '/help 查看完整用法和文件参数示例',
12
+ sample?`试运行 /run ${sample.id}\n${sample.description??sample.title??'按提示填写参数,执行后查看结果。'}`:'当前工作区还没有工作流。',
13
+ '',
14
+ `/workflows 工作流 /plugins 插件`,
15
+ `${readOnly?'只读运行包': '/new 新建'} /runs 运行观察 /help 帮助`,
18
16
  ].join('\n');
19
17
  }
20
18
 
@@ -0,0 +1,86 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { Text, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
3
+ import {palette} from './theme.js';
4
+
5
+ export const runStatuses={pending:'待执行',running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',skipped:'已跳过',stopped:'已停止'};
6
+ const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
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;};
8
+ export const mouseOn='\x1b[?1000h\x1b[?1006h';
9
+ export const mouseOff='\x1b[?1006l\x1b[?1000l';
10
+
11
+ export class RunViewer{
12
+ constructor({record,flow,workflows=[],terminal,onClose,onCancel,requestRender}){
13
+ Object.assign(this,{terminal,onClose,onCancel,requestRender});this.view=0;this.selected=0;this.offset=0;this.follow=true;this.allLogs=false;this.confirmCancel=false;
14
+ this.planned=[];
15
+ const walk=(steps,prefix,ancestors)=>{
16
+ for(const step of steps??[]){
17
+ const callPath=`${prefix}/${step.id}`;this.planned.push({callPath,stepId:step.id,action:step.action,title:step.title,status:'pending'});
18
+ if(step.branch){walk(step.branch.then,callPath,ancestors);walk(step.branch.else,callPath,ancestors);}
19
+ if(step.parallel)walk(step.parallel.steps,callPath,ancestors);
20
+ if(step.workflow){const child=workflows.find(w=>`${w.id}.yaml`===step.workflow.replace(/^\.\//,''));if(child&&!ancestors.has(child.id))walk(child.steps,callPath,new Set([...ancestors,child.id]));}
21
+ }
22
+ };
23
+ if(flow)walk(flow.steps,flow.id,new Set([flow.id]));
24
+ this.update(record);
25
+ }
26
+ invalidate(){}
27
+ update(record){
28
+ if(!record)return;
29
+ this.record=record;const nodes=new Map(this.planned.map(node=>[node.callPath,{...node}]));
30
+ for(const event of record.events??[]){
31
+ if(!event.type.startsWith('step.')||!event.callPath)continue;
32
+ const node=nodes.get(event.callPath)??{callPath:event.callPath,stepId:event.stepId};
33
+ Object.assign(node,event,{status:event.type==='step.started'?'running':event.type.slice(5)});nodes.set(event.callPath,node);
34
+ }
35
+ this.nodes=[...nodes.values()];this.selected=Math.max(0,Math.min(this.selected,this.nodes.length-1));
36
+ this.requestRender();
37
+ }
38
+ content(){
39
+ const node=this.nodes[this.selected],status=runStatuses[this.record.status]??this.record.status;
40
+ if(this.view===0)return [`运行:${status}${this.record.reason?` · ${this.record.reason}`:''}`,this.record.error?`错误:${this.record.error.message}`:'',...this.nodes.map((n,i)=>`${i===this.selected?'›':' '} ${runStatuses[n.status]??n.status} · ${n.callPath}${n.action?` · ${n.action}`:''}${n.message?` · ${n.message}`:''}`)].filter(Boolean).join('\n');
41
+ if(this.view===1)return `步骤:${node?.callPath??'运行结果'}\n输入\n${JSON.stringify(node?.input??{},null,2)}\n输出\n${JSON.stringify(node?.output??{},null,2)}${node?.error?`\n错误\n${JSON.stringify(node.error,null,2)}`:''}${node?.reason||node?.message?`\n说明:${node.reason??node.message}`:''}\n运行状态:${status}${this.record.reason?` · ${this.record.reason}`:''}\n汇总输出\n${JSON.stringify(this.record.output??{},null,2)}`;
42
+ const logs=(this.record.events??[]).filter(e=>e.type==='log'&&(this.allLogs||!node||e.callPath===node.callPath));
43
+ return logs.length?logs.map(e=>`${this.allLogs?`[${e.callPath??'运行'}] `:''}[${e.stream??'日志'}] ${e.message??''}${e.data?` ${JSON.stringify(e.data)}`:''}`).join('\n'):'尚无日志';
44
+ }
45
+ layout(width){
46
+ const height=Math.max(1,this.terminal.rows),pageSize=Math.max(1,height-3);
47
+ const lines=new Text(safe(this.content()),0,0).render(Math.max(1,width));
48
+ const maxOffset=Math.max(0,lines.length-pageSize);
49
+ this.offset=this.follow&&this.view===2?maxOffset:Math.max(0,Math.min(this.offset,maxOffset));
50
+ return {height,pageSize,lines,maxOffset};
51
+ }
52
+ render(width){
53
+ const {height,pageSize,lines}=this.layout(width),node=this.nodes[this.selected];
54
+ const body=lines.slice(this.offset,this.offset+pageSize);while(body.length<pageSize)body.push('');
55
+ const tabs=`${['1 总览','2 输入输出','3 日志'].map((s,i)=>this.view===i?palette.brand(`[${s}]`):palette.muted(s)).join(' ')} ${palette.muted('·')} ${palette.text(safe(runStatuses[this.record.status]??this.record.status))} ${palette.muted('· '+safe(this.record.workflowId??'运行'))}`;
56
+ const selection=`[ / ] 切换步骤:${node?.callPath??'暂无步骤'}${this.view===2?` · ${this.allLogs?'全部日志':'本步骤日志'} · ${this.follow?'跟随尾部':'暂停跟随'}`:''}`;
57
+ const footer=this.confirmCancel?'取消整个运行?y 确认 · 任意其他键返回':`q/Esc 关闭 · x 取消 · ↑↓/滚轮 PgUp/PgDn · End 跟随 · a 全部日志 · ${this.offset+1}–${Math.min(this.offset+pageSize,lines.length)}/${lines.length}`;
58
+ const styledBody=body.map(line=>line.startsWith('›')?palette.brand(line):line);
59
+ const styledFooter=palette.muted(safe(footer));
60
+ const screen=height===1?styledBody:height===2?[styledBody[0],styledFooter]:height===3?[tabs,styledBody[0],styledFooter]:[tabs,palette.muted(safe(selection)),...styledBody,styledFooter];
61
+ return screen.map(line=>truncateToWidth(line,width));
62
+ }
63
+ handleInput(data){
64
+ if(this.confirmCancel){this.confirmCancel=false;if(data==='y')this.onCancel();this.requestRender();return;}
65
+ if(['escape','q','enter'].some(key=>matchesKey(data,key))){this.onClose();return;}
66
+ if(data==='x'&&this.record.status==='running'){this.confirmCancel=true;this.requestRender();return;}
67
+ if(['1','2','3'].includes(data)){this.view=Number(data)-1;this.offset=0;this.follow=true;this.requestRender();return;}
68
+ if(data==='['||data===']'){
69
+ this.selected=Math.max(0,Math.min(this.nodes.length-1,this.selected+(data===']'?1:-1)));this.offset=0;
70
+ if(this.view===0)this.offset=Math.max(0,this.selected-Math.floor((this.terminal.rows-3)/2));
71
+ this.requestRender();return;
72
+ }
73
+ if(data==='a'){this.allLogs=!this.allLogs;this.offset=0;this.requestRender();return;}
74
+ if(data==='f'){this.follow=!this.follow;this.requestRender();return;}
75
+ const {pageSize,maxOffset}=this.layout(this.terminal.columns),wheel=mouseWheel(data);
76
+ if(wheel)this.offset+=wheel*3;
77
+ else if(matchesKey(data,'up'))this.offset--;
78
+ else if(matchesKey(data,'down'))this.offset++;
79
+ else if(matchesKey(data,'pageUp'))this.offset-=pageSize;
80
+ else if(matchesKey(data,'pageDown'))this.offset+=pageSize;
81
+ else if(matchesKey(data,'home'))this.offset=0;
82
+ else if(matchesKey(data,'end')){this.offset=maxOffset;this.follow=true;this.requestRender();return;}
83
+ else return;
84
+ this.follow=false;this.offset=Math.max(0,Math.min(maxOffset,this.offset));this.requestRender();
85
+ }
86
+ }
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import path from 'node:path';
3
- import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources } from './model.js';
3
+ import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
4
+ import { editCondition } from './conditions.js';
5
+ import { runStatuses } from './run-viewer.js';
4
6
  import { editValue, needsJsonEditor } from './schema.js';
5
7
  import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
6
8
 
@@ -10,14 +12,23 @@ export class TerminalSession{
10
12
  current(){if(!this.flow)throw new Error('先用 /workflows 打开工作流,或用 /new 新建');return this.flow;}
11
13
  commands(){return [...commands,...this.app.listCommands()];}
12
14
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
15
+ async mayExit(){
16
+ if(!await this.discard())return false;
17
+ const active=this.app.listRuns().filter(run=>run.status==='running');
18
+ if(active.length){
19
+ if(!await this.ui.confirm(`仍有 ${active.length} 个后台任务。退出工作台将取消这些任务,是否退出?`))return false;
20
+ for(const run of active)this.app.cancelRun(run.id);
21
+ }
22
+ return true;
23
+ }
13
24
  async run(){
14
- this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project));
25
+ this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
15
26
  try{
16
27
  while(!this.exiting){
17
- 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.flow.steps.length} 步,${this.dirty?'未保存':'已保存'})`:''}`);
28
+ 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?'未保存':'已保存'}`:''}`);
18
29
  let text;
19
- try{text=await this.ui.input('fst > 输入 / 选择操作',{commands:this.commands()});}
20
- catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.discard())break;}catch{}continue;}
30
+ try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands()});}
31
+ catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
21
32
  if(!text.trim())continue;
22
33
  try{await this.execute(text);}catch(e){this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回,未完成的修改已放弃。':`操作失败:${e.message}`);}
23
34
  }
@@ -29,8 +40,27 @@ export class TerminalSession{
29
40
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
30
41
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
31
42
  switch(command){
32
- case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',`# 从这里开始\n\n${gettingStarted(await this.app.listWorkflows(),this.app.project)}\n\n## 文件与运行参数\n\n输入 @ 后按 Tab 选择文件。相对路径以 ${this.baseDir} 为起点。\n\n参数字段中 @文件 表示文件路径;运行命令中的 @参数.json 会读取 JSON 对象作为本次输入。\n\n例如:/run 工作流ID @"参数 文件.json"\n\n## 全部操作\n\n${this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n')}`);return;
33
- case 'exit':if(await this.discard())this.exiting=true;return;
43
+ case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
44
+ '# Fishtools',
45
+ '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
46
+ `当前工作区:${this.app.projectDir}`,
47
+ `文件路径起点:${this.baseDir}`,
48
+ '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
49
+ '## 编排工作流','/new 新建 → /add 添加步骤 → /steps 编辑参数、条件与分组 → /save 保存 → /run 执行。',
50
+ '## 观察运行','/runs 打开观察面板。1 / 2 / 3 切换步骤总览、输入输出和日志,[ / ] 切换步骤。↑↓、PgUp/PgDn 或滚轮浏览;End 恢复尾部跟随。q / Esc 关闭后任务在后台继续,x 后 y 明确取消。',
51
+ '## 文件与运行参数','输入 @ 后按 Tab 选择文件;@@ 表示字面量 @。运行命令的 @参数.json 会读取 JSON 对象作为输入。',
52
+ '/run 工作流ID @"参数 文件.json"',
53
+ '## 离线文档','输入 /docs 阅读插件开发、部署、控制流与服务文档。独立命令 fst docs --path 查看资料位置,fst docs --export 目标目录 导出资料。',
54
+ '## 全部操作',this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n'),
55
+ ].join('\n\n'));return;
56
+ case 'docs':{
57
+ const {listDocumentation,readDocumentation}=await import('../documentation.js');
58
+ const topics=await listDocumentation();
59
+ const topic=await this.ui.select('离线文档', [...topics.map(item=>({value:item.id,label:item.title})),{value:'back',label:'返回工作台'}]);
60
+ if(topic!=='back'){const document=await readDocumentation(topic);await this.ui.viewMarkdown(document.title,document.text);}
61
+ return;
62
+ }
63
+ case 'exit':if(await this.mayExit())this.exiting=true;return;
34
64
  case 'workflows':{
35
65
  const flows=await this.app.listWorkflows();
36
66
  if(!flows.length){this.ui.show('没有工作流,输入 /new 新建。');return;}
@@ -49,6 +79,7 @@ export class TerminalSession{
49
79
  case 'outputs':return this.outputs();
50
80
  case 'save':this.writable();await this.app.saveWorkflow(this.current());this.dirty=false;this.ui.show(`已保存:${this.flow.title}`);return;
51
81
  case 'run':return this.startRun(args);
82
+ case 'runs':return this.runs();
52
83
  case 'logs':return this.logs();
53
84
  case 'plugins':return this.plugins();
54
85
  case 'config':return this.configure();
@@ -80,7 +111,7 @@ export class TerminalSession{
80
111
  await this.app.configurePlugin(id,value);this.ui.show('插件配置已保存到当前工作区。');
81
112
  }
82
113
  async logs(){
83
- const statuses={running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消'};
114
+ const statuses=runStatuses;
84
115
  while(true){
85
116
  const history=await this.app.listRunHistory(),keep=this.app.logSettings().keepRuns;
86
117
  const choice=await this.ui.select(`运行日志 · ${keep===null?'不自动清理':`项目保留最近 ${keep} 次`}`,[
@@ -101,17 +132,67 @@ export class TerminalSession{
101
132
  }
102
133
  async add(){
103
134
  this.writable();const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
104
- const choice=await this.ui.select('添加一个能力或子工作流',[
135
+ await this.addNode(flow,[],catalog,workflows);
136
+ this.flow=flow;this.dirty=true;this.ui.show('步骤已添加。/add 继续添加,/steps 调整,/save 保存。');
137
+ }
138
+ async addNode(flow,parent,catalog,workflows){
139
+ this.writable();
140
+ const choice=await this.ui.select('添加能力、子工作流或控制节点',[
105
141
  ...catalog.map(a=>({value:`action:${a.id}`,label:a.title??a.id,description:a.description})),
106
142
  ...workflows.filter(w=>w.id!==flow.id).map(w=>({value:`workflow:${w.id}`,label:`工作流:${w.title}`,description:w.description})),
143
+ {value:'control:branch',label:'条件分支'},{value:'control:parallel',label:'并行组'},{value:'control:stop',label:'提前结束'},
107
144
  ]);
108
- const step={id:`step-${randomUUID().slice(0,8)}`,with:{}};
109
- if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;
110
- flow.steps.push(step);await this.editBindings(flow,flow.steps.length-1,catalog,workflows);
111
- this.flow=flow;this.dirty=true;this.ui.show('步骤已添加。/add 继续添加,/steps 调整,/save 保存。');
145
+ const sequence=sequenceAt(flow,parent),step={id:`step-${randomUUID().slice(0,8)}`},index=[...parent,sequence.length];
146
+ sequence.push(step);
147
+ if(choice==='control:branch')step.branch={condition:await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir}),then:[],else:[]};
148
+ else if(choice==='control:parallel')step.parallel={concurrency:4,steps:[]};
149
+ else if(choice==='control:stop')step.stop={reason:''};
150
+ else{step.with={};if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;}
151
+ await this.editNode(flow,index,catalog,workflows);
152
+ }
153
+ async editNode(flow,index,catalog,workflows){
154
+ this.writable();const step=stepAt(flow,index);
155
+ if(step.stop){step.stop.reason=await this.ui.input('提前结束原因',{value:step.stop.reason,files:false});return;}
156
+ if(step.parallel){
157
+ const value=Number(await this.ui.input('最大并发数(1–32)',{value:String(step.parallel.concurrency??4),files:false}));
158
+ if(!Number.isInteger(value)||value<1||value>32)throw new Error('并发数必须为 1–32 的整数');
159
+ step.parallel.concurrency=value;await this.editSequence(flow,[...index,'parallel','steps'],catalog,workflows);return;
160
+ }
161
+ if(step.branch){
162
+ step.branch.else??=[];
163
+ while(true){
164
+ const choice=await this.ui.select('条件分支', [{value:'condition',label:'修改判断条件'},{value:'then',label:`条件成立 · ${step.branch.then.length} 步`},{value:'else',label:`条件不成立 · ${step.branch.else.length} 步`},{value:'done',label:'完成分支编辑'}]);
165
+ if(choice==='done')return;
166
+ if(choice==='condition')step.branch.condition=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.branch.condition});
167
+ else await this.editSequence(flow,[...index,'branch',choice],catalog,workflows);
168
+ }
169
+ }
170
+ return this.editBindings(flow,index,catalog,workflows);
171
+ }
172
+ async editSequence(flow,parent,catalog,workflows){
173
+ const sequence=sequenceAt(flow,parent);
174
+ while(true){
175
+ const choice=await this.ui.select('嵌套步骤', [...sequence.map((s,i)=>({value:`step:${i}`,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`})),{value:'add',label:'添加步骤'},{value:'done',label:'完成此组'}]);
176
+ if(choice==='done')return;
177
+ if(choice==='add')await this.addNode(flow,parent,catalog,workflows);
178
+ else await this.stepOperation(flow,[...parent,Number(choice.slice(5))],catalog,workflows);
179
+ }
180
+ }
181
+ async stepOperation(flow,index,catalog,workflows){
182
+ this.writable();const step=stepAt(flow,index),parent=index.slice(0,-1),position=index.at(-1),sequence=sequenceAt(flow,parent);
183
+ const action=await this.ui.select('步骤操作',[{value:'edit',label:step.branch||step.parallel||step.stop?'编辑控制节点与嵌套步骤':'设置参数'},{value:'when',label:'设置执行条件'},...(step.when?[{value:'clear-when',label:'移除执行条件'}]:[]),...(position>0?[{value:'up',label:'上移一步'}]:[]),...(position<sequence.length-1?[{value:'down',label:'下移一步'}]:[]),{value:'remove',label:'删除此步骤'}]);
184
+ if(action==='edit')await this.editNode(flow,index,catalog,workflows);
185
+ else if(action==='when')step.when=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.when});
186
+ else if(action==='clear-when')delete step.when;
187
+ else if(action==='remove'){
188
+ if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
189
+ for(const key of Object.keys(step.with??{}))setBinding(flow,index,key,'fixed',{},undefined);
190
+ sequence.splice(position,1);
191
+ pruneRuntimeInputs(flow);
192
+ }else{const to=position+(action==='up'?-1:1);[sequence[position],sequence[to]]=[sequence[to],sequence[position]];}
112
193
  }
113
194
  async editBindings(flow,index,catalog,workflows){
114
- const step=flow.steps[index],meta=stepMetadata(step,catalog,workflows);
195
+ const step=stepAt(flow,index),meta=stepMetadata(step,catalog,workflows);
115
196
  if(!meta)throw new Error('该步骤所需插件或子工作流不可用');
116
197
  if(needsJsonEditor(meta.input)||!meta.input?.properties){
117
198
  step.with=await editValue(this.ui,meta.input??{type:'object'},step.with,{baseDir:this.baseDir,required:true,label:`${meta.title} · 完整参数 JSON`});return;
@@ -144,13 +225,7 @@ export class TerminalSession{
144
225
  const id=await this.ui.select('选择步骤',flow.steps.map((s,i)=>({value:s.id,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`,description:JSON.stringify(s.with)})));
145
226
  const index=flow.steps.findIndex(s=>s.id===id);
146
227
  if(this.app.project.readOnly){this.ui.show(JSON.stringify(flow.steps[index],null,2));return;}
147
- const action=await this.ui.select('步骤操作',[{value:'edit',label:'设置参数'},...(index>0?[{value:'up',label:'上移一步'}]:[]),...(index<flow.steps.length-1?[{value:'down',label:'下移一步'}]:[]),{value:'remove',label:'删除此步骤'}]);
148
- if(action==='edit')await this.editBindings(flow,index,catalog,workflows);
149
- else if(action==='remove'){
150
- if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
151
- for(const key of Object.keys(flow.steps[index].with??{}))setBinding(flow,index,key,'fixed',{},undefined);
152
- flow.steps.splice(index,1);
153
- }else{const to=index+(action==='up'?-1:1);[flow.steps[index],flow.steps[to]]=[flow.steps[to],flow.steps[index]];}
228
+ await this.stepOperation(flow,[index],catalog,workflows);
154
229
  this.flow=flow;this.dirty=true;this.ui.show('步骤已更新,保存前可继续调整。');
155
230
  }
156
231
  async outputs(){
@@ -179,15 +254,27 @@ export class TerminalSession{
179
254
  await this.app.checkWorkflow(flow.id);
180
255
  const input=reference?await readRunInput(reference,this.baseDir):(needsJsonEditor(flow.inputs)||Object.keys(flow.inputs?.properties??{}).length)?await editValue(this.ui,flow.inputs,{}, {baseDir:this.baseDir,label:'本次运行参数',required:true}):{};
181
256
  const run=await this.app.startRun(flow.id,input);
257
+ if(this.ui.observeRun){
258
+ await this.ui.observeRun({app:this.app,runId:run.id,flow,workflows:await this.app.listWorkflows()});
259
+ this.ui.show(`已关闭运行观察面板。输入 /runs 重新查看,/logs 查看历史日志。`);return;
260
+ }
182
261
  const unsubscribe=this.app.subscribe(e=>{if(e.runId===run.id&&e.type.startsWith('step.'))this.ui.show(`${e.type} · ${e.callPath}${e.message?' · '+e.message:''}`);});
183
262
  const done=this.ui.busy('运行中 · Ctrl+C 取消当前运行',()=>this.app.cancelRun(run.id));
184
263
  try{
185
264
  const result=await run.done;
186
- this.ui.show(`运行${result.status==='succeeded'?'成功':result.status==='cancelled'?'已取消':'失败'}\n${JSON.stringify(result.status==='succeeded'?result.output:result.error,null,2)}`);
265
+ this.ui.show(`运行${runStatuses[result.status]??result.status}\n${JSON.stringify(result.reason??(result.status==='succeeded'?result.output:result.error),null,2)}`);
187
266
  if(result.status==='succeeded'&&!Object.keys(result.output??{}).length)this.ui.show(JSON.stringify(result.events.filter(e=>e.type==='step.succeeded').map(e=>({step:e.callPath,output:e.output})),null,2));
188
267
  this.ui.show(`输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}\n结果目录:${path.join(this.app.outputDir,run.id)}`);
189
268
  }finally{unsubscribe();done();}
190
269
  }
270
+ async runs(){
271
+ const runs=this.app.listRuns();
272
+ if(!runs.length){this.ui.show('当前会话还没有运行。/run 执行工作流,/logs 查看历史记录。');return;}
273
+ const id=await this.ui.select('运行观察 · 关闭面板后后台继续', [...runs.slice().reverse().map(run=>({value:run.id,label:`${runStatuses[run.status]??run.status} · ${run.workflowId}`,description:run.id})),{value:'back',label:'返回工作台'}]);
274
+ if(id==='back')return;
275
+ const workflows=await this.app.listWorkflows(),record=this.app.getRun(id);
276
+ await this.ui.observeRun({app:this.app,runId:id,flow:workflows.find(flow=>flow.id===record.workflowId),workflows});
277
+ }
191
278
  async plugins(){
192
279
  this.ui.clearMessages?.();
193
280
  while(true){
@@ -0,0 +1,28 @@
1
+ import {stripVTControlCharacters} from 'node:util';
2
+ import {truncateToWidth,visibleWidth} from '@mariozechner/pi-tui';
3
+
4
+ // 先清理外部文本,再由受控主题函数添加颜色;不接受插件传入的 ANSI。
5
+ export const cleanText=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
6
+ const color=(rgb,text)=>Object.hasOwn(process.env,'NO_COLOR')?text:`\x1b[38;2;${rgb}m${text}\x1b[39m`;
7
+ export const palette={
8
+ brand:text=>color('78;201;176',text),
9
+ text:text=>color('229;233;237',text),
10
+ muted:text=>color('139;149;158',text),
11
+ border:text=>color('74;101;99',text),
12
+ };
13
+ export const menuTheme={selectedPrefix:palette.brand,selectedText:palette.brand,description:palette.muted,scrollInfo:palette.muted,noMatch:palette.muted};
14
+ export const fitLine=(text,width)=>truncateToWidth(text,Math.max(1,width),'…');
15
+
16
+ export function shortenPath(value,width){
17
+ if(visibleWidth(value)<=width)return value;
18
+ // 中间省略保留盘符与项目名;完整目录仍在帮助页中展示。
19
+ const chars=[...value],head=chars.splice(0,Math.min(3,Math.max(0,width-2))).join('');
20
+ while(chars.length&&visibleWidth(head+'…'+chars.join(''))>width)chars.shift();
21
+ return fitLine(head+'…'+chars.join(''),width);
22
+ }
23
+
24
+ export function markdownTheme(){
25
+ const plain=text=>text;
26
+ return {heading:palette.brand,link:palette.brand,linkUrl:palette.muted,code:palette.brand,codeBlock:plain,codeBlockBorder:palette.border,quote:plain,quoteBorder:palette.border,hr:palette.border,listBullet:palette.brand,
27
+ bold:palette.text,italic:plain,strikethrough:plain,underline:plain};
28
+ }
@@ -1,15 +1,14 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { stripVTControlCharacters } from 'node:util';
5
- import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
4
+ import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
6
5
  import { MarkdownLogViewer } from './log-viewer.js';
6
+ import { RunViewer, mouseOn, mouseOff } from './run-viewer.js';
7
7
  import { logo } from './presentation.js';
8
8
  import { VERSION } from '../version.js';
9
+ import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
9
10
 
10
11
  export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'PROMPT_CANCELLED'});
11
- const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
12
- const theme={selectedPrefix:s=>`\x1b[36m${s}\x1b[0m`,selectedText:s=>`\x1b[36m${s}\x1b[0m`,description:s=>s,scrollInfo:s=>s,noMatch:s=>s};
13
12
  // 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
14
13
  function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
15
14
 
@@ -48,28 +47,41 @@ export class TerminalView{
48
47
  this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];
49
48
  const originalStart=terminal.start.bind(terminal);
50
49
  terminal.start=(input,resize)=>originalStart(data=>{
51
- if(matchesKey(data,'ctrl+c')){if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
52
- if(matchesKey(data,'escape')&&this.pending&&!this.logViewer){
50
+ if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
51
+ if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
53
52
  // 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
54
53
  if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
55
54
  this.pending.reject(cancelled());return;
56
55
  }
57
56
  input(data);
58
57
  },resize);
59
- this.header=new Text('',1,1);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('↑↓ 选择 · Enter 确认 · Tab 补全 · Esc 返回 · Ctrl+C 取消/退出',1,1);
58
+ this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
59
+ this.hint.render=width=>['',fitLine(' '+palette.muted(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'),width)];
60
60
  const renderTranscript=this.transcript.render.bind(this.transcript);
61
- this.headingLines=(width,mark='FISHtools')=>new Text(`${mark} v${VERSION}\n${this.summaryText??''}`,1,1).render(width).map(line=>truncateToWidth(line,width));
62
- // 根据整屏换行后的行数选择标题,而不是只判断终端宽度。
61
+ this.headingLines=(width,mark='FISHtools')=>{
62
+ const room=Math.max(1,width-2),lines=[''];
63
+ if(mark===logo){
64
+ lines.push(...logo.split('\n').map((line,i)=>' '+palette.brand(line)+(i===2?' '+palette.text('tools')+' '+palette.muted(`v${VERSION}`):'')));
65
+ }else lines.push(' '+palette.brand('FISH')+palette.text('tools')+' '+palette.muted(`v${VERSION}`));
66
+ for(const [i,line] of (this.summaryText??'').split('\n').entries()){
67
+ if(line.startsWith('当前工作区:')){
68
+ const label='当前工作区:',folder=shortenPath(line.slice(label.length),Math.max(1,room-visibleWidth(label)));
69
+ lines.push(' '+palette.muted(label)+palette.text(folder));
70
+ }else lines.push(' '+(i===0?palette.text:palette.muted)(fitLine(line,room)));
71
+ }
72
+ lines.push('');return lines.map(line=>fitLine(line,width));
73
+ };
74
+ // 先保证输入与菜单可见,空间充足才展示三行字标。
63
75
  this.header.render=width=>{
64
76
  const full=this.headingLines(width,logo),other=renderTranscript(width).length+this.prompt.render(width).length+this.hint.render(width).length;
65
- return width>=52&&full.length+other<=this.terminal.rows?full:this.headingLines(width);
77
+ return width>=34&&this.terminal.rows>=18&&full.length+other<=this.terminal.rows?full:this.headingLines(width);
66
78
  };
67
79
  this.transcript.render=width=>{
68
80
  const lines=renderTranscript(width),budget=Math.max(0,this.terminal.rows-this.header.render(width).length-this.prompt.render(width).length-this.hint.render(width).length);
69
81
  if(lines.length<=budget)return lines;
70
82
  if(!budget)return [];
71
83
  const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
72
- return [...excerpt,truncateToWidth(' … /help 查看用法 · /logs 查看运行结果',width)];
84
+ return [...excerpt,fitLine(' '+palette.muted('… /help 完整帮助 · /logs 运行日志'),width)];
73
85
  };
74
86
  this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
75
87
  }
@@ -81,7 +93,11 @@ export class TerminalView{
81
93
  }
82
94
  summary(text){this.summaryText=safe(text);this.tui.requestRender();}
83
95
  clearMessages(){this.logs=[];this.transcript.setText('');this.tui.requestRender();}
84
- show(message){this.logs.push(safe(message).slice(0,6000));this.logs=this.logs.slice(-6);this.transcript.setText(this.logs.join('\n\n'));this.tui.requestRender();}
96
+ show(message,{kind='message'}={}){
97
+ const text=safe(message).slice(0,6000);
98
+ const formatted=kind==='welcome'?text.split('\n').map((line,i)=>i===0?palette.text(line):palette.muted(line)).join('\n'):text;
99
+ this.logs.push(formatted);this.logs=this.logs.slice(-6);this.transcript.setText(this.logs.join('\n\n'));this.tui.requestRender();
100
+ }
85
101
  async viewMarkdown(title,text){
86
102
  const previousPending=this.pending??null,previousViewer=this.logViewer;
87
103
  const children=this.tui.children;
@@ -90,6 +106,7 @@ export class TerminalView{
90
106
  await new Promise((resolve,reject)=>{
91
107
  this.pending={resolve,reject};
92
108
  this.logViewer=new MarkdownLogViewer({title:safe(title),text:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
109
+ this.terminal.write(mouseOn);
93
110
  // Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
94
111
  this.tui.children=[];
95
112
  overlay=this.tui.showOverlay(this.logViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});
@@ -98,34 +115,72 @@ export class TerminalView{
98
115
  }finally{
99
116
  this.tui.children=children;this.pending=previousPending;this.logViewer=previousViewer;
100
117
  overlay?.hide();this.tui.requestRender(true);
118
+ this.terminal.write(mouseOff);
119
+ }
120
+ }
121
+ async observeRun({app,runId,flow,workflows=[]}){
122
+ const previousPending=this.pending??null,children=this.tui.children;let overlay,unsubscribe;
123
+ try{
124
+ await new Promise((resolve,reject)=>{
125
+ this.pending={resolve,reject};
126
+ this.runViewer=new RunViewer({record:app.getRun(runId),flow,workflows,terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),requestRender:()=>this.tui.requestRender()});
127
+ unsubscribe=app.subscribe(event=>{if(event.runId===runId)this.runViewer?.update(app.getRun(runId));});
128
+ this.tui.children=[];this.terminal.write(mouseOn);
129
+ overlay=this.tui.showOverlay(this.runViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});this.tui.requestRender(true);
130
+ });
131
+ }finally{
132
+ unsubscribe?.();this.runViewer=null;this.tui.children=children;this.pending=previousPending;overlay?.hide();this.terminal.write(mouseOff);this.tui.requestRender(true);
101
133
  }
102
134
  }
103
135
  async ask(message,component){
104
136
  fit(component);
105
- this.prompt.clear();this.prompt.addChild(new Text(safe(message),1,1));this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
137
+ const title=new Text(palette.text(safe(message)),1,0);
138
+ if(message.startsWith('fst >'))title.render=width=>[fitLine(' '+palette.text(width<45?'fst > / 选择操作 · @ 文件':safe(message)),width)];
139
+ this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
106
140
  try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=resolve;component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
107
141
  finally{this.pending=null;this.editor=null;this.prompt.clear();this.tui.requestRender();}
108
142
  }
109
143
  input(message,{value='',commands=[],files=true}={}){
110
- const editor=new Editor(this.tui,{borderColor:theme.selectedPrefix,selectList:theme},{paddingX:1});
144
+ const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
145
+ const renderEditor=editor.render.bind(editor);
146
+ editor.render=width=>{
147
+ if(width<8)return renderEditor(width);
148
+ const inner=width-4,lines=renderEditor(inner);let closed=false;
149
+ return lines.map((line,i)=>{
150
+ if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
151
+ if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
152
+ if(closed)return ' '+line;
153
+ return ' '+palette.border('│')+line+' '.repeat(Math.max(0,inner-visibleWidth(line)))+palette.border('│');
154
+ });
155
+ };
111
156
  this.editor=editor;editor.setText(value);
112
157
  if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir));
113
158
  return this.ask(message,editor);
114
159
  }
115
160
  select(message,items){
116
161
  if(!items.length)throw new Error('当前没有可选择的项目');
117
- const list=new SelectList(items.map(i=>({...i,label:safe(i.label),description:i.description?safe(i.description):undefined})),8,theme);
118
- const render=list.render.bind(list);
162
+ 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);
119
163
  list.render=width=>{
120
- const reserved=this.headingLines(width).length+new Text(safe(message),1,1).render(width).length+this.hint.render(width).length+1;
164
+ const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+1;
121
165
  list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
122
- return render(width);
166
+ const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
167
+ const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
168
+ for(let i=start;i<end;i++){
169
+ const item=list.filteredItems[i],selected=i===list.selectedIndex,available=Math.max(1,width-4);
170
+ const labelWidth=item.description&&width>=56?Math.min(32,Math.floor(available*0.45)):available;
171
+ const label=fitLine(item.label||item.value,labelWidth),prefix=selected?'› ':' ';
172
+ let line=' '+(selected?palette.brand(prefix+label):palette.text(prefix+label));
173
+ if(item.description&&width>=56)line+=' '.repeat(Math.max(2,labelWidth-visibleWidth(label)+2))+palette.muted(fitLine(item.description,Math.max(1,available-labelWidth-2)));
174
+ lines.push(fitLine(line,width));
175
+ }
176
+ if(start>0||end<list.filteredItems.length)lines.push(palette.muted(` ${list.selectedIndex+1} / ${list.filteredItems.length}`));
177
+ return lines.length?lines:[palette.muted(' 没有匹配项')];
123
178
  };
124
179
  return this.ask(message,list);
125
180
  }
126
181
  async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
127
182
  busy(message,cancel){
128
- this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(safe(message),1,1));this.tui.setFocus(this.prompt);this.tui.requestRender();
183
+ this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(palette.text(safe(message)),1,0));this.tui.setFocus(this.prompt);this.tui.requestRender();
129
184
  return ()=>{this.cancelRun=null;this.prompt.clear();this.tui.requestRender();};
130
185
  }
131
186
  }
@@ -3,12 +3,23 @@ import { FlowError } from '@mytools/sdk';
3
3
 
4
4
  const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
5
5
  const validators = new WeakMap();
6
+ const booleanValidators = new Map();
6
7
  export const emptyObject = { type: 'object', properties: {}, additionalProperties: false };
7
8
 
9
+ export function assertSchema(schema,label) {
10
+ if (!(typeof schema==='boolean'||schema&&typeof schema==='object'&&!Array.isArray(schema))) throw new FlowError(`${label} 必须声明有效的 JSON Schema`,{code:'VALIDATION'});
11
+ const cache=typeof schema==='boolean'?booleanValidators:validators;
12
+ let check=cache.get(schema);
13
+ if(!check){
14
+ try{check=ajv.compile(schema);cache.set(schema,check);}
15
+ catch(cause){throw new FlowError(`${label} JSON Schema 无效:${cause.message}`,{code:'VALIDATION',cause});}
16
+ }
17
+ return check;
18
+ }
19
+
8
20
  export function validate(schema, value, label) {
9
- if (!schema) return structuredClone(value);
10
- let check = validators.get(schema);
11
- if (!check) { check = ajv.compile(schema); validators.set(schema, check); }
21
+ if (schema==null) return structuredClone(value);
22
+ const check = assertSchema(schema,label);
12
23
  const copy = structuredClone(value);
13
24
  if (!check(copy)) throw new FlowError(`${label}: ${ajv.errorsText(check.errors, { separator: ';' })}`, { code: 'VALIDATION', details: check.errors });
14
25
  return copy;
@@ -42,7 +53,7 @@ export function bind(value, context) {
42
53
  if (Object.hasOwn(value, '$ref')) {
43
54
  let current = context;
44
55
  for (const field of referenceParts(value.$ref)) {
45
- if (current == null || !Object.hasOwn(current, field)) throw new FlowError(`引用的值不存在:${value.$ref}`);
56
+ if (current == null || !Object.hasOwn(current, field)) throw new FlowError(`引用的值不存在:${value.$ref}`,{code:'MISSING_REFERENCE'});
46
57
  current = current[field];
47
58
  }
48
59
  return structuredClone(current);
@@ -1,2 +1,2 @@
1
1
  // 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
2
- export const VERSION="0.1.4";
2
+ export const VERSION="0.2.0";