@yanglaofish/fishtools 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ npm install -g @yanglaofish/fishtools
17
17
  fst
18
18
  ```
19
19
 
20
- 也可从 GitHub Release 下载 `.tgz` 后执行 `npm install -g ./yanglaofish-fishtools-0.1.3.tgz`。没有 Node/npm 的机器使用 Release 中的 Windows `.zip` 或 Linux `.tar.gz` 自包含包,解压后运行 `fst.cmd` 或 `./fst`。发布结果以 [GitHub Releases](https://github.com/yanglaofish/Fishtools/releases) 为准;当前仓库为私有,下载附件需要仓库访问权限。
20
+ 也可从 GitHub Release 下载 `.tgz` 后执行 `npm install -g ./yanglaofish-fishtools-0.1.4.tgz`。没有 Node/npm 的机器使用 Release 中的 Windows `.zip` 或 Linux `.tar.gz` 自包含包,解压后运行 `fst.cmd` 或 `./fst`。发布结果以 [GitHub Releases](https://github.com/yanglaofish/Fishtools/releases) 为准;当前仓库为私有,下载附件需要仓库访问权限。
21
21
 
22
22
  `fst` 在交互终端里启动终端工作台(TUI);`fst web` 启动 Web 并打印访问地址;`fst run hello` 直接执行工作流。首次使用在 `~/.fishtools` 创建工作区,也可通过 `FISHTOOLS_HOME` 指定。安装包已包含官方插件、页面和生产依赖,不需要源码仓库或前端构建。
23
23
 
@@ -39,6 +39,10 @@ fst --project ./my-project pack hello --target linux-x64-glibc --web
39
39
 
40
40
  启动 `fst` 后输入 `/` 会展开操作列表,使用上下箭头选择、Tab 补全、Enter 确认,Escape 返回,Ctrl+C 取消当前输入或运行。在主提示符处 Ctrl+C 退出,有未保存修改时会先确认。
41
41
 
42
+ 首页展示 FISHtools 字符标识、欢迎介绍、当前工作区以及插件和工作流数量;小窗口自动使用紧凑标题。首次使用可以输入 `/run hello`,按提示输入文本(也可保留默认值),体验“转为大写 → 追加标记 → 保存文件”;执行后输入 `/logs` 阅读日志。首页只推荐当前工作区实际存在的工作流,空工作区提示用 `/new` 创建。
43
+
44
+ `/plugins` 直接列出当前已加载的插件名称、版本和状态。上下选择后可查看用途、编排能力、输入输出、环境声明及斜杠命令;有配置的插件提供配置入口,自行安装的插件提供移除入口。内置插件标明来源,避免误以为没有安装。`/help` 在终端内提供可翻页的用法说明。
45
+
42
46
  | 操作 | 功能 |
43
47
  | --- | --- |
44
48
  | `/workflows`、`/new` | 选择已有工作流,或新建流程 |
@@ -46,7 +50,8 @@ fst --project ./my-project pack hello --target linux-x64-glibc --web
46
50
  | `/outputs`、`/save` | 选择汇总输出,保存为 Web 可继续编辑的同一份流程 |
47
51
  | `/run` | 逐字段填写运行参数并执行,显示状态、结果和可直接阅读的 Markdown 日志路径 |
48
52
  | `/logs` | 在终端内翻页阅读历史日志,设置整个项目保留最近 N 次日志 |
49
- | `/plugins`、`/pack` | 安装/移除插件,勾选工作流并选择目标平台打包 |
53
+ | `/plugins` | 浏览已安装插件、用途及能力,进入配置、安装和移除操作 |
54
+ | `/pack` | 勾选工作流并选择目标平台打包 |
50
55
  | `/config` | 编辑并保存插件声明的配置 |
51
56
  | `/web`、`/help`、`/exit` | 启动可选 Web、查看说明、退出 |
52
57
 
@@ -54,7 +59,7 @@ fst --project ./my-project pack hello --target linux-x64-glibc --web
54
59
 
55
60
  插件可以注册自己的命令:官方示例提供 `/text.upper`、交互式 `/text.ask` 和 `/env.check`。安装或移除插件会同步刷新 `/` 菜单;命令和工作流共用日志、取消和结果机制。
56
61
 
57
- 在字符串参数中输入 `@` 可选择本地文件或目录,引用结果为绝对路径;`@@文字` 表示以 @ 开头的普通文字。`/run hello @"输入 参数.json"` 则明确读取文件中的 JSON 对象作为运行输入。文件路径相对启动时的目录(屏幕中会显示),不会自动上传文件,也不会把文件内容当作命令。路径补全无需额外安装 `fd`。
62
+ 在字符串参数中输入 `@` 可选择本地文件或目录,引用结果为绝对路径;`@@文字` 表示以 @ 开头的普通文字。`/run hello @"输入 参数.json"` 则明确读取文件中的 JSON 对象作为运行输入。文件路径相对启动时的目录(在 `/help` 中显示),不会自动上传文件,也不会把文件内容当作命令。路径补全无需额外安装 `fd`。
58
63
 
59
64
  非 TTY(例如重定向输入)直接执行 `fst` 只显示帮助;自动化请使用 `fst run`、`fst pack` 等子命令。只读运行包提供终端选流程、填写输入和运行功能。
60
65
 
package/docs/releases.md CHANGED
@@ -38,6 +38,7 @@ git push origin v0.1.4
38
38
 
39
39
  - Secret `NPM` 仅传入最后的发布步骤。需要有创建/发布 `@yanglaofish/fishtools` 的权限,以及 npm 自动发布要求的 Bypass 2FA 配置。缺失或权限不足时保留失败状态,修正 Secret 后重跑失败任务。
40
40
  - npm 与 GitHub 不能进行同一个原子事务。若 npm 已成功、GitHub 暂时失败,使用 Actions 的 **Re-run failed jobs**;同版本归档 SHA-512 一致才跳过 npm 并继续 GitHub 发布。不同内容必须增加版本,不能覆盖。
41
+ - npm 首次发布可能先返回成功并提示正在处理,而注册表暂时仍返回 404。发布后会等待最多约 5 分钟再核对 SHA-512;若仍未可见,等待 `npm view @yanglaofish/fishtools@版本号` 能查询到后,再重跑失败任务,避免在处理阶段重复提交。
41
42
  - GitHub Release 先创建草稿,附件齐备后公开;重试可补齐草稿。已公开 Release 不自动覆盖附件。
42
43
  - 也可使用 `gh workflow run release.yml --ref v0.1.3` 针对现有 tag 重新触发。由于完整重建可能产生不同归档,优先重跑失败任务,以复用原始已验证产物。
43
44
  - 仓库当前为 private,Release 也沿用仓库访问权限;npm 包公开发布。未启用要求公开源码仓库的 npm provenance,不改变仓库可见性。
@@ -57,3 +58,7 @@ git push origin v0.1.4
57
58
  `v0.1.2` 的 Linux 全量验收和 Windows 新包安装验收通过,但 Windows Web 测试在运行日志写完前删除了临时目录,出现 `run.md` 写入 EPERM。Node 测试清理钩子按注册顺序执行;`v0.1.3` 调整该测试为先关闭 Web 和应用、等待日志完成,再删除目录。此修正保留完整发布门槛,不跳过失败用例。
58
59
 
59
60
  同一提交的普通分支 CI 还发现 Windows npm 发布预演超出 120 秒预算;预演时限调整到 300 秒,与打包验收一致,所有包名、版本和执行结果断言保留。
61
+
62
+ `v0.1.3` 的 Windows/Linux 全量测试、自包含包执行和 Docker 验收全部通过。npm 于 2026-10-10 23:18(北京时间)接受 `@yanglaofish/fishtools@0.1.3`,首次发布后的处理延迟曾导致注册表返回 404。待注册表可见后重跑原发布任务,核对相同归档并跳过重复上传,于 23:24 完成 [GitHub Release](https://github.com/yanglaofish/Fishtools/releases/tag/v0.1.3),包含 npm、Windows、Linux 三个归档及 SHA256SUMS。[发布流程](https://github.com/yanglaofish/Fishtools/actions/runs/38062508265) 全部成功;后续发布脚本已增加有上限的注册表可见性等待。
63
+
64
+ 首次发布时第三方 npm 镜像可能尚未同步归档。若遇到镜像 404,可指定官方源安装:`npm install -g @yanglaofish/fishtools --registry=https://registry.npmjs.org`。
@@ -165,3 +165,10 @@ MiB = 1,048,576 字节。以下为本机单次测量,不是性能保证;运
165
165
  ## 分层便携包阶段远端结果(历史)
