glmcode 0.1.15 → 0.1.17

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 +16 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +80 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.17 (2026-08-29)
4
+
5
+ - **根治"任务栏/输入框复制多份"**:引入终端滚动保护区(DECSTBM),对话内容只在顶部滚动区内滚动,输入行/下拉菜单/状态栏全部画在滚动区外 → 任何输出都永远无法把固定 UI 顶进历史产生副本
6
+ - **任务栏永远固定在可视区最底行**:电脑终端(无键盘)恒定在最底行;手机键盘弹出时终端变矮,状态栏自动上浮到键盘正上方(键盘收起后清屏重绘,无残留)
7
+ - 用 xterm.js 无头渲染器三重验证:多轮对话后状态栏唯一且恒定、键盘弹出上浮、键盘收起零残留;35 项 `--selftest` 全过
8
+ - 版本升级至 0.1.17
9
+
10
+ ## 0.1.16 (2026-08-29)
11
+
12
+ - **自适应布局**:监听终端尺寸变化(手机横竖屏切换 / 窗口拉伸),输入态下立即重绘,不再残留错乱;各元素仍按实时列宽/行高计算
13
+ - **启动画面不再浪费空白**:欢迎块(LOGO+提示+启动信息面板)整体**垂直居中**,中间区域填充常用命令、技能目录提示、兼容性说明,屏幕矮时自动紧凑回顶部
14
+ - **Ctrl+C / /exit 退出即"打开新页面"**:真正清屏,清掉本会话所有历史输出,只显示一张**会话总结页**:运行时长、执行过的指令列表、生成的文件列表、Token 消耗
15
+ - **会话统计**:自动记录本次会话运行过哪些指令、通过 write_file 生成了哪些文件、API 累计消耗的 token 数(从流式响应 usage 读取)
16
+ - 35 项 `--selftest` 全过;36x40 高屏 PTY+pyte 仿真验证:居中布局、菜单缩回无残留、退出清屏总结页全部正常
17
+ - 版本升级至 0.1.16
18
+
3
19
  ## 0.1.15 (2026-08-29)
4
20
 
5
21
  - **命令菜单移至输入框下方**:菜单紧贴输入行正下方往下展开,状态栏仍固定在屏幕最底;缩回菜单时彻底擦除,不再残留"被缩回去的一行"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glmcode",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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.15";
10
+ const VERSION="0.1.16";
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();
@@ -31,6 +31,8 @@ const DEFAULT_CONFIG={
31
31
 
32
32
  let config={...DEFAULT_CONFIG};
33
33
  let apiKey=null, messages=[], cwd=process.cwd(), thinkingEnabled=false, skills=new Map();
34
+ /* 会话统计:退出时生成耗费总结(运行了哪些指令/生成了哪些文件/耗了多少token) */
35
+ const SESSION={start:Date.now(),commands:[],files:[],tokens:0,calls:0};
34
36
 
35
37
  function readJson(file,fallback={}){try{return JSON.parse(fs.readFileSync(file,"utf8"));}catch{return fallback;}}
36
38
  function saveJson(file,v){fs.mkdirSync(DATA_DIR,{recursive:true,mode:0o700});fs.writeFileSync(file,JSON.stringify(v,null,2)+"\n",{mode:0o600});try{fs.chmodSync(file,0o600);}catch{}}
@@ -158,7 +160,7 @@ function execTool(name,args){
158
160
  try{const r=spawnSync(file,shArgs,{cwd,encoding:"utf8",timeout:60000});return ((r.stdout||"")+(r.stderr?"\n[stderr]\n"+r.stderr:"")).slice(0,8000)||"(无输出)";}
159
161
  catch(e){return `执行失败: ${e.message}`;}
160
162
  }
161
- if(name==="write_file"){try{const p=path.resolve(cwd,args.path);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,args.content);return `已写入 ${p}`;}catch(e){return `写入失败: ${e.message}`;}}
163
+ if(name==="write_file"){try{const p=path.resolve(cwd,args.path);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,args.content);SESSION.files.push(p);return `已写入 ${p}`;}catch(e){return `写入失败: ${e.message}`;}}
162
164
  if(name==="read_file"){try{return fs.readFileSync(path.resolve(cwd,args.path),"utf8").slice(0,8000);}catch(e){return `读取失败: ${e.message}`;}}
163
165
  if(name==="use_skill"){const s=skills.get(args.skill_name);return s?`技能已加载:${s.name}\n\n${s.content}`:`技能不存在:${args.skill_name}`;}
