@yanglaofish/fishtools 0.2.0 → 0.4.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 (83) hide show
  1. package/README.md +43 -17
  2. package/manual/README.md +43 -17
  3. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  4. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  5. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  6. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  7. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  8. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  9. package/manual/docs/examples/script-plugin/LICENSE +21 -0
  10. package/manual/docs/examples/script-plugin/README.md +64 -0
  11. package/manual/docs/examples/script-plugin/index.js +39 -0
  12. package/manual/docs/examples/script-plugin/package.json +9 -0
  13. package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
  14. package/manual/docs/plugin-development.md +75 -14
  15. package/manual/docs/releases.md +30 -1
  16. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  17. package/manual/docs/roadmap.md +8 -1
  18. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
  19. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  20. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  21. package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
  22. package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
  23. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  24. package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
  25. package/manual/docs/verification.md +100 -0
  26. package/manual/packages/core/src/application.js +235 -0
  27. package/manual/packages/core/src/engine.js +93 -0
  28. package/manual/packages/core/src/host.js +22 -5
  29. package/manual/packages/core/src/runtime.js +135 -0
  30. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  31. package/manual/packages/core/src/terminal/session.js +438 -0
  32. package/manual/packages/core/src/terminal/view.js +283 -0
  33. package/manual/tests/application.test.js +30 -0
  34. package/node_modules/@mytools/core/bin/fst.js +27 -5
  35. package/node_modules/@mytools/core/package.json +2 -0
  36. package/node_modules/@mytools/core/src/application.js +46 -21
  37. package/node_modules/@mytools/core/src/contracts.js +27 -9
  38. package/node_modules/@mytools/core/src/documentation.js +1 -1
  39. package/node_modules/@mytools/core/src/engine.js +1 -1
  40. package/node_modules/@mytools/core/src/environments.js +39 -1
  41. package/node_modules/@mytools/core/src/host.js +22 -5
  42. package/node_modules/@mytools/core/src/markdown-log.js +1 -1
  43. package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
  44. package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
  45. package/node_modules/@mytools/core/src/plugins.js +16 -6
  46. package/node_modules/@mytools/core/src/process.js +30 -8
  47. package/node_modules/@mytools/core/src/schema-path.js +31 -0
  48. package/node_modules/@mytools/core/src/terminal/model.js +1 -1
  49. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  50. package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
  51. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  52. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  53. package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
  54. package/node_modules/@mytools/core/src/terminal/session.js +118 -50
  55. package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
  56. package/node_modules/@mytools/core/src/terminal/view.js +125 -28
  57. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  58. package/node_modules/@mytools/core/src/validation.js +24 -9
  59. package/node_modules/@mytools/core/src/version.js +1 -1
  60. package/node_modules/@mytools/core/src/workflows.js +13 -15
  61. package/node_modules/@mytools/pack/dependencies.js +8 -1
  62. package/node_modules/@mytools/pack/index.js +4 -2
  63. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
  64. package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
  65. package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
  66. package/node_modules/yallist/README.md +8 -7
  67. package/node_modules/yallist/package.json +51 -12
  68. package/package.json +15 -15
  69. package/node_modules/tar/node_modules/yallist/package.json +0 -68
  70. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
  71. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
  72. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
  73. /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
  74. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
  75. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
  76. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
  77. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
  78. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
  79. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
  80. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
  81. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
  82. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
  83. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