166
166
 
167
167
  代码与测试提交 e55075e 的 [Windows / Ubuntu CI](https://github.com/yanglaofish/Fishtools/actions/runs/38053011826) 均通过;Windows 5 分 13 秒、Ubuntu 1 分 27 秒。验收文档归档提交不改变已验证代码。
168
+ # 2026-10-11 TUI 上手体验修正
169
+
170
+ - 修正首页缺少产品介绍、示例入口以及插件清单不显眼的问题:显示 FISHtools 字符标识、欢迎语、当前工作区、数量和真实工作流示例。
171
+ - `/plugins` 直接列出已有插件,详情展示声明的动作、输入输出、环境、配置及命令;维护模式仍保留说明,仅隐藏不可执行的命令入口。
172
+ - 完整页面按终端高度分配空间;80×24、52×24、40×20 自动化渲染检查覆盖首页和可滚动插件列表。
173
+ - 本地终端与 Markdown 阅读器共 26 项测试通过;Windows 与 WSL 真实 PTY 检查首页、插件列表和详情返回。安装及远端全量验证以对应 CI 最终结果为准。
174
+ - 修复 npm 发布预演依赖远端版本状态的问题:仅预演采用离线独立空缓存,实际发布继续检查注册表版本和归档校验值。
@@ -0,0 +1,44 @@
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',
7
+ ].join('\n');
8
+
9
+ export const pluginStatus=plugin=>({ready:'可用',pending:'等待服务',failed:'失败',stopped:'已停止'})[plugin.runtime?.status]??'未知';
10
+
11
+ export function gettingStarted(flows,{readOnly=false}={}){
12
+ const sample=flows.find(w=>w.id==='hello')??flows[0];
13
+ 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 查看完整用法和文件参数示例',
18
+ ].join('\n');
19
+ }
20
+
21
+ function fields(schema){
22
+ if(!schema)return '未声明';
23
+ if(!schema.properties)return `类型:${schema.type??'由插件约束定义'}`;
24
+ const entries=Object.entries(schema.properties);
25
+ return entries.length?entries.map(([key,s])=>`${s.title??key} (${key}, ${s.type??'任意类型'}${schema.required?.includes(key)?', 必填':''})${s.description?`:${s.description}`:''}`).join(';'):'无';
26
+ }
27
+
28
+ export function pluginDetails(plugin,actions,commands){
29
+ return [
30
+ `# ${plugin.title??plugin.id}`,
31
+ plugin.description??'作者尚未提供用途说明。',
32
+ `标识:${plugin.id}\n\n版本:${plugin.version}\n\n来源:${plugin.removable?'自行安装(可移除)':'发行包内置 / 项目预置'}\n\n状态:${pluginStatus(plugin)}`,
33
+ ...(plugin.runtime?.missing?.length?[`缺少服务:${plugin.runtime.missing.join(', ')}`]:[]),
34
+ `声明平台:${plugin.platforms?.join(', ')??'作者未声明'}\n\n声明架构:${plugin.architectures?.join(', ')??'作者未声明'}`,
35
+ '## 可用于编排的能力',
36
+ ...(actions.length?actions.map(a=>`### ${a.title??a.id}\n\n${a.description??''}\n\n能力 ID:${a.id}\n\n输入:${fields(a.input)}\n\n输出:${fields(a.output)}`):['此插件没有提供编排动作,可能提供 Web、共享服务或交互命令。']),
37
+ '## 斜杠命令',commands.length?commands.map(c=>`- /${c.name}:${c.description??'未提供说明'}`).join('\n'):'没有注册斜杠命令。',
38
+ '## 插件配置',plugin.config?fields(plugin.config):'无需配置。',
39
+ '## 环境声明',
40
+ ...[plugin,...actions].flatMap(item=>Object.entries(item.environments??{}).map(([name,e])=>`- ${item.id} / ${name}:${({host:'使用宿主机环境',bundled:'随包携带',tool:'使用 Fishtools 自带环境'})[e.source]??e.source}`)),
41
+ '环境与平台由插件作者声明;具体执行结果请在 /logs 查看。',
42
+ '编排用法:/new 新建工作流 → /add 选择上述能力 → 填写参数 → /save → /run。',
43
+ ].join('\n\n');
44
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import path from 'node:path';
3
3
  import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources } from './model.js';
