glmcode 0.1.12 → 0.1.14

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 (3) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +114 -45
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.14 (2026-08-29)
4
+
5
+ - **手机端渲染进一步修复**:LOGO 启动画面、底部状态栏、灰色下拉菜单在窄屏/手机 Termux 下的显示优化
6
+ - 命令菜单扩容至 29 条,补齐常用斜杠命令
7
+ - 技能(skills)支持读取用户目录:`~/.glmcode/skills` 与包内 `skills/` 双目录加载
8
+ - 输入提示符改为目录提示符 `shortDir() > `,当前工作目录一目了然
9
+ - 通过 40x20 窄屏仿真 + 35 项 `--selftest` 自检,全阶段无超宽换行
10
+ - 版本升级至 0.1.14
11
+
12
+ ## 0.1.13 (2026-08-28)
13
+
14
+ - **修复手机 Termux 窄屏 TUI 渲染故障**:状态栏/菜单原按「字符数」截断,CJK 宽字符实际占 2 列,超宽后触发终端自动换行+滚动,导致旧菜单擦不掉、`/exit` 叠影、状态栏混乱
15
+ - 新增 `strW` / `truncW` 列宽工具:所有文本按「显示列宽」截断(CJK 等宽字符计 2 列),从根上杜绝超宽换行
16
+ - 状态栏、菜单、输入行、光标定位全部改用列宽计算
17
+ - 修复菜单绘制顺序:改为**底部对齐正序**绘制,此前第一项画在最底行导致菜单整体倒序
18
+ - 修复滚动指示符 `⋯`/`⋮`:改为行首绘制,不再压盖菜单文本;同时修复「上滚指示永不显示」的 bug
19
+ - 40x20 PTY + pyte 仿真验证全阶段无超宽行,菜单/过滤/退格/滚动/超长输入全部正常
20
+ - 版本升级至 0.1.13
21
+
3
22
  ## 0.1.12 (2026-08-28)
4
23
 
5
24
  - **新增 `COMPATIBILITY.md` 跨平台兼容性说明**:覆盖 Windows / macOS / Linux / Android(Termux) / FreeBSD 等全部支持系统,含功能支持矩阵、各平台安装方式、跨平台自动适配机制、已知限制与自测清单
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glmcode",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "GLM coding agent CLI with shell tools, skills (SKILL.md/zip auto-extract), streaming chat, slash-command menu and bottom status bar. Auto-installs its dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import {fileURLToPath} from "node:url";
7
7
  import termkit from "terminal-kit";
8
8
  const term = termkit.terminal;
9
9
 
10
- const VERSION="0.1.12";
10
+ const VERSION="0.1.13";
11
11
  const BASE_URL="https://open.bigmodel.cn/api/paas/v4";
12
12
  const API_KEYS_URL="https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys";
13
13
  const HOME=os.homedir();
@@ -16,7 +16,9 @@ const AUTH_FILE=path.join(DATA_DIR,"auth.json");
16
16
  const CONFIG_FILE=path.join(DATA_DIR,"config.json");
17
17
  const PACKAGE_ROOT=path.resolve(path.dirname(new URL(import.meta.url).pathname),"..");
18
18
  const MAIN_FILE_DIR=path.dirname(fileURLToPath(import.meta.url));
19
- const SKILLS_DIR=process.env.GLM_SKILLS_DIR || path.join(PACKAGE_ROOT,"skills");
19
+ const SKILLS_DIR=process.env.GLM_SKILLS_DIR || path.join(HOME,".glmcode","skills");
20
+ const PKG_SKILLS_DIR=path.join(PACKAGE_ROOT,"skills");
21
+ function skillDirs(){return [PKG_SKILLS_DIR,SKILLS_DIR];}
20
22
 
21
23
  const C={reset:"\x1b[0m",dim:"\x1b[2m",bold:"\x1b[1m",cyan:"\x1b[36m",cyanB:"\x1b[1;36m",green:"\x1b[32m",yellow:"\x1b[33m",red:"\x1b[31m",mag:"\x1b[35m",gray:"\x1b[90m"};
22
24
  const color=(s,c)=>c+s+C.reset;
@@ -81,14 +83,14 @@ async function ensureDeps(){
81
83
  }
82
84
 