@@ -0,0 +1,78 @@
1
+ import {readableValue,readableError,readableTimeline} from './run-presentation.js';
2
+ import {runStatuses} from './run-viewer.js';
3
+
4
+ // 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
5
+ export class RunFeed{
6
+ constructor(refresh){this.refresh=refresh;this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;}
7
+ track({app,runId,title,input}){
8
+ if(this.runs.has(runId))return;
9
+ const record=app.getRun(runId),item={app,record,title,input,count:0,ended:false,steps:new Map(),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
10
+ this.runs.set(runId,item);
11
+ this.entries.push({type:'start',item});
12
+ if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
13
+ const item=this.runs.get(event.runId);if(!item||item.app!==app)return;
14
+ this.update(item,event);this.release(app);
15
+ if(event.type==='log'&&!item.ended)this.scheduleRefresh();
16
+ else this.flushRefresh();
17
+ }));
18
+ this.update(item);this.release(app);this.refresh();
19
+ }
20
+ scheduleRefresh(){
21
+ if(this.refreshTimer)return;
22
+ this.refreshTimer=setTimeout(()=>{this.refreshTimer=null;this.refresh();},100);
23
+ }
24
+ flushRefresh(){
25
+ if(this.refreshTimer){clearTimeout(this.refreshTimer);this.refreshTimer=null;}
26
+ this.refresh();
27
+ }
28
+ update(item,event){
29
+ if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type))item.record.events.push(event);
30
+ else{
31
+ const record=item.app.getRun(item.record.id);if(!record)return;
32
+ item.record={...record,events:[...(record.events??[])]};
33
+ }
34
+ const {record}=item,events=record.events.slice(item.count);item.count=record.events.length;
35
+ for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
36
+ if(events.length){
37
+ const last=this.entries.at(-1);
38
+ if(last?.type==='events'&&last.item===item)last.events.push(...events);
39
+ else this.entries.push({type:'events',item,events});
40
+ }
41
+ if(record.status!=='running'&&!item.ended){item.ended=true;this.entries.push({type:'result',item});}
42
+ }
43
+ release(app){
44
+ if([...this.runs.values()].some(item=>item.app===app&&!item.ended))return;
45
+ this.subscriptions.get(app)?.();this.subscriptions.delete(app);
46
+ }
47
+ message(text){this.entries.push({type:'message',text});this.refresh();}
48
+ compactEvents(entry,seen){
49
+ const {item}=entry,rows=[];
50
+ for(const event of entry.events){
51
+ const key=`${item.record.id}\0${event.callPath}`;
52
+ if(event.type.startsWith('step.')&&!seen.has(key)){
53
+ seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
54
+ const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
55
+ const name=action?.title??latest.action??latest.stepId??latest.callPath;
56
+ rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
57
+ }
58
+ if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
59
+ if(event.type==='step.failed'&&![...item.steps.values()].some(step=>step.type==='step.failed'&&step.callPath?.startsWith(event.callPath+'/')))rows.push(`✗ ${event.callPath}\n${readableError(event.error??{message:event.message})}`);
60
+ if(event.type==='step.stopped'||event.type==='step.skipped')rows.push(`${event.callPath}:${event.reason??event.message??''}`);
61
+ }
62
+ return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
63
+ }
64
+ text(){const seen=new Set();return this.entries.map(entry=>{
65
+ if(entry.type==='message')return entry.text;
66
+ const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
67
+ if(entry.type==='start')return [
68
+ `› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
69
+ `状态:${runStatuses[record.status]??record.status}`,
70
+ this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
71
+ ].filter(Boolean).join('\n');
72
+ if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
73
+ return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
74
+ this.expanded&&record.output!==undefined?readableValue(record.output):'',record.error&&(this.expanded||![...entry.item.steps.values()].some(step=>step.type==='step.failed'))?readableError(record.error,{details:this.expanded}):'',record.reason??'',
75
+ ].filter(Boolean).join('\n');
76
+ }).filter(Boolean).join('\n\n');}
77
+ close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
78
+ }
@@ -0,0 +1,60 @@
1
+ // 阅读层只处理展示;原始事件、输入输出与错误仍保留在运行记录中。
2
+ export function readableValue(value,indent=''){
3
+ if(value===undefined)return '暂无记录';
4
+ if(value===null)return 'null';
5
+ if(typeof value!=='object')return String(value);
6
+ const entries=Array.isArray(value)?value.map((v,i)=>[`[${i+1}]`,v]):Object.entries(value);
7
+ if(!entries.length)return Array.isArray(value)?'(空列表)':'(空对象)';
8
+ return entries.map(([key,item])=>{
9
+ const nested=item!==null&&typeof item==='object';
10
+ return `${indent}${key}:${nested?'\n'+readableValue(item,indent+' '):String(item).replaceAll('\n','\n'+indent+' ')}`;
11
+ }).join('\n');
12
+ }
13
+ export function readableError(error,{details=false}={}){
14
+ if(!error)return '';
15
+ const chain=[];for(let current=error;current;current=current.cause)chain.push(current);
16
+ const program=chain.find(e=>e.details?.executable)?.details.executable;
17
+ const code=chain.find(e=>e.code==='ENOENT')?.code??error.code;
18
+ const summary=[`原因:${code==='ENOENT'&&program?'未找到所需程序':error.message??'未知错误'}`,error.plugin&&`插件:${error.plugin}`,code&&`错误码:${code}`,program&&`程序:${program}`,code==='ENOENT'&&'请检查程序是否安装、路径是否正确以及 PATH 配置。'].filter(Boolean).join('\n');
19
+ if(!details)return summary;
20
+ const diagnostic=chain.flatMap((item,index)=>[
21
+ `${index===0?'错误':'上游原因 '+index}:${item.message??'未知错误'}`,
22
+ item.code&&`错误码:${item.code}`,
23
+ item.details!==undefined&&`细节:\n${readableValue(item.details)}`,
24
+ item.stack&&`调用栈:\n${item.stack}`,
25
+ ].filter(Boolean));
26
+ return `${summary}\n\n${diagnostic.join('\n')}`;
27
+ }
28
+ const eventNames={'step.started':'步骤开始','step.succeeded':'步骤成功','step.failed':'步骤失败','step.cancelled':'步骤取消','step.skipped':'步骤跳过','step.stopped':'步骤停止','run.started':'运行开始','run.succeeded':'运行成功','run.failed':'运行失败','run.cancelled':'运行取消','run.stopped':'提前结束'};
29
+ const time=value=>{const date=new Date(value);return Number.isFinite(+date)?date.toLocaleTimeString('zh-CN',{hour12:false}):'--:--:--';};
30
+ export function readableTimeline(events,{callPath,values=false}={}){
31
+ const rows=[];let pending;
32
+ const push=(event,message)=>{
33
+ const prefix=`${time(event.time)} [${event.callPath??'运行'}${event.stream?' · '+event.stream:''}]`;
34
+ const lines=String(message).replaceAll('\r\n','\n').replaceAll('\r','\n').split('\n');
35
+ if(lines.at(-1)==='')lines.pop();
36
+ for(const line of lines)rows.push(`${prefix} ${line}`);
37
+ };
38
+ const flush=()=>{if(pending){if(pending.text)push(pending.event,pending.text);pending=null;}};
39
+ for(const event of events??[]){
40
+ if(callPath&&event.callPath!==callPath)continue;
41
+ if(event.type==='log'&&event.stream){
42
+ const key=`${event.callPath}\0${event.stream}`;
43
+ if(pending?.key!==key){flush();pending={key,event,text:''};}
44
+ pending.text+=event.message??'';
45
+ const end=pending.text.lastIndexOf('\n');
46
+ if(end>=0){push(pending.event,pending.text.slice(0,end+1));pending.text=pending.text.slice(end+1);pending.event=event;}
47
+ continue;
48
+ }
49
+ if(event.type==='log'){flush();push(event,`${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);continue;}
50
+ if(eventNames[event.type]){
51
+ const detail=event.error?readableError(event.error,{details:values}):event.reason??event.message;
52
+ flush();push(event,`${eventNames[event.type]}${event.action?' · '+event.action:''}${event.durationMs!==undefined?` · ${event.durationMs} ms`:''}${detail?'\n'+detail:''}`);
53
+ if(values&&event.type.startsWith('step.')){
54
+ if(event.input!==undefined&&Object.keys(event.input??{}).length)push(event,'输入\n'+readableValue(event.input));
55
+ if(event.output!==undefined)push(event,'输出\n'+readableValue(event.output));
56
+ }
57
+ }
58
+ }
59
+ flush();return rows.join('\n')||'等待执行记录。插件通过 log 或 runProcess 输出的内容会显示在这里。';
60
+ }
@@ -1,6 +1,7 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
2
  import { Text, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
3
3
  import {palette} from './theme.js';
4
+ import {readableValue,readableError,readableTimeline} from './run-presentation.js';
4
5
 
5
6
  export const runStatuses={pending:'待执行',running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',skipped:'已跳过',stopped:'已停止'};
6
7
  const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
@@ -9,8 +10,8 @@ export const mouseOn='\x1b[?1000h\x1b[?1006h';
9
10
  export const mouseOff='\x1b[?1006l\x1b[?1000l';
10
11
 
11
12
  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;
13
+ constructor({record,flow,workflows=[],catalog=[],terminal,onClose,onCancel,requestRender}){
14
+ Object.assign(this,{terminal,onClose,onCancel,requestRender,catalog});this.view=0;this.selected=0;this.offset=0;this.follow=true;this.allLogs=true;this.raw=false;this.confirmCancel=false;
14
15
  this.planned=[];
15
16
  const walk=(steps,prefix,ancestors)=>{
16
17
  for(const step of steps??[]){
@@ -30,20 +31,34 @@ export class RunViewer{
30
31
  for(const event of record.events??[]){
31
32
  if(!event.type.startsWith('step.')||!event.callPath)continue;
32
33
  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
+ Object.assign(node,event,{status:event.type==='step.started'?'running':event.type.slice(5)});
35
+ node.title??=this.catalog.find(action=>action.id===node.action)?.title;nodes.set(event.callPath,node);
34
36
  }
35
37
  this.nodes=[...nodes.values()];this.selected=Math.max(0,Math.min(this.selected,this.nodes.length-1));
36
38
  this.requestRender();
37
39
  }
38
40
  content(){
39
41
  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'):'尚无日志';
42
+ if(this.view===0)return [
43
+ `运行:${status}${this.record.reason?` · ${this.record.reason}`:''}`,
44
+ this.record.error?readableError(this.record.error):'',
45
+ this.record.output!==undefined?'\n最终输出\n'+readableValue(this.record.output):'',
46
+ '\n步骤 · Enter 查看详情',
47
+ ...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:''}`),
48
+ !this.nodes.length?'此运行没有工作流步骤,可在日志页查看插件输出。':'',
49
+ ].filter(Boolean).join('\n');
50
+ if(this.view===1){
51
+ const format=value=>this.raw?JSON.stringify(value??null,null,2):readableValue(value);
52
+ const error=node?node.error:this.record.error;
53
+ return [`步骤:${node?.callPath??'整个运行'}`,node?.action?'能力:'+node.action:'',error?'\n错误\n'+(this.raw?format(error):readableError(error)):'',
54
+ node?'\n输入\n'+format(node.input):'', '\n输出\n'+format(node?node.output:this.record.output),
55
+ !error&&(node?.reason||node?.message)?'\n说明:'+(node.reason??node.message):'',this.raw?'\n原始 JSON · 包含完整错误详情与调用栈':'\nj 查看原始 JSON 与完整诊断',
56
+ ].filter(Boolean).join('\n');
57
+ }
58
+ return readableTimeline(this.record.events,{callPath:this.allLogs?undefined:node?.callPath});
44
59
  }
45
60
  layout(width){
46
- const height=Math.max(1,this.terminal.rows),pageSize=Math.max(1,height-3);
61
+ const height=Math.max(1,this.terminal.rows),pageSize=Math.max(1,height-4);
47
62
  const lines=new Text(safe(this.content()),0,0).render(Math.max(1,width));
48
63
  const maxOffset=Math.max(0,lines.length-pageSize);
49
64
  this.offset=this.follow&&this.view===2?maxOffset:Math.max(0,Math.min(this.offset,maxOffset));
@@ -54,20 +69,23 @@ export class RunViewer{
54
69
  const body=lines.slice(this.offset,this.offset+pageSize);while(body.length<pageSize)body.push('');
55
70
  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
71
  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}`;
72
+ const footer=this.confirmCancel?'取消整个运行?y 确认 · 任意其他键返回':'q/Esc 返回 · x 取消 · 1/2/3 切换';
73
+ const shortcuts=width<60?'↑↓/滚轮 · [ ] 步骤 · j 原始数据':`↑↓/滚轮 翻页 · [ ] 步骤 · j 原始数据 · a 全部/本步骤 · End 跟随 · ${this.offset+1}–${Math.min(this.offset+pageSize,lines.length)}/${lines.length}`;
58
74
  const styledBody=body.map(line=>line.startsWith('›')?palette.brand(line):line);
59
75
  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];
76
+ const screen=height===1?styledBody:height===2?[styledBody[0],styledFooter]:height===3?[tabs,styledBody[0],styledFooter]:height===4?[tabs,styledBody[0],styledFooter,palette.muted(shortcuts)]:[tabs,palette.muted(safe(selection)),...styledBody,styledFooter,palette.muted(shortcuts)];
61
77
  return screen.map(line=>truncateToWidth(line,width));
62
78
  }
63
79
  handleInput(data){
64
80
  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;}
81
+ if(['escape','q'].some(key=>matchesKey(data,key))){this.onClose();return;}
82
+ if(matchesKey(data,'enter')){if(this.view===0){this.view=1;this.offset=0;this.requestRender();}return;}
83
+ if(data==='j'){this.raw=!this.raw;this.view=1;this.offset=0;this.requestRender();return;}
66
84
  if(data==='x'&&this.record.status==='running'){this.confirmCancel=true;this.requestRender();return;}
67
85
  if(['1','2','3'].includes(data)){this.view=Number(data)-1;this.offset=0;this.follow=true;this.requestRender();return;}
68
86
  if(data==='['||data===']'){
69
87
  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));
88
+ if(this.view===0){const {lines,pageSize,maxOffset}=this.layout(this.terminal.columns);const row=lines.findIndex(line=>line.startsWith('›'));this.offset=Math.min(maxOffset,Math.max(0,row-Math.floor(pageSize/2)));}
71
89
  this.requestRender();return;
72
90
  }
73
91
  if(data==='a'){this.allLogs=!this.allLogs;this.offset=0;this.requestRender();return;}
@@ -1,16 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
  import path from 'node:path';
3
- import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
4
+ import { parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
4
5
  import { editCondition } from './conditions.js';
5
6
  import { runStatuses } from './run-viewer.js';
6
7
  import { editValue, needsJsonEditor } from './schema.js';
7
8
  import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
+ import {readableValue,readableError} from './run-presentation.js';
10
+ import {commandMenu} from './navigation.js';
11
+ import {workflowMenu} from './workflow-menu.js';
8
12
 
9
13
  export class TerminalSession{
10
- constructor(app,ui,{baseDir=process.cwd(),webOptions={}}={}){this.app=app;this.ui=ui;this.baseDir=baseDir;this.webOptions=webOptions;this.flow=null;this.dirty=false;this.exiting=false;this.web=null;}
14
+ constructor(app,ui,{baseDir=process.cwd(),webOptions={}}={}){this.app=app;this.ui=ui;this.baseDir=baseDir;this.webOptions=webOptions;this.flow=null;this.dirty=false;this.exiting=false;this.web=null;ui.activeRuns=()=>app.listRuns().filter(run=>run.status==='running').length;}
11
15
  writable(){if(this.app.project.readOnly)throw new Error('这是只读运行包,请在完整工作区中编排、安装或打包');}
12
- current(){if(!this.flow)throw new Error('先用 /workflows 打开工作流,或用 /new 新建');return this.flow;}
13
- commands(){return [...commands,...this.app.listCommands()];}
16
+ current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
17
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
14
18
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
15
19
  async mayExit(){
16
20
  if(!await this.discard())return false;
@@ -27,15 +31,25 @@ export class TerminalSession{
27
31
  while(!this.exiting){
28
32
  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?'未保存':'已保存'}`:''}`);
29
33
  let text;
30
- try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands()});}
34
+ try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
31
35
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
32
36
  if(!text.trim())continue;
33
- try{await this.execute(text);}catch(e){this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回,未完成的修改已放弃。':`操作失败:${e.message}`);}
37
+ 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}`);}
34
38
  }
35
39
  }finally{if(this.web)await this.web.close();}
36
40
  }
37
41
  async execute(text){
42
+ // 只记录用户提交的入口;菜单内部调用 /save 等动作不制造伪命令。
43
+ const outer=!this.commandDepth;
44
+ if(outer)this.ui.beginCommand?.(text);
45
+ this.commandDepth=(this.commandDepth??0)+1;
46
+ try{return await this.executeCommand(text);}
47
+ finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
48
+ }
49
+ async executeCommand(text){
38
50
  const [command,...args]=parseCommand(text);
51
+ const entry=this.commands().find(c=>c.name===command&&c.plugin);
52
+ if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
39
53
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
40
54
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
41
55
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
@@ -46,8 +60,11 @@ export class TerminalSession{
46
60
  `当前工作区:${this.app.projectDir}`,
47
61
  `文件路径起点:${this.baseDir}`,
48
62
  '## 常用操作',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 明确取消。',
63
+ '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
64
+ '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
65
+ '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
66
+ '## 观察运行','输入 /logs 打开执行记录。列表包含多个工作流的每次执行,运行中任务排在前面;选择后查看进度、结果和日志。从详情返回后可选择另一条记录,本次启动期间每条记录分别保留阅读位置。重启后仍可查看保留下来的执行记录。',
67
+ '1 / 2 / 3 切换总览、输入输出和日志,Enter 查看步骤详情,[ / ] 切换步骤,j 查看原始诊断数据。↑↓、PgUp/PgDn 或滚轮浏览;End 恢复尾部跟随。q / Esc 返回,正在执行的任务在后台继续,x 后 y 明确取消。',
51
68
  '## 文件与运行参数','输入 @ 后按 Tab 选择文件;@@ 表示字面量 @。运行命令的 @参数.json 会读取 JSON 对象作为输入。',
52
69
  '/run 工作流ID @"参数 文件.json"',
53
70
  '## 离线文档','输入 /docs 阅读插件开发、部署、控制流与服务文档。独立命令 fst docs --path 查看资料位置,fst docs --export 目标目录 导出资料。',
@@ -61,28 +78,26 @@ export class TerminalSession{
61
78
  return;
62
79
  }
63
80
  case 'exit':if(await this.mayExit())this.exiting=true;return;
64
- case 'workflows':{
65
- const flows=await this.app.listWorkflows();
66
- if(!flows.length){this.ui.show('没有工作流,输入 /new 新建。');return;}
67
- if(!await this.discard())return;
68
- const id=await this.ui.select('打开工作流',flows.map(w=>({value:w.id,label:w.title??w.id,description:`${w.steps.length} 步 · ${w.description??''}`})));
69
- this.flow=structuredClone(flows.find(w=>w.id===id));this.dirty=false;this.ui.show(`已打开:${this.flow.title}`);return;
70
- }
81
+ case 'workflows':return workflowMenu(this);
71
82
  case 'new':{
72
- this.writable();if(!await this.discard())return;
83
+ this.writable();if(!await this.discard())return false;
73
84
  const title=(await this.ui.input('工作流名称',{files:false})).trim();if(!title)throw new Error('名称不能为空');
74
85
  const description=await this.ui.input('用途说明(可留空)',{files:false});
75
- this.flow=newWorkflow(title,description);this.dirty=true;this.ui.show('已新建。输入 /add 添加第一个步骤。');return;
86
+ this.flow=newWorkflow(title,description);this.dirty=true;this.ui.show('工作流草稿已创建,可以在编辑菜单添加步骤。');return true;
76
87
  }
77
88
  case 'add':return this.add();
78
89
  case 'steps':return this.steps();
79
90
  case 'outputs':return this.outputs();
80
91
  case 'save':this.writable();await this.app.saveWorkflow(this.current());this.dirty=false;this.ui.show(`已保存:${this.flow.title}`);return;
81
92
  case 'run':return this.startRun(args);
82
- case 'runs':return this.runs();
93
+ case 'runs':return this.logs();
83
94
  case 'logs':return this.logs();
84
- case 'plugins':return this.plugins();
85
- case 'config':return this.configure();
95
+ case 'plugin':case 'plugins':return this.plugins();
96
+ case 'config':return this.toolConfig();
97
+ case 'tools':{
98
+ const choice=await this.ui.select('工具',[{value:'web',label:'启动 Web 工作台'},{value:'docs',label:'阅读离线文档'},{value:'back',label:'返回主面板'}]);
99
+ if(choice!=='back')await this.execute('/'+choice);return;
100
+ }
86
101
  case 'pack':return this.pack();
87
102
  case 'web':{
88
103
  if(!this.web)this.web=await this.app.startWeb(this.webOptions);
@@ -96,9 +111,13 @@ export class TerminalSession{
96
111
  const input=args.length?await readRunInput(args[0],this.baseDir):command.input?await editValue(this.ui,command.input,{}, {baseDir:this.baseDir,required:true,label:command.description}):{};
97
112
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
98
113
  const run=await this.app.startCommand(command.name,input,{ui});
99
- const unsubscribe=this.app.subscribe(e=>{if(e.runId===run.id&&e.type==='log')this.ui.show(e.message);});
114
+ if(this.ui.trackRun){
115
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
116
+ if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
117
+ }
118
+ const unsubscribe=this.ui.trackRun?()=>{}:this.app.subscribe(e=>{if(e.runId===run.id&&e.type==='log')this.ui.show(e.message);});
100
119
  const done=this.ui.busy(`正在执行 /${command.name} · Ctrl+C 取消`,()=>this.app.cancelRun(run.id));
101
- try{const result=await run.done;this.ui.show(`${result.status}\n${JSON.stringify(result.error??result.output,null,2)}\n输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}`);}
120
+ try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
102
121
  finally{unsubscribe();done();}
103
122
  }
104
123
  async configure(selectedId){
@@ -113,27 +132,26 @@ export class TerminalSession{
113
132
  async logs(){
114
133
  const statuses=runStatuses;
115
134
  while(true){
116
- const history=await this.app.listRunHistory(),keep=this.app.logSettings().keepRuns;
117
- const choice=await this.ui.select(`运行日志 · ${keep===null?'不自动清理':`项目保留最近 ${keep} 次`}`,[
118
- ...history.map(r=>({value:`run:${r.id}`,label:`${statuses[r.status]??r.status} · ${r.workflowId}`,description:`${new Date(r.startedAt).toLocaleString()} · ${r.id}`})),
119
- ...(!this.app.project.readOnly?[{value:'settings',label:'设置日志保留次数'}]:[]),{value:'back',label:'返回'},
135
+ const history=(await this.app.listRunHistory()).sort((a,b)=>(b.status==='running')-(a.status==='running'));
136
+ if(!history.length){this.ui.show('还没有执行记录。运行工作流后,可在 /logs 回看。');return;}
137
+ const titles=new Map((await this.app.listWorkflows()).map(flow=>[flow.id,flow.title??flow.id]));
138
+ const choice=await this.ui.select('执行记录 · 选择一次执行,查看步骤、输入输出与日志',[
139
+ ...history.map(r=>({value:`run:${r.id}`,label:`${statuses[r.status]??r.status} · ${titles.get(r.workflowId)??r.workflowId}`,description:`${new Date(r.startedAt).toLocaleString()} · ${r.id.slice(0,8)}`})),
140
+ {value:'back',label:'返回'},
120
141
  ]);
121
142
  if(choice==='back')return;
122
- if(choice==='settings'){
123
- const mode=await this.ui.select('保留策略(整个项目合计,仅清理日志,保留业务文件)',[{value:'count',label:'仅保留最近 N 次已结束运行'},{value:'all',label:'不自动清理'},{value:'back',label:'返回'}]);
124
- if(mode==='back')continue;
125
- const count=mode==='all'?null:Number(await this.ui.input('保留次数(例如 10)',{value:String(keep??10),files:false}));
126
- await this.app.configureLogs(count);this.ui.show('日志保留设置已保存。');continue;
127
- }
128
143
  const id=choice.slice(4),record=history.find(r=>r.id===id);
129
- try{await this.ui.viewMarkdown(`${record.workflowId} · ${statuses[record.status]??record.status}`,await this.app.readLog(id));}
144
+ try{
145
+ if(this.ui.observeRun)await this.openRun(record,{initialView:2});
146
+ else await this.ui.viewMarkdown(`${record.workflowId} · ${statuses[record.status]??record.status}`,await this.app.readLog(id));
147
+ }
130
148
  catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
131
149
  }
132
150
  }
133
151
  async add(){
134
152
  this.writable();const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
135
153
  await this.addNode(flow,[],catalog,workflows);
136
- this.flow=flow;this.dirty=true;this.ui.show('步骤已添加。/add 继续添加,/steps 调整,/save 保存。');
154
+ this.flow=flow;this.dirty=true;this.ui.show('步骤已添加,可在工作流菜单继续调整或保存。');
137
155
  }
138
156
  async addNode(flow,parent,catalog,workflows){
139
157
  this.writable();
@@ -226,6 +244,7 @@ export class TerminalSession{
226
244
  const index=flow.steps.findIndex(s=>s.id===id);
227
245
  if(this.app.project.readOnly){this.ui.show(JSON.stringify(flow.steps[index],null,2));return;}
228
246
  await this.stepOperation(flow,[index],catalog,workflows);
247
+ if(isDeepStrictEqual(flow,this.flow))return;
229
248
  this.flow=flow;this.dirty=true;this.ui.show('步骤已更新,保存前可继续调整。');
230
249
  }
231
250
  async outputs(){
@@ -241,22 +260,42 @@ export class TerminalSession{
241
260
  }else delete flow.outputs[action.slice(7)];
242
261
  this.flow=flow;this.dirty=true;this.ui.show('汇总输出已更新。');
243
262
  }
244
- async startRun(args){
263
+ async startRun(args,{draft=false}={}){
245
264
  if(args.length>2)throw new Error('用法:/run [工作流ID] [@参数.json]');
246
265
  const [first,second]=args;let reference=first?.startsWith('@')?first:second;
247
266
  if(first?.startsWith('@')&&second)throw new Error('参数文件后不能再附加参数');
248
- let flow=this.flow;
249
- if(first&&!first.startsWith('@')&&first!==flow?.id){
250
- const found=(await this.app.listWorkflows()).find(w=>w.id===first);if(!found)throw new Error(`工作流不存在:${first}`);flow=found;
267
+ const workflows=await this.app.listWorkflows();
268
+ let flow=draft?this.current():null;
269
+ if(first&&!first.startsWith('@')){
270
+ flow=workflows.find(w=>w.id===first);if(!flow)throw new Error(`工作流不存在:${first}`);
271
+ }
272
+ if(!flow){
273
+ if(!workflows.length){this.ui.show('还没有已保存的工作流,请在 /workflows 中新建并保存。');return false;}
274
+ const id=await this.ui.select('运行 · 选择已保存的工作流',[...workflows.map(w=>({value:`workflow:${w.id}`,label:w.title??w.id,description:w.id})),{value:'back',label:'返回主面板'}]);
275
+ if(id==='back')return false;flow=workflows.find(w=>`workflow:${w.id}`===id);
276
+ }
277
+ if(flow===this.flow&&this.dirty){
278
+ const saved=workflows.find(w=>w.id===flow.id);
279
+ const choice=await this.ui.select(`${flow.title??flow.id} · 有未保存的修改`,[
280
+ {value:'cancel',label:'返回编辑',description:'保留草稿,本次不运行'},
281
+ {value:'save',label:'保存并运行',description:'将当前修改保存到工作流文件'},
282
+ ...(saved?[{value:'saved',label:'运行已保存版本',description:'保留当前草稿,不执行未保存修改'}]:[]),
283
+ ]);
284
+ if(choice==='cancel'){this.ui.show('已取消本次运行,草稿保留,返回工作流编辑。');return false;}
285
+ if(choice==='saved')flow=saved;
286
+ else await this.execute('/save');
251
287
  }
252
- if(!flow)throw new Error('先用 /workflows 选择工作流,或使用 /run 工作流ID');
253
- if(flow===this.flow&&this.dirty){if(!await this.ui.confirm('保存当前修改后运行?'))return;await this.execute('/save');}
254
288
  await this.app.checkWorkflow(flow.id);
255
289
  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}):{};
256
290
  const run=await this.app.startRun(flow.id,input);
291
+ if(this.ui.trackRun){
292
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:flow.title??flow.id,input});
293
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return true;
294
+ }
257
295
  if(this.ui.observeRun){
258
296
  await this.ui.observeRun({app:this.app,runId:run.id,flow,workflows:await this.app.listWorkflows()});
259
- this.ui.show(`已关闭运行观察面板。输入 /runs 重新查看,/logs 查看历史日志。`);return;
297
+ const status=this.app.getRun(run.id)?.status;
298
+ this.ui.clearMessages?.();this.ui.show(`${status==='running'?'已返回工作台,任务在后台继续执行。':`本次运行${runStatuses[status]??'已结束'}。`}\n输入 /logs 查看任意一次执行的进度、结果和日志。`);return true;
260
299
  }
261
300
  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:''}`);});