4
4
  import { editValue, needsJsonEditor } from './schema.js';
5
+ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
5
6
 
6
7
  export class TerminalSession{
7
8
  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;}
@@ -10,10 +11,10 @@ export class TerminalSession{
10
11
  commands(){return [...commands,...this.app.listCommands()];}
11
12
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
12
13
  async run(){
13
- this.ui.show('输入 / 查看操作,或输入 /workflows 选择已有工作流。\n参数中 @文件 表示路径;/run 可使用 @参数.json。');
14
+ this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project));
14
15
  try{
15
16
  while(!this.exiting){
16
- this.ui.summary(`${this.app.project.readOnly?'运行模式':'编排模式'} · ${this.app.projectDir}\n${this.flow?`${this.flow.title} · ${this.flow.steps.length} 步 · ${this.dirty?'未保存':'已保存'}`:'尚未选择工作流'}\n文件路径基准:${this.baseDir}`);
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?'未保存':'已保存'})`:''}`);
17
18
  let text;
18
19
  try{text=await this.ui.input('fst > 输入 / 选择操作',{commands:this.commands()});}
19
20
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.discard())break;}catch{}continue;}
@@ -28,7 +29,7 @@ export class TerminalSession{
28
29
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
29
30
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
30
31
  switch(command){
31
- case 'help':this.ui.show(this.commands().map(c=>`/${c.name} ${c.description}`).join('\n'));return;
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;
32
33
  case 'exit':if(await this.discard())this.exiting=true;return;
33
34
  case 'workflows':{
34
35
  const flows=await this.app.listWorkflows();
@@ -69,11 +70,11 @@ export class TerminalSession{
69
70
  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')}`);}
70
71
  finally{unsubscribe();done();}
71
72
  }
72
- async configure(){
73
+ async configure(selectedId){
73
74
  this.writable();
74
75
  const plugins=this.app.listPlugins().filter(p=>p.config);
75
76
  if(!plugins.length){this.ui.show('当前插件没有声明可配置字段。');return;}
76
- const id=await this.ui.select('选择要配置的插件',plugins.map(p=>({value:p.id,label:p.title??p.id})));
77
+ const id=selectedId??await this.ui.select('选择要配置的插件',plugins.map(p=>({value:p.id,label:p.title??p.id})));
77
78
  const plugin=plugins.find(p=>p.id===id);
78
79
  const value=await editValue(this.ui,plugin.config,this.app.project.config?.[id]??{}, {baseDir:this.baseDir,required:true,label:plugin.title??id});
79
80
  await this.app.configurePlugin(id,value);this.ui.show('插件配置已保存到当前工作区。');
@@ -188,22 +189,53 @@ export class TerminalSession{
188
189
  }finally{unsubscribe();done();}
189
190
  }
190
191
  async plugins(){
191
- const plugins=this.app.listPlugins();this.ui.show(plugins.map(p=>`${p.title??p.id} · ${p.id}@${p.version} · 状态:${({ready:'可用',pending:'等待服务',failed:'失败',stopped:'已停止'})[p.runtime?.status]??'未知'}${p.runtime?.missing?.length?`(缺少 ${p.runtime.missing.join(', ')})`:''} · 平台:${p.platforms?.join(', ')??'作者未声明'}`).join('\n'));
192
- if(this.app.project.readOnly)return;
193
- const action=await this.ui.select('插件管理',[{value:'local',label:'安装本地插件 / @路径'},{value:'npm',label:'安装 npm 插件包'},{value:'remove',label:'移除已安装插件'},{value:'maintenance',label:this.app.maintenance?'恢复插件服务':'进入维护模式(停止有状态服务)'},...(this.web?[{value:'stop-web',label:'停止 Web 工作台'}]:[]),{value:'back',label:'返回'}]);
192
+ this.ui.clearMessages?.();
193
+ while(true){
194
+ const plugins=this.app.listPlugins();
195
+ const action=await this.ui.select(`已安装插件(${plugins.length})· ↑↓ 选择插件查看能力与配置`,[
196
+ ...plugins.map(p=>({value:`plugin:${p.id}`,label:`${p.title??p.id} · v${p.version} · ${pluginStatus(p)}`,description:p.description??p.id})),
197
+ ...(!this.app.project.readOnly?[{value:'local',label:'+ 安装本地插件 / @路径'},{value:'npm',label:'+ 安装 npm 插件包'},{value:'maintenance',label:this.app.maintenance?'恢复插件服务':'进入维护模式(停止有状态服务)'},...(this.web?[{value:'stop-web',label:'停止 Web 工作台'}]:[])]:[]),{value:'back',label:'返回工作台'},
198
+ ]);
199
+ if(action.startsWith('plugin:')){
200
+ try{await this.pluginPage(action.slice(7));}catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
201
+ continue;
202
+ }
194
203
  if(action==='maintenance'){await this.app.setMaintenance(!this.app.maintenance);this.ui.show(this.app.maintenance?'已进入维护模式,可修改插件与配置。':'插件服务已恢复。');return;}
195
204
  if(action==='stop-web'){await this.web.close();this.web=null;this.ui.show('Web 已停止。');return;}
196
205
  if(action==='back')return;
197
- if(action==='remove'){
198
- const id=await this.ui.select('选择要移除的插件',plugins.filter(p=>p.removable).map(p=>({value:p.id,label:p.title??p.id})));
199
- if(await this.ui.confirm(`移除 ${id}?已有工作流可能依赖它。`))await this.app.removePlugin(id);
200
- }else{
206
+ this.writable();
207
+ if(action==='local'||action==='npm'){
201
208
  const text=await this.ui.input(action==='npm'?'npm 包名(支持 @scope/name)':'输入 @ 并选择插件目录或 .tgz',{files:action==='local'});
202
209
  const source=action==='local'?fileReference(text,this.baseDir):text.trim();if(!source)throw new Error('插件来源不能为空');
203
210
  if(!await this.ui.confirm(`安装并加载可信插件:${source}?`))return;
204
211
  const done=this.ui.busy('正在安装插件…');try{await this.app.installPlugin(source);}finally{done();}
205
212
  }
206
213
  this.ui.show('插件列表已更新。');
214
+ }
215
+ }
216
+ async pluginPage(id){
217
+ while(true){
218
+ const plugin=this.app.listPlugins().find(p=>p.id===id);if(!plugin)return;
219
+ const definition=this.app.host.definitions.get(id);
220
+ // 详情显示作者声明;维护模式只停止执行,不能让能力说明消失。
221
+ const actions=Object.entries(definition.actions??{}).map(([name,a])=>({...a,id:`${id}:${name}`}));
222
+ const declaredCommands=Object.entries(definition.commands??{}).map(([name,c])=>({...c,name}));
223
+ const commands=this.app.listCommands().filter(c=>c.plugin===id);
224
+ const choice=await this.ui.select(`${plugin.title??id} · ${pluginStatus(plugin)} · ${actions.length} 项能力`,[
225
+ {value:'details',label:'查看用途、能力、输入输出和环境要求'},
226
+ ...(!this.app.project.readOnly&&plugin.config?[{value:'configure',label:'设置插件配置'}]:[]),
227
+ ...commands.map(c=>({value:`command:${c.name}`,label:`运行 /${c.name}`,description:c.description})),
228
+ ...(!this.app.project.readOnly&&plugin.removable?[{value:'remove',label:'移除此插件'}]:[]),
229
+ {value:'back',label:'返回插件列表'},
230
+ ]);
231
+ if(choice==='back')return;
232
+ if(choice==='details')await this.ui.viewMarkdown(plugin.title??id,pluginDetails(plugin,actions,declaredCommands));
233
+ else if(choice==='configure')await this.configure(id);
234
+ else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[]);
235
+ else if(choice==='remove'){
236
+ this.writable();if(await this.ui.confirm(`移除 ${id}?已有工作流可能依赖它。`)){await this.app.removePlugin(id);this.ui.show('插件已移除。');return;}
237
+ }
238
+ }
207
239
  }
208
240
  async pack(){
209
241
  this.writable();if(this.dirty){if(!await this.ui.confirm('先保存当前工作流再选择打包?'))return;await this.execute('/save');}
@@ -4,6 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { stripVTControlCharacters } from 'node:util';
5
5
  import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
6
6
  import { MarkdownLogViewer } from './log-viewer.js';
7
+ import { logo } from './presentation.js';
8
+ import { VERSION } from '../version.js';
7
9
 
8
10
  export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'PROMPT_CANCELLED'});
9
11
  const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
@@ -55,6 +57,20 @@ export class TerminalView{
55
57
  input(data);
56
58
  },resize);
57
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);
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
+ // 根据整屏换行后的行数选择标题,而不是只判断终端宽度。
63
+ this.header.render=width=>{
64
+ 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);
66
+ };
67
+ this.transcript.render=width=>{
68
+ 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
+ if(lines.length<=budget)return lines;
70
+ if(!budget)return [];
71
+ const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
72
+ return [...excerpt,truncateToWidth(' … /help 查看用法 · /logs 查看运行结果',width)];
73
+ };
58
74
  this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