83
85
  /* ---- 技能系统:SKILL.md + zip 自动解压 ---- */
84
- async function extractZipSkills(){
85
- if(!fs.existsSync(SKILLS_DIR))return;
86
- const zips=fs.readdirSync(SKILLS_DIR).filter(x=>x.toLowerCase().endsWith(".zip")).sort((a,b)=>a.localeCompare(b));
86
+ async function extractZipIn(dir){
87
+ if(!fs.existsSync(dir))return;
88
+ const zips=fs.readdirSync(dir).filter(x=>x.toLowerCase().endsWith(".zip")).sort((a,b)=>a.localeCompare(b));
87
89
  if(!zips.length)return;
88
90
  let AdmZip=null;
89
91
  try{AdmZip=(await import("adm-zip")).default;}catch{return;}
90
92
  for(const f of zips){
91
- const zp=path.join(SKILLS_DIR,f), target=path.join(SKILLS_DIR,f.replace(/\.zip$/i,""));
93
+ const zp=path.join(dir,f), target=path.join(dir,f.replace(/\.zip$/i,""));
92
94
  if(fs.existsSync(target))continue;
93
95
  try{
94
96
  new AdmZip(zp).extractAllTo(target,true);
@@ -103,25 +105,34 @@ async function extractZipSkills(){
103
105
  }catch(e){console.log(color(`[glmcode] 解压失败 ${f}: ${e.message}`,C.red));}
104
106
  }
105
107
  }
106
- async function loadSkills(){
107
- await extractZipSkills();
108
- skills=new Map(); fs.mkdirSync(SKILLS_DIR,{recursive:true});
109
- const entries=fs.readdirSync(SKILLS_DIR,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name));
108
+ async function extractZipSkills(){for(const d of skillDirs())await extractZipIn(d);}
109
+ function loadSkillsFrom(dir){
110
+ if(!fs.existsSync(dir))return;
111
+ const entries=fs.readdirSync(dir,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name));
110
112
  for(const entry of entries){
111
113
  if(!entry.isDirectory())continue;
112
- const file=path.join(SKILLS_DIR,entry.name,"SKILL.md"); if(!fs.existsSync(file))continue;
114
+ const file=path.join(dir,entry.name,"SKILL.md"); if(!fs.existsSync(file))continue;
113
115
  const content=fs.readFileSync(file,"utf8");
114
116
  let name=entry.name, description="";
115
117
  const fm=content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
116
118
  if(fm){const n=fm[1].match(/^name:\s*(.+)$/m),d=fm[1].match(/^description:\s*(.+)$/m);if(n)name=n[1].trim();if(d)description=d[1].trim();}
117
119
  if(!description)description=(content.trim().split("\n")[0]||name).replace(/^#\s*/,"").slice(0,60);
118
- skills.set(name,{name,description,content});
120
+ skills.set(name,{name,description,content,dir});
119
121
  }
120
122
  }
123
+ async function loadSkills(){
124
+ await extractZipSkills();
125
+ skills=new Map();
126
+ for(const d of skillDirs()){fs.mkdirSync(d,{recursive:true});loadSkillsFrom(d);}
127
+ }
121
128
  const BASE_COMMANDS={
122
129
  "/help":"显示全部命令","/clear":"清空对话","/thinking":"切换思考模式(开/关)",
123
- "/model":"切换模型 /model <ID>","/pwd":"显示当前目录","/cd":"切换目录 /cd <路径>",
124
- "/ls":"列出文件","/cat":"查看文件 /cat <路径>","/run":"执行命令 /run <shell>","/install":"安装程序/依赖 /install <程序>","/exit":"退出"
130
+ "/model":"切换模型 /model <ID>","/temp":"设置温度 /temp <0-2>","/pwd":"显示当前目录","/cd":"切换目录 /cd <路径>",
131
+ "/ls":"列出文件","/cat":"查看文件 /cat <路径>","/find":"查找文件 /find <名字>","/grep":"搜索内容 /grep <模式>",
132
+ "/touch":"创建空文件 /touch <路径>","/mkdir":"创建目录 /mkdir <路径>","/rm":"删除文件/目录 /rm <路径>","/cp":"复制 /cp <源> <目标>","/mv":"移动/重命名 /mv <源> <目标>",
133
+ "/run":"执行命令 /run <shell>","/install":"安装程序/依赖 /install <程序>","/shell":"显示系统/Shell信息","/whoami":"当前用户","/date":"当前时间",
134
+ "/history":"命令历史","/config":"查看配置","/compact":"压缩上下文","/save":"保存会话 /save <名字>","/load":"载入会话 /load <名字>",
135
+ "/skills":"技能列表与目录","/skill":"管理技能 /skill new <名字>","/reload":"重载技能","/auth":"查看API Key状态","/exit":"退出"
125
136
  };
126
137
  function commands(){const d={...BASE_COMMANDS};for(const s of skills.values())d["/"+s.name]="技能: "+s.description;return d;}
127
138
 
@@ -209,7 +220,7 @@ function showMenu(prefix){
209
220
  for(const [c,d] of list)console.log(` ${color(c,C.cyanB).padEnd(20)} ${color(d,C.gray)}`);
210
221
  console.log("");
211
222
  }
212
- function handleCommand(line){
223
+ async function handleCommand(line){
213
224
  const [cmd,...rest]=line.trim().split(/\s+/), arg=rest.join(" ");
214
225
  if(cmd.slice(1) && skills.has(cmd.slice(1))){const s=skills.get(cmd.slice(1));messages.push({role:"system",content:s.content});console.log(color(`已加载技能:${s.name}`,C.green));console.log(color("技能指令已注入,现在输入你的任务:",C.gray));return;}
215
226
  if(cmd==="/help"){showMenu("/");}
@@ -220,6 +231,26 @@ function handleCommand(line){
220
231
  else if(cmd==="/cd"){try{cwd=path.resolve(cwd,arg||HOME);process.chdir(cwd);console.log(color(`-> ${cwd}`,C.green));}catch(e){console.log(color(`切换失败: ${e.message}`,C.red));}}
221
232
  else if(cmd==="/ls"){try{for(const f of fs.readdirSync(cwd,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name)))console.log(` ${f.name}${f.isDirectory()?"/":""}`);}catch(e){console.log(color(e.message,C.red));}}
222
233
  else if(cmd==="/cat"){try{console.log(fs.readFileSync(path.resolve(cwd,arg),"utf8").slice(0,8000));}catch(e){console.log(color(`失败: ${e.message}`,C.red));}}
234
+ else if(cmd==="/find"){try{const q=(arg||"").toLowerCase();let hit=0;const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);if(e.isDirectory()){try{walk(p);}catch{}}else if(!q||e.name.toLowerCase().includes(q)){console.log(" "+p);hit++;}}};walk(cwd);if(!hit)console.log(color("未找到匹配文件",C.gray));else console.log(color(`共 ${hit} 个`,C.gray));}catch(e){console.log(color(e.message,C.red));}}
235
+ else if(cmd==="/grep"){if(!arg)console.log("用法: /grep <模式>");else{let hit=0;const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);if(e.isDirectory()){try{walk(p);}catch{}}else{try{const c=fs.readFileSync(p,"utf8");if(c.includes(arg)){console.log(" "+p);hit++;}}catch{}}}};walk(cwd);if(!hit)console.log(color("未找到包含该内容的文件",C.gray));else console.log(color(`共 ${hit} 个`,C.gray));}}
236
+ else if(cmd==="/touch"){if(!arg)console.log("用法: /touch <路径>");else{try{fs.writeFileSync(path.resolve(cwd,arg),"");console.log(color(`已创建 ${path.resolve(cwd,arg)}`,C.green));}catch(e){console.log(color(e.message,C.red));}}}
237
+ else if(cmd==="/mkdir"){if(!arg)console.log("用法: /mkdir <路径>");else{try{fs.mkdirSync(path.resolve(cwd,arg),{recursive:true});console.log(color(`已创建目录 ${path.resolve(cwd,arg)}`,C.green));}catch(e){console.log(color(e.message,C.red));}}}
238
+ else if(cmd==="/rm"){if(!arg)console.log("用法: /rm <路径>");else{try{fs.rmSync(path.resolve(cwd,arg),{recursive:true,force:true});console.log(color(`已删除 ${arg}`,C.green));}catch(e){console.log(color(e.message,C.red));}}}
239
+ else if(cmd==="/cp"){const a=arg.split(/\s+/);if(a.length<2)console.log("用法: /cp <源> <目标>");else{try{fs.cpSync(path.resolve(cwd,a[0]),path.resolve(cwd,a[1]),{recursive:true});console.log(color(`已复制 ${a[0]} -> ${a[1]}`,C.green));}catch(e){console.log(color(e.message,C.red));}}}
240
+ else if(cmd==="/mv"){const a=arg.split(/\s+/);if(a.length<2)console.log("用法: /mv <源> <目标>");else{try{fs.renameSync(path.resolve(cwd,a[0]),path.resolve(cwd,a[1]));console.log(color(`已移动 ${a[0]} -> ${a[1]}`,C.green));}catch(e){console.log(color(e.message,C.red));}}}
241
+ else if(cmd==="/shell"){const s=shellInfo();console.log(`OS: ${process.platform} ${process.arch}\nShell: ${s.name}\n可执行: ${s.exe}`);}
242
+ else if(cmd==="/whoami"){console.log(os.userInfo().username||process.env.USER||"unknown");}
243
+ else if(cmd==="/date"){console.log(new Date().toString());}
244
+ else if(cmd==="/history"){TUI.history.forEach((x,i)=>console.log(` ${i+1}. ${x}`));if(!TUI.history.length)console.log(color("(空)",C.gray));}
245
+ else if(cmd==="/config"){console.log(JSON.stringify(config,null,2));}
246
+ else if(cmd==="/compact"){if(messages.length>2)messages=messages.slice(0,1).concat(messages.slice(-8));else messages=[];console.log(color(`已压缩上下文(当前 ${messages.length} 条)`,C.green));}
247
+ else if(cmd==="/temp"){if(arg){const t=parseFloat(arg);if(!isNaN(t)&&t>=0&&t<=2){config.temperature=t;saveConfig();console.log(color(`温度已设为 ${t}`,C.green));}else console.log(color("温度需在 0~2 之间",C.red));}else console.log(`当前温度: ${config.temperature}`);}
248
+ else if(cmd==="/save"){if(!arg)console.log("用法: /save <会话名>");else{const f=path.join(DATA_DIR,`session_${arg}.json`);saveJson(f,{savedAt:new Date().toISOString(),messages,cwd});console.log(color(`会话已保存: ${f}`,C.green));}}
249
+ else if(cmd==="/load"){if(!arg)console.log("用法: /load <会话名>");else{const f=path.join(DATA_DIR,`session_${arg}.json`);const d=readJson(f,null);if(!d||!d.messages)console.log(color(`会话不存在: ${arg}`,C.red));else{messages=d.messages;if(d.cwd)try{process.chdir(d.cwd);cwd=d.cwd;}catch{}console.log(color(`已载入会话 ${arg}(${d.messages.length} 条)`,C.green));}}}
250
+ else if(cmd==="/skills"){console.log(color("技能目录:",C.cyanB));for(const d of skillDirs())console.log(" "+d);console.log(color("已加载技能:",C.cyanB));if(!skills.size)console.log(color(" (无) 创建: /skill new <名字> 后把 SKILL.md 放进目录即可",C.gray));else for(const s of skills.values())console.log(` /${s.name} ${s.description} ${color(s.dir,C.gray)}`);}
251
+ else if(cmd==="/skill"){const parts=arg.split(/\s+/);if(parts[0]==="new"&&parts[1]){const d=path.join(SKILLS_DIR,parts[1]);fs.mkdirSync(d,{recursive:true});fs.writeFileSync(path.join(d,"SKILL.md"),`---\nname: ${parts[1]}\ndescription: ${parts[1]} 技能\n---\n# ${parts[1]}\n在这里写技能说明。\n`);console.log(color(`技能目录已创建: ${d}`,C.green));console.log(color("把文件放进该目录,/reload 生效。",C.gray));}else console.log("用法: /skill new <名字>");}
252
+ else if(cmd==="/reload"){await loadSkills();console.log(color(`已重载 ${skills.size} 个技能`,C.green));}
253
+ else if(cmd==="/auth"){const a=loadAuth();console.log(a?`API Key: ${mask(a.key)}\n来源: ${a.source}`:"未检测到 API Key");}
223
254
  else if(cmd==="/run"){if(!arg)console.log("用法: /run <命令>");else console.log(execTool("run_shell",{command:arg}));}