262
301
  const done=this.ui.busy('运行中 · Ctrl+C 取消当前运行',()=>this.app.cancelRun(run.id));
@@ -266,14 +305,38 @@ export class TerminalSession{
266
305
  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));
267
306
  this.ui.show(`输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}\n结果目录:${path.join(this.app.outputDir,run.id)}`);
268
307
  }finally{unsubscribe();done();}
308
+ return true;
309
+ }
310
+ async pluginSkills(id){
311
+ const plugin=this.app.listPlugins().find(p=>p.id===id),commands=this.app.listCommands().filter(c=>c.plugin===id);
312
+ const choice=await this.ui.select(`[插件] ${plugin?.title??id} · 选择能力`,[
313
+ ...commands.map(c=>({value:`command:${c.name}`,label:c.description??c.name,description:`/${c.name}`})),{value:'back',label:'返回主面板'},
314
+ ]);
315
+ if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[]);
316
+ }
317
+ async toolConfig(){
318
+ while(true){
319
+ const keep=this.app.logSettings().keepRuns;
320
+ const choice=await this.ui.select('Fishtools 配置',[
321
+ {value:'info',label:'查看工作区信息'},
322
+ ...(!this.app.project.readOnly?[{value:'logs',label:'日志保留次数',description:keep===null?'不自动清理':`最近 ${keep} 次`}]:[]),
323
+ {value:'back',label:'返回主面板'},
324
+ ]);
325
+ if(choice==='back')return;
326
+ if(choice==='info')this.ui.show(`工作区:${this.app.projectDir}\n日志目录:${this.app.outputDir}\n模式:${this.app.project.readOnly?'只读运行包':'可编辑工作区'}`);
327
+ else await this.logSettings();
328
+ }
269
329
  }
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});
330
+ async logSettings(){
331
+ const keep=this.app.logSettings().keepRuns;
332
+ const mode=await this.ui.select('保留策略(整个项目合计,仅清理日志,保留业务文件)',[{value:'count',label:'仅保留最近 N 次已结束运行'},{value:'all',label:'不自动清理'},{value:'back',label:'返回'}]);
333
+ if(mode==='back')return;
334
+ const count=mode==='all'?null:Number(await this.ui.input('保留次数(例如 10)',{value:String(keep??10),files:false}));
335
+ await this.app.configureLogs(count);this.ui.show('日志保留设置已保存。');
336
+ }
337
+ async openRun(entry,{initialView}={}){
338
+ const record=this.app.getRun(entry.id)??await this.app.readRun(entry.id),workflows=await this.app.listWorkflows();
339
+ await this.ui.observeRun({app:this.app,runId:record.id,record,flow:workflows.find(flow=>flow.id===record.workflowId),workflows,initialView});
277
340
  }