59
75
  }
60
76
  start(){this.tui.start();}
@@ -63,7 +79,8 @@ export class TerminalView{
63
79
  await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
64
80
  if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
65
81
  }
66
- summary(text){this.header.setText(`Fishtools / fst\n${safe(text)}`);this.tui.requestRender();}
82
+ summary(text){this.summaryText=safe(text);this.tui.requestRender();}
83
+ clearMessages(){this.logs=[];this.transcript.setText('');this.tui.requestRender();}
67
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();}
68
85
  async viewMarkdown(title,text){
69
86
  const previousPending=this.pending??null,previousViewer=this.logViewer;
@@ -97,7 +114,14 @@ export class TerminalView{
97
114
  }
98
115
  select(message,items){
99
116
  if(!items.length)throw new Error('当前没有可选择的项目');
100
- return this.ask(message,new SelectList(items.map(i=>({...i,label:safe(i.label),description:i.description?safe(i.description):undefined})),8,theme));
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);
119
+ 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;
121
+ list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
122
+ return render(width);
123
+ };
124
+ return this.ask(message,list);
101
125
  }
102
126
  async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
103
127
  busy(message,cancel){
@@ -1,2 +1,2 @@
1
1
  // 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
2
- export const VERSION="0.1.3";
2
+ export const VERSION="0.1.4";