224
255
  else if(cmd==="/install"){installProgram(arg)}
225
256
  else if(cmd==="/exit"){term.grabInput(false);term("\n");console.log(color("再见",C.cyan));process.exit(0);}
@@ -227,14 +258,41 @@ function handleCommand(line){
227
258
  }
228
259
 
229
260
  function logo(){
230
- const rows=[" ██████╗ ██╗ ███╗ ███╗","██╔════╝ ██║ ████╗ ████║","██║ ███╗ ██║ ██╔████╔██║","██║ ██║ ██║ ██║╚██╔╝██║","╚██████╔╝ ███████╗ ██║ ╚═╝ ██║"," ╚═════╝ ╚══════╝ ╚═╝ ╚═╝"];
231
- const W=44;console.log(color("".padStart(W),C.cyan));console.log(color("✧ GLM ✧".padStart((W+"✧ GLM ✧".length)/2),C.cyanB));for(const r of rows)console.log(color(r.padStart((W+r.length)/2),C.cyanB));console.log(color("GLM Code · Coding Agent",C.cyanB));console.log(color(`v${VERSION}`,C.gray));console.log(color("输入 / 弹菜单 · ↑↓ 选择 · 回车执行 · Ctrl+C 退出",C.gray));console.log("");
261
+ const w=tw();
262
+ const art=[
263
+ " ██████╗ ██╗ ███╗ ███╗",
264
+ "██╔════╝ ██║ ████╗ ████║",
265
+ "██║ ███╗██║ ██╔████╔██║",
266
+ "██║ ██║██║ ██║╚██╔╝██║",
267
+ "╚██████╔╝███████╗██║ ╚═╝ ██║",
268
+ " ╚═════╝ ╚══════╝╚═╝ ╚═╝"
269
+ ];
270
+ for(const a of art)console.log(color(truncW(a,w),C.cyanB));
271
+ console.log(color(truncW(` Coding Agent · v${VERSION} · 输入 / 弹菜单 · ↑↓ 选择 · 回车执行`,w),C.gray));
272
+ console.log("");
232
273
  }