278
341
  async plugins(){
279
342
  this.ui.clearMessages?.();
@@ -339,7 +402,12 @@ export class TerminalSession{
339
402
  const closure=await this.app.checkWorkflow([...entries][0]);
340
403
  const ids=new Set(closure.plugins);
341
404
  for(const id of [...entries].slice(1))for(const plugin of (await this.app.checkWorkflow(id)).plugins)ids.add(plugin);
342
- if(includeWeb)ids.add('@mytools/web');
405
+ if(includeWeb){
406
+ const provider=[...this.app.host.definitions.values()].find(plugin=>typeof plugin.services?.web==='function');
407
+ if(!provider)throw new Error('未安装提供 Web 服务的插件');
408
+ ids.add(provider.id);
409
+ }
410
+ for(const id of ids)for(const dependency of Object.keys(this.app.host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
343
411
  const environments={};
344
412
  for(const id of ids){
345
413
  const plugin=this.app.host.definitions.get(id),declarations=new Map();
@@ -363,7 +431,7 @@ export class TerminalSession{
363
431
 
364
432
  export async function startTerminal(app,options={}){
365
433
  const {TerminalView}=await import('./view.js');
366
- const ui=new TerminalView({baseDir:process.cwd()}),session=new TerminalSession(app,ui,options);
434
+ const ui=new TerminalView({baseDir:options.baseDir??process.cwd(),previewLabel:options.previewLabel}),session=new TerminalSession(app,ui,options);
367
435
  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);};
368
436
  process.once('SIGTERM',terminate);
369
437
  try{ui.start();await session.run();}finally{process.removeListener('SIGTERM',terminate);await ui.close();}
@@ -6,6 +6,7 @@ export const cleanText=value=>stripVTControlCharacters(String(value)).replace(/[
6
6
  const color=(rgb,text)=>Object.hasOwn(process.env,'NO_COLOR')?text:`\x1b[38;2;${rgb}m${text}\x1b[39m`;
7
7
  export const palette={
8
8
  brand:text=>color('78;201;176',text),
9
+ error:text=>color('244;117;117',text),
9
10
  text:text=>color('229;233;237',text),
10
11
  muted:text=>color('139;149;158',text),
11
12
  border:text=>color('74;101;99',text),