164
166
  return "未知工具";
@@ -183,7 +185,8 @@ async function streamChat(){
183
185
  line=line.trim();if(!line.startsWith("data:"))continue;
184
186
  const data=line.slice(5).trim();if(data==="[DONE]")continue;
185
187
  try{
186
- const d=JSON.parse(data).choices?.[0]?.delta||{};
188
+ const dj=JSON.parse(data);const d=dj.choices?.[0]?.delta||{};
189
+ if(dj.usage&&dj.usage.total_tokens){SESSION.tokens+=dj.usage.total_tokens;SESSION.calls++;}
187
190
  if(d.reasoning_content&&thinkingEnabled)process.stdout.write(color(d.reasoning_content,C.gray));
188
191
  if(d.content){content+=d.content;process.stdout.write(d.content);}
189
192
  for(const tc of d.tool_calls||[]){
@@ -224,7 +227,7 @@ async function handleCommand(line){
224
227
  const [cmd,...rest]=line.trim().split(/\s+/), arg=rest.join(" ");
225
228
  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;}
226
229
  if(cmd==="/help"){showMenu("/");}
227
- else if(cmd==="/clear"){messages=[];console.log(color("已清空对话",C.green));}
230
+ else if(cmd==="/clear"){messages=[];TUI.input="";TUI.cursor=0;term.clear();term.moveTo(1,1);render();term.moveTo(1,Math.max(1,th()-2));console.log(color("已清空对话",C.green));}
228
231
  else if(cmd==="/thinking"){thinkingEnabled=!thinkingEnabled;config.thinking=thinkingEnabled;saveConfig();console.log(color(`思考模式: ${thinkingEnabled?"开启":"关闭"}`,C.yellow));}
229
232
  else if(cmd==="/model"){if(arg){config.model=arg;saveConfig();console.log(color(`模型已切换: ${arg}`,C.green));}else console.log(`当前模型: ${config.model}`);}
230
233
  else if(cmd==="/pwd")console.log(cwd);
@@ -279,12 +282,12 @@ async function handleCommand(line){
279
282
  else if(cmd==="/auth"){const a=loadAuth();console.log(a?`API Key: ${mask(a.key)}\n来源: ${a.source}`:"未检测到 API Key");}
280
283
  else if(cmd==="/run"){if(!arg)console.log("用法: /run <命令>");else console.log(execTool("run_shell",{command:arg}));}
281
284
  else if(cmd==="/install"){installProgram(arg)}
282
- else if(cmd==="/exit"){term.grabInput(false);term("\n");console.log(color("再见",C.cyan));process.exit(0);}
285
+ else if(cmd==="/exit"){showSummaryAndExit();process.exit(0);}
283
286
  else console.log(color(`未知命令: ${cmd}(输入 / 查看全部)`,C.red));
284
287
  }
285
288
 
286
289
  function logo(){
287
- const w=tw();
290
+ const w=tw(),h=th();
288
291
  const art=[
289
292
  " ██████╗ ██╗ ███╗ ███╗",
290
293
  "██╔════╝ ██║ ████╗ ████║",
@@ -293,14 +296,28 @@ function logo(){
293
296
  "╚██████╔╝███████╗██║ ╚═╝ ██║",
294
297
  " ╚═════╝ ╚══════╝╚═╝ ╚═╝"
295
298
  ];
299
+ // 启动信息面板:填充中间空白区域,矮屏自动紧凑
300
+ const tip=truncW(" Coding Agent · v"+VERSION+" · 输入 / 弹菜单 · ↑↓ 选择 · 回车执行 · Ctrl+C 退出看总结",w);
301
+ const cmdKeys=[["/help","全部命令"],["/clear","清空对话"],["/skills","技能列表与目录"],["/skill","管理技能(可新建)"],["/model","切换模型"],["/exit","退出"]];
302
+ const panel=[""];
303
+ for(const [k,d] of cmdKeys)panel.push(" "+color(k,C.cyanB)+" "+color(truncW(d,Math.max(4,w-6)),C.gray));
304
+ panel.push("");
305
+ panel.push(truncW(" · 技能自动加载: ~/.glmcode/skills/ 或包内 skills/ · /skill new <名字> 新建",w));
306
+ panel.push(truncW(" · 全平台兼容 Linux/Android(termux)/Windows/macOS · 底部状态栏常驻",w));
307
+ const blockH=art.length+1+panel.length;
308
+ // 欢迎块整体垂直居中(底部恒留2行给输入行+状态栏);过矮/超屏则顶部紧凑
309
+ let start=Math.max(1,Math.floor((h-blockH-2)/2));
310
+ if(start+blockH>h-2)start=1;
311
+ term.moveTo(1,start);
296
312
  for(const a of art)console.log(color(truncW(a,w),C.cyanB));
297
- console.log(color(truncW(` Coding Agent · v${VERSION} · 输入 / 弹菜单 · ↑↓ 选择 · 回车执行`,w),C.gray));
298
- console.log("");
313
+ console.log(color(tip,C.gray));
314
+ for(const p of panel)console.log(truncW(p,w));
299
315
  }
300
316
 
301
317
  /* =============== 真 TUI:悬浮菜单 + 上下键 + 实时过滤 + 底部状态栏 =============== */
302
318
  const PROMPT=()=>`${shortDir()} > `;
303
319
  const TUI={input:"",cursor:0,items:[],sel:0,scroll:0,history:[],histIdx:-1};
320
+ let tuiActive=false; // 是否正处于输入等待态(resize 时仅此时才安全重绘)
304
321
  function tw(){return (term.width>0&&isFinite(term.width))?term.width:40;}
305
322
  function th(){return (term.height>0&&isFinite(term.height))?term.height:24;}
306
323
  const MENU_MAX=4;
@@ -343,12 +360,16 @@ function render(){
343
360
  const count=TUI.items.length?Math.min(TUI.items.length,MAX):0;
344
361
  // 输入行:菜单展开时上移,把下方空间让给菜单(菜单紧贴输入框正下方);缩回时回到 h-1
345
362
  const inputRow=h-1-count;
346
- // 底部状态栏(固定在最后一行;反色灰条铺满整行,按列宽截断防超宽换行)
363
+ // ★ 滚动保护区:对话内容区 = 1..inputRow-1。所有对话/命令输出只在此区域内滚动,
364
+ // 输入行、下拉菜单、状态栏全部画在滚动区之外 → 输出永远无法把固定UI顶进历史/复制出多份
365
+ term.scrollingRegion(1, Math.max(2, inputRow-1));
366
+ // 底部状态栏(固定在"可视区域"最后一行而非终端无限底;手机键盘弹出时 term.height 变小,
367
+ // 触发 resize 重绘后整条状态栏上浮到键盘正上方)
347
368
  const st=truncW(statusText(),w);const stPad=" ".repeat(Math.max(0,w-strW(st)));
348
369
  term.moveTo(1,h);term.eraseLine();
349
370
  term.inverse(st+stPad);term.styleReset;
350
- // 先彻底擦除底部区域(状态栏上方 MAX+1 行),保证每次重绘不残留任何旧菜单行
351
- for(let j=0;j<=MAX;j++){term.moveTo(1,h-1-j);term.eraseLine();}
371
+ // 只擦输入行+菜单区(inputRow..h-1),绝不触碰内容区,避免误删对话末尾
372
+ for(let j=h-1;j>=inputRow;j--){term.moveTo(1,j);term.eraseLine();}
352
373
  // 下拉菜单:输入行正下方从上到下展开,最多 MAX 行,灰色反色条(与状态栏一致)
353
374
  if(count){
354
375
  for(let j=0;j<count;j++){
@@ -388,6 +409,7 @@ function refreshFilter(){
388
409
  function tuiInput(){
389
410
  return new Promise(resolve=>{
390
411
  TUI.input="";TUI.cursor=0;TUI.items=[];TUI.sel=0;TUI.scroll=0;TUI.histIdx=-1;
412
+ tuiActive=true;
391
413
  term.grabInput(true);
392
414
  render();
393
415
  const onKey=(name,matches,data)=>{
@@ -420,8 +442,11 @@ function tuiInput(){
420
442
  term.removeListener("key",onKey);
421
443
  term.grabInput(false);
422
444
  term.hideCursor();
423
- term("\n");
424
- if(v.trim())TUI.history.push(v.trim());
445
+ tuiActive=false;
446
+ // 收起菜单并恢复全宽滚动区,光标移回滚动区底行再换行:后续输出只在对话区滚动
447
+ TUI.items=[];render();
448
+ term.moveTo(1,Math.max(1,th()-2));term("\n");
449
+ if(v.trim()){TUI.history.push(v.trim());SESSION.commands.push(v.trim());}
425
450
  resolve(v);
426
451
  }
427
452
  else if(name==="BACKSPACE"){
@@ -436,7 +461,7 @@ function tuiInput(){
436
461
  else if(name==="RIGHT"){if(TUI.cursor<TUI.input.length){TUI.cursor++;}render();}
437
462
  else if(name==="HOME"){TUI.cursor=0;render();}
438
463
  else if(name==="END"){TUI.cursor=TUI.input.length;render();}
439
- else if(name==="CTRL_C"){term.removeListener("key",onKey);term.grabInput(false);term.hideCursor(true);term("\n");resolve(null);}
464
+ else if(name==="CTRL_C"){term.removeListener("key",onKey);term.grabInput(false);term.hideCursor(true);tuiActive=false;term.moveTo(1,Math.max(1,th()-2));term("\n");resolve(null);}
440
465
  else if(name==="CTRL_L"){term.clear();term.moveTo(1,1);render();}
441
466
  };
442
467
  // 防监听器累积:注册前先移除上一轮的 key 监听,杜绝按键被重复处理
@@ -445,9 +470,48 @@ function tuiInput(){
445
470
  tuiInput._key=onKey;
446
471
  });
447
472
  }
473
+ function showSummaryAndExit(){
474
+ term.grabInput(false);term.hideCursor(true);
475
+ // 真正清屏:像打开新页面一样,清掉本会话所有输出
476
+ term.clear();term.moveTo(1,1);
477
+ const dur=Math.max(0,Math.round((Date.now()-SESSION.start)/1000));
478
+ const mm=String(Math.floor(dur/60)).padStart(2,"0"),ss=String(dur%60).padStart(2,"0");
479
+ const bar="=".repeat(Math.max(8,Math.min(tw()-2,42)));
480
+ console.log(color(bar,C.cyan));
481
+ console.log(color(" GLMCode 会话总结",C.cyanB));
482
+ console.log(color(bar,C.cyan));
483
+ console.log("");
484
+ console.log(` 时长: ${mm}:${ss} 模型: ${config.model}`);
485
+ console.log(` 执行指令: ${SESSION.commands.length} 条`);
486
+ if(SESSION.commands.length){
487
+ const shown=SESSION.commands.slice(-10);
488
+ for(const c of shown)console.log(color(" · "+truncW(c,Math.max(8,tw()-8)),C.gray));
489
+ if(SESSION.commands.length>shown.length)console.log(color(` … 还有 ${SESSION.commands.length-shown.length} 条`,C.gray));
490
+ }else console.log(color(" (无)",C.gray));
491
+ console.log(` 生成文件: ${SESSION.files.length} 个`);
492
+ if(SESSION.files.length){for(const f of SESSION.files.slice(-8))console.log(color(" · "+truncW(f,Math.max(8,tw()-8)),C.gray));}
493
+ else console.log(color(" (无)",C.gray));
494
+ console.log(` Token 消耗: ${SESSION.tokens}`);
495
+ console.log("");
496
+ console.log(color(" ✔ 已清屏,像打开新页面一样干净",C.green));
497
+ }
448
498
  async function runTui(){
449
499
  term.hideCursor();
450
500
  term.clear();logo();
501
+ // 启动即建立滚动保护区+底部固定UI(输入行/状态栏),后续任何输出都不会顶走它们
502
+ render();
503
+ // 自适应:终端尺寸变化(手机横竖屏/键盘弹出/窗口拉伸)时立即重绘。
504
+ // 键盘弹出 → term.height 变小 → 状态栏/输入行自动上浮到键盘正上方
505
+ let lastH=th();
506
+ const onResize=()=>{
507
+ if(!tuiActive)return;
508
+ const h=th();
509
+ // 高度增大(键盘收起/横屏):先清屏再重绘,抹掉放大后露出的旧输入行/状态栏残留
510
+ if(h>lastH){term.clear();term.moveTo(1,1);}
511
+ render();
512
+ lastH=h;
513
+ };
514
+ term.on("resize",onResize);
451
515
  while(true){
452
516
  const input=await tuiInput();
453
517
  if(input===null)break;
@@ -456,8 +520,9 @@ async function runTui(){
456
520
  if(text.startsWith("/"))await handleCommand(text);
457
521
  else await chat(text);
458
522
  }
523
+ term.removeListener("resize",onResize);
459
524
  term.grabInput(false);
460
- term("\n"+color("再见",C.cyan)+"\n");
525
+ showSummaryAndExit();
461
526
  process.exit(0);
462
527
  }
463
528