233
274
 
234
275
  /* =============== 真 TUI:悬浮菜单 + 上下键 + 实时过滤 + 底部状态栏 =============== */
235
- const PROMPT="┃ 输入( / 菜单 · ↑↓选择 · Enter执行) ";
276
+ const PROMPT=()=>`${shortDir()} > `;
236
277
  const TUI={input:"",cursor:0,items:[],sel:0,scroll:0,history:[],histIdx:-1};
237
- function statusText(){return ` 思考:${thinkingEnabled?"开":"关"} 模型:${config.model} 技能:${skills.size} 目录:${cwd} `;}
278
+ function tw(){return (term.width>0&&isFinite(term.width))?term.width:40;}
279
+ function th(){return (term.height>0&&isFinite(term.height))?term.height:24;}
280
+ const MENU_MAX=5;
281
+ /* ---- 显示列宽工具:CJK 等宽字符占 2 列,按列截断,杜绝超宽触发终端换行滚动 ---- */
282
+ function strW(s){let w=0;for(const ch of String(s)){w+=ch.codePointAt(0)>0x2e7f?2:1;}return w;}
283
+ function truncW(s,maxW){let w=0,o="";for(const ch of String(s)){const c=ch.codePointAt(0),cw=c>0x2e7f?2:1;if(w+cw>maxW)break;w+=cw;o+=ch;}return o;}
284
+ function shortDir(){
285
+ const home=HOME.replace(/[\\/]+$/,"");
286
+ if(cwd===home)return "~";
287
+ if(cwd.startsWith(home+path.sep))return "~"+cwd.slice(home.length);
288
+ // 非 HOME 目录:压缩为 …/末两级,避免超长路径刷爆状态栏/输入行
289
+ const parts=cwd.split(/[\\/]/).filter(Boolean);
290
+ if(parts.length<=2)return cwd;
291
+ return "…/"+parts.slice(-2).join("/");
292
+ }
293
+ function statusText(){
294
+ return ` GLMCode v${VERSION} · 思考:${thinkingEnabled?"开":"关"} 模型:${config.model} 技能:${skills.size} 目录:${shortDir()} `;
295
+ }
238
296
  function filterItems(input){
239
297
  const s=(input||"").trim();
240
298
  if(!s.startsWith("/"))return [];
@@ -246,43 +304,51 @@ function filterItems(input){
246
304
  return n.startsWith(name)||n.includes(name)||d.toLowerCase().includes(name);
247
305
  }).map(([c,d])=>`${c} ${d}`);
248
306
  }
249
- function menuMax(){const h=term.height>0?term.height:24;return Math.max(1,Math.min(10,h-3));}
307
+ function menuMax(){return MENU_MAX;}
250
308
  function keepSelVisible(){
251
- const MAX=menuMax();
309
+ const MAX=MENU_MAX;
252
310
  if(TUI.sel<TUI.scroll)TUI.scroll=TUI.sel;
253
311
  else if(TUI.sel>=TUI.scroll+MAX)TUI.scroll=TUI.sel-MAX+1;
254
312
  }
255
313
  function render(){
256
- const h=term.height>0?term.height:24, w=term.width>0?term.width:80;
257
- const inputRow=h-1, MAX=menuMax();
258
- term.saveCursor();
259
- // 底部状态栏(常驻)
314
+ const h=th(), w=tw();
315
+ const inputRow=h-1, MAX=MENU_MAX;
316
+ // 底部状态栏(固定在最后一行;反色灰条铺满整行,按列宽截断防超宽换行)
317
+ const st=truncW(statusText(),w);const stPad=" ".repeat(Math.max(0,w-strW(st)));
260
318
  term.moveTo(1,h);term.eraseLine();
261
- const st=statusText().padEnd(Math.max(1,w-1));
262
- term.inverse(st.slice(0,w));term.styleReset;
263
- // 擦除菜单区
319
+ term.inverse(st+stPad);term.styleReset;
320
+ // 先擦除菜单区(输入行上方 MAX 行),保证每次重绘不残留
264
321
  for(let j=0;j<MAX;j++){term.moveTo(1,inputRow-1-j);term.eraseLine();}
265
- // 悬浮菜单
322
+ // 下拉菜单:输入行上方底部对齐展开,最多 MAX 行,灰色反色条样式(与状态栏一致)
266
323
  if(TUI.items.length){
267
324
  const count=Math.min(TUI.items.length,MAX);
268
325
  for(let j=0;j<count;j++){
269
326
  const idx=TUI.scroll+j;
270
- const row=inputRow-1-j;
271
- term.moveTo(1,row);
272
- const line=" "+TUI.items[idx]+" ";
273
- if(idx===TUI.sel)term.bgBrightBlue.black(line.slice(0,w));
274
- else term.gray(line.slice(0,w));
327
+ term.moveTo(1,inputRow-count+j);
328
+ const mark=(j===0&&TUI.scroll>0)?"⋯":(j===count-1&&TUI.scroll+count<TUI.items.length)?"⋮":" ";
329
+ const text=truncW(" "+mark+" "+TUI.items[idx],w);
330
+ const pad=" ".repeat(Math.max(0,w-strW(text)));
331
+ if(idx===TUI.sel)term.bgBrightBlue.black(text+pad);
332
+ else term.inverse(text+pad);
275
333
  term.styleReset;
276
334
  }
277
335
  }
278
- // 输入行
336
+ // 输入行(固定在倒数第二行,与状态栏互不重叠)
279
337
  term.moveTo(1,inputRow);term.eraseLine();
280
- term.cyan.bold(PROMPT);
281
338
  const before=TUI.input.slice(0,TUI.cursor), after=TUI.input.slice(TUI.cursor);
282
- term(before);term.cyan(after);term.styleReset;
283
- // 光标
284
- term.restoreCursor();
285
- term.moveTo(Math.min(1+PROMPT.length+TUI.cursor,w),inputRow);
339
+ let prompt=PROMPT();let pw=strW(prompt);
340
+ if(pw>w-3){prompt=truncW(prompt,w-3)+"…";pw=strW(prompt);} // 超宽提示符截断,绝不换行顶乱状态栏
341
+ term.cyan.bold(prompt);
342
+ if(pw+strW(before)+strW(after)<=w){
343
+ term(before);term.cyan(after);term.styleReset;
344
+ term.moveTo(Math.min(1+pw+strW(before),w),inputRow);
345
+ }else{
346
+ // 超宽防护:只显示输入末尾,防止换行把状态栏/菜单顶乱
347
+ const keep=Math.max(1,w-pw-2);
348
+ const tail=truncW(before+after,keep);
349
+ term("…"+tail);term.styleReset;
350
+ term.moveTo(Math.min(1+pw+keep+1,w),inputRow);
351
+ }
286
352
  term.hideCursor(false);
287
353
  }
288
354
  function refreshFilter(){
@@ -339,17 +405,21 @@ function tuiInput(){
339
405
  else if(name==="CTRL_C"){term.removeListener("key",onKey);term.grabInput(false);term.hideCursor(true);term("\n");resolve(null);}
340
406
  else if(name==="CTRL_L"){term.clear();term.moveTo(1,1);render();}
341
407
  };
408
+ // 防监听器累积:注册前先移除上一轮的 key 监听,杜绝按键被重复处理
409
+ if(tuiInput._key)term.removeListener("key",tuiInput._key);
342
410
  term.on("key",onKey);
411
+ tuiInput._key=onKey;
343
412
  });
344
413
  }
345
414
  async function runTui(){
346
415
  term.hideCursor();
416
+ term.clear();logo();
347
417
  while(true){
348
418
  const input=await tuiInput();
349
419
  if(input===null)break;
350
420
  const text=input.trim();
351
421
  if(!text)continue;
352
- if(text.startsWith("/"))handleCommand(text);
422
+ if(text.startsWith("/"))await handleCommand(text);
353
423
  else await chat(text);
354
424
  }
355
425
  term.grabInput(false);
@@ -437,8 +507,7 @@ async function selftest(){
437
507
 
438
508
  async function main(){
439
509
  loadConfig();const a=loadAuth();if(!a){console.log(color("未检测到 GLM API Key。",C.red));console.log(`API Key 页面: ${API_KEYS_URL}`);console.log("获得 Key 后运行: glmcode auth set <API_KEY>");process.exit(1);}
440
- apiKey=a.key;await ensureDeps();await loadSkills();logo();console.log(color(`API: ${mask(apiKey)} (${a.source})`,C.green));console.log(color(`模型: ${config.model} 技能: ${skills.size}`,C.gray));console.log("");
441
- runTui();
510
+ apiKey=a.key;await ensureDeps();await loadSkills();runTui();
442
511
  }
443
512
  const argv=process.argv.slice(2);
444
513
  if(argv[0]==="--selftest"||argv[0]==="selftest"){await selftest();}
@@ -447,8 +516,8 @@ if(argv[0]==="--help"||argv[0]==="-h"){console.log(`GLMCode ${VERSION}
447
516
  启动: glmcode
448
517
  TUI: 输入 / 或字母实时弹出悬浮命令菜单,↑↓ 键选择,Tab 补全,Enter 执行
449
518
  底部常驻状态栏:思考开关 / 模型 / 技能数 / 当前目录
450
- 斜杠命令: /help /clear /thinking /model /pwd /cd /ls /cat /run /install /exit
451
- 技能: 将 SKILL.md 目录或 .zip 包放入 skills/ 自动加载。
519
+ 斜杠命令: /help /clear /thinking /model /temp /pwd /cd /ls /cat /find /grep /touch /mkdir /rm /cp /mv /run /install /shell /whoami /date /history /config /compact /save /load /skills /skill /reload /auth /exit
520
+ 技能: 将 SKILL.md 目录或 .zip 包放入 ~/.glmcode/skills/(或包内 skills/)自动加载;/skill new <名字> 一键创建目录。
452
521
  认证: glmcode auth set <API_KEY> | auth status | auth logout | auth login
453
522
  Shell: glmcode shell | shell list`);process.exit(0);}
454
523
  if(argv[0]==="auth"){const s=argv[1]||"status";if(s==="set"){if(!argv[2]){console.error("用法: glmcode auth set <API_KEY>");process.exit(2);}saveJson(AUTH_FILE,{provider:"zhipu",apiKey:argv[2],updatedAt:new Date().toISOString()});console.log(`API Key 已保存到 ${AUTH_FILE}`);}