kodenique-agent 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -14,7 +14,7 @@ kodenique-agent
14
14
  ```
15
15
 
16
16
  Update with `npm install -g kodenique-agent@latest`.
17
- A release archive can also be installed with `npm install -g ./kodenique-agent-0.7.0.tgz`.
17
+ A release archive can also be installed with `npm install -g ./kodenique-agent-0.8.0.tgz`.
18
18
 
19
19
  The wizard asks for their API key (hidden while typing), optional Anthropic workspace ID, model from the live Claude API list, and whether to enable thinking summaries. Use a separate key per teammate; don't include your personal key in the package.
20
20
 
@@ -39,20 +39,33 @@ node /path/to/kodenique-agent/cli.mjs /path/to/project
39
39
  ## What it looks like
40
40
 
41
41
  ```text
42
- kodenique-agent · claude-sonnet-4-6
43
- Project: /your/project
44
-
45
- You › Find and fix the failing test
46
- Working… 2s
47
- Thinking summary ›
48
- [Summary supplied by Claude, when available]
49
- Preparing read_file…
50
- → read_file · package.json
51
- ✓ Tool completed
52
- Claude ›
53
- [Answer streams here as it arrives]
42
+ > Find and fix the failing test
43
+
44
+ ✻ Working… (2s · esc to interrupt)
45
+
46
+ ⏺ Bash(npm test)
47
+ ⎿ Exit code 1
48
+ ✗ parses dates
49
+
50
+ ⏺ Read(src/date.ts)
51
+ ⎿ Read 42 lines
52
+
53
+ ⏺ Write(src/date.ts)
54
+ @@ line 12 @@
55
+ - return d.getDay();
56
+ + return d.getDate();
57
+
58
+ Do you want to make this edit to src/date.ts?
59
+ ❯ 1. Yes
60
+ 2. Yes, and auto-approve edits this session
61
+ 3. No
62
+
63
+ ⏺ Fixed: getDay() returned the weekday, not the day of month.
64
+ ⎿ Done · 9.4s · session tokens 5120 in / 640 out
54
65
  ```
55
66
 
67
+ Keys: **Esc** interrupts the current turn. The partial turn is removed from context and nothing more runs, but a shell command that already started finishes. **Shift+Tab** at the prompt cycles approval modes (ask → auto-edit → auto-all). Replies can use up to 16,000 output tokens (32,000 with thinking).
68
+
56
69
  Thinking is optional. Enable with `/thinking on`, disable with `/thinking off`. It requests adaptive thinking with summarized display; use a compatible model such as Sonnet 4.6. Summaries are exactly what the API returns, not full private reasoning. Claude can skip thinking for simple prompts. Thinking can consume additional tokens, and the app makes no fixed-cost guarantee.
57
70
 
58
71
  ## Chat commands
@@ -69,12 +82,13 @@ Thinking is optional. Enable with `/thinking on`, disable with `/thinking off`.
69
82
  | `/project` | Prompt for a local project path |
70
83
  | `/files` | List the current project directory locally |
71
84
  | `/help` | Show help |
85
+ | `/compact` | Replace the conversation with a model-written summary to free up context |
72
86
  | `/clear` | Reset conversation context |
73
87
  | `/thinking on` or `/thinking off` | Toggle thinking for this session |
74
88
  | `/usage` | Show reported session input/output tokens |
75
89
  | `/exit` or Ctrl+C | Exit |
76
90
 
77
- The agent lists and reads files, proposes full-file edits, and runs commands such as searches and tests. In Ask mode, every write shows a colored change preview and requires `y`; every command shows its exact text and requires `y`. Use `/full` at an approval prompt to see the complete change. Tool activity is visible. It never runs an incomplete streamed tool request.
91
+ The agent lists and reads files, proposes full-file edits, and runs commands such as searches and tests. In Ask mode, every write shows a colored change preview and every command shows its exact text. Pick 1 (yes), 2 (yes, and auto-approve that kind of action for this session) or 3 / Enter (no). Use `/full` at an approval prompt to see the complete change. Tool activity is visible. It never runs an incomplete streamed tool request.
78
92
 
79
93
  ## Boundaries
80
94
 
@@ -82,7 +96,7 @@ Approved shell commands have your full local permissions; they are **not sandbox
82
96
 
83
97
  File tools reject traversal outside the project, symlinks, `.git`, `.ssh`, `.aws`, `.env*`, `node_modules`, `.pem` and `.key` files. This is not a complete secret detector or protection against malicious concurrent filesystem changes. Prompts and tool results are sent directly to Anthropic. File and command output limits are 100 KB; directory listings stop at 500 entries.
84
98
 
85
- Conversations live in memory only. There is no saved history, context compaction, dollar spending cap. `/clear` reduces growing context. The loop stops after 15 model calls per user turn. Shell commands time out after 60 seconds; API calls after 120 seconds. Ctrl+C exits the agent; already started shell descendants may require separate cleanup.
99
+ Conversations live in memory only. There is no saved history and no dollar spending cap. `/compact` summarizes manually; there is no automatic compaction. `/clear` reduces growing context. The loop stops after 15 model calls per user turn. Shell commands time out after 60 seconds; API calls after 120 seconds. Ctrl+C exits the agent; already started shell descendants may require separate cleanup.
86
100
 
87
101
  ## Build a team release
88
102
 
@@ -98,7 +112,7 @@ The package allowlist contains only the CLI, agent, stream parser, config and mo
98
112
 
99
113
  ## Validation and protocol
100
114
 
101
- Twelve tests cover file restrictions and approvals, concurrent edits, real local HTTP request/response and errors, workspace headers, fragmented streamed thinking/text/tool JSON, signature preservation, interrupted streams, private config/environment overrides, paginated model discovery/selection, approval mode enforcement, UI previews, and local project selection/completion. They do not establish live model availability or account billing. No live Claude request was made for this release.
115
+ Fourteen tests cover file restrictions and approvals, concurrent edits, real local HTTP request/response and errors, workspace headers, fragmented streamed thinking/text/tool JSON, signature preservation, interrupted streams, private config/environment overrides, paginated model discovery/selection, approval mode enforcement, UI previews, and local project selection/completion. They do not establish live model availability or account billing. No live Claude request was made for this release.
102
116
 
103
117
  API contract: POST `https://api.anthropic.com/v1/messages`, `x-api-key`, `anthropic-version: 2023-06-01`, optional `anthropic-workspace-id`. Streaming uses SSE. Thinking blocks/signatures are preserved in conversation history, followed by all matching tool results.
104
118
 
package/agent.mjs CHANGED
@@ -59,12 +59,12 @@ export async function executeTool(root, tool, approve) {
59
59
  await fs.writeFile(target,input.content,'utf8');
60
60
  return `Wrote ${input.path}`;
61
61
  }
62
- export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic.com',timeout=120000}, endpoint, body) {
62
+ export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic.com',timeout=120000,signal}, endpoint, body) {
63
63
  const url=new URL(baseUrl);
64
64
  if(url.origin!=='https://api.anthropic.com' && !(['localhost','127.0.0.1','[::1]'].includes(url.hostname) && url.protocol==='http:')) throw Error('Only Anthropic or a local HTTP test server is allowed');
65
65
  const headers={'content-type':'application/json','x-api-key':key,'anthropic-version':'2023-06-01'};
66
66
  if(workspaceId?.trim()) headers['anthropic-workspace-id']=workspaceId.trim();
67
- const response=await fetch(`${baseUrl.replace(/\/$/,'')}${endpoint}`,{method:body?'POST':'GET',headers,...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(timeout),redirect:'error'});
67
+ const response=await fetch(`${baseUrl.replace(/\/$/,'')}${endpoint}`,{method:body?'POST':'GET',headers,...(body?{body:JSON.stringify(body)}:{}),signal:signal?AbortSignal.any([signal,AbortSignal.timeout(timeout)]):AbortSignal.timeout(timeout),redirect:'error'});
68
68
  if(!response.ok) {
69
69
  let detail='';
70
70
  try {
@@ -82,13 +82,14 @@ export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic
82
82
  return response;
83
83
  }
84
84
  export async function requestMessage({model,messages,stream=false,thinking=false,onEvent,...config}) {
85
- const response=await apiRequest(config,'/v1/messages',{model,max_tokens:thinking?8192:4096,system,tools,messages,...(stream?{stream:true}:{}),...(thinking?{thinking:{type:'adaptive',display:'summarized'}}:{})});
85
+ const response=await apiRequest(config,'/v1/messages',{model,max_tokens:thinking?32000:16000,system,tools,messages,...(stream?{stream:true}:{}),...(thinking?{thinking:{type:'adaptive',display:'summarized'}}:{})});
86
86
  const result=stream ? await readMessageStream(response.body,onEvent) : await response.json();
87
87
  if(!Array.isArray(result.content)) throw Error('Invalid API response');
88
88
  return result;
89
89
  }
90
- export async function runTurn({messages,request,execute,output=()=>{},onUsage=()=>{},streamed=false,maxSteps=15}) {
90
+ export async function runTurn({messages,request,execute,output=()=>{},onUsage=()=>{},streamed=false,maxSteps=15,signal}) {
91
91
  for(let step=0;step<maxSteps;step++) {
92
+ signal?.throwIfAborted();
92
93
  const response=await request(messages);
93
94
  onUsage(response.usage || {});
94
95
  // Never execute a possibly incomplete tool request.
@@ -99,6 +100,7 @@ export async function runTurn({messages,request,execute,output=()=>{},onUsage=()
99
100
  if(!calls.length) return;
100
101
  const results=[];
101
102
  for(const call of calls) {
103
+ signal?.throwIfAborted();
102
104
  try { results.push({type:'tool_result',tool_use_id:call.id,content:String(await execute(call))}); }
103
105
  catch(e) {
104
106
  if(e.code==='ANTHROPIC_RATE_LIMIT') throw e;
package/cli.mjs CHANGED
@@ -3,17 +3,19 @@ import { createInterface } from 'node:readline/promises';
3
3
  import { Writable } from 'node:stream';
4
4
  import {commands,completeCommand,resolveProject} from './commands.mjs';
5
5
  import { executeTool, printable, requestMessage, runTurn } from './agent.mjs';
6
- import {createUI,parseArgs,autoApproves,approvalModes,editPreview} from './ui.mjs';
6
+ import {createUI,parseArgs,approvalModes,nextMode,VERSION} from './ui.mjs';
7
+ import {turnHandlers} from './render.mjs';
7
8
  import { pickModel } from './models.mjs';
8
9
  import { configPath, loadConfig, saveConfig, resolveConfig } from './config.mjs';
9
10
 
10
11
  const args=process.argv.slice(2);
11
- const help=`kodenique-agent [project-folder] [--approval ask|auto-edit|auto-all]\nkodenique-agent --auto Auto-approve edits and commands for this session\nkodenique-agent setup Configure your own API key and workspace\nkodenique-agent --version\n\nChat: / /project [folder] /files /approval /status /model /models /help /clear /thinking on|off /usage /exit\nEnvironment overrides: ANTHROPIC_API_KEY, ANTHROPIC_WORKSPACE_ID,\nANTHROPIC_MODEL, ANTHROPIC_THINKING=on|off\nRequires Node.js 22+, macOS or Linux. Ask is the default. Auto-all runs shell commands with your full permissions.`;
12
+ const help=`kodenique-agent [project-folder] [--approval ask|auto-edit|auto-all]\nkodenique-agent --auto Auto-approve edits and commands for this session\nkodenique-agent setup Configure your own API key and workspace\nkodenique-agent --version\n\nChat: / /project [folder] /files /approval /status /model /models /help /compact /clear /thinking on|off /usage /exit\nEnvironment overrides: ANTHROPIC_API_KEY, ANTHROPIC_WORKSPACE_ID,\nANTHROPIC_MODEL, ANTHROPIC_THINKING=on|off\nRequires Node.js 22+, macOS or Linux. Ask is the default. Auto-all runs shell commands with your full permissions.`;
12
13
  if(args.includes('--help')) { console.log(help); process.exit(0); }
13
- if(args.includes('--version')) { console.log('kodenique-agent 0.7.0'); process.exit(0); }
14
+ if(args.includes('--version')) { console.log(`kodenique-agent ${VERSION}`); process.exit(0); }
14
15
  let options;
15
16
  try { options=parseArgs(args); } catch(e) {console.error(e.message);process.exit(1);}
16
- let approval=options.mode;
17
+ const state={approval:options.mode,approving:false};
18
+ let turn=null;
17
19
  if(!process.stdin.isTTY) { console.error('Run in an interactive terminal.'); process.exit(1); }
18
20
  let muted=false;
19
21
  const terminalOutput=new Writable({write(chunk,encoding,done){ if(!muted) process.stdout.write(chunk); done(); }});
@@ -32,7 +34,13 @@ const show=value=>console.log(clean(value));
32
34
  const ui=createUI(clean);
33
35
  const commandMenu=()=>{ui.label('Commands · type a command, Tab completes');for(const [command,description] of commands) show(` ${command.padEnd(16)} ${description}`);};
34
36
  let chatPrompt=false;
35
- process.stdin.on('keypress',(text)=>{
37
+ let frame=null;
38
+ process.stdin.on('keypress',(text,key)=>{
39
+ if(key?.name==='escape' && turn && !state.approving) turn.abort();
40
+ if(chatPrompt && frame && key?.name==='tab' && key.shift) {
41
+ state.approval=nextMode(state.approval);
42
+ process.stdout.write('\x1b7\x1b[1A\r\x1b[2K'+ui.modeLine({...frame,mode:state.approval})+'\x1b8');
43
+ }
36
44
  if(chatPrompt && text==='/' && rl.line==='/') {
37
45
  process.stdout.write('\n'); commandMenu(); rl.prompt(true);
38
46
  }
@@ -54,13 +62,14 @@ try {
54
62
  }
55
63
  if(options.workspace!=='setup') {
56
64
  let root=await resolveProject(options.workspace||process.cwd());
57
- ui.header({model:config.model,root,mode:approval,thinking:config.thinking});
58
- if(approval==='auto-all') ui.note('Auto-all: edits and shell commands run without prompts, with full local permissions.');
65
+ ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});
66
+ if(state.approval==='auto-all') ui.note('Auto-all: edits and shell commands run without prompts, with full local permissions.');
59
67
  let messages=[]; let usage={input:0,output:0};
60
68
  while(!rl.closed) {
61
69
  chatPrompt=true;
62
- ui.inputFrame({model:config.model,root,mode:approval,thinking:config.thinking});
63
- const prompt=await ask(ui.prompt(approval));
70
+ frame={model:config.model,root,thinking:config.thinking};
71
+ ui.inputFrame({...frame,mode:state.approval});
72
+ const prompt=await ask(ui.prompt(state.approval));
64
73
  chatPrompt=false;
65
74
  if(!prompt) continue;
66
75
  if(prompt==='/exit') break;
@@ -71,7 +80,7 @@ try {
71
80
  try {
72
81
  const next=await resolveProject(input,root);
73
82
  if(next!==root) {root=next;messages=[];ui.success('Project opened. Conversation cleared.');}
74
- ui.header({model:config.model,root,mode:approval,thinking:config.thinking});
83
+ ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});
75
84
  } catch(e) {ui.error(`Cannot open project: ${e.message}. Current project unchanged.`);}
76
85
  continue;
77
86
  }
@@ -93,76 +102,47 @@ try {
93
102
  }
94
103
  continue;
95
104
  }
96
- if(prompt==='/status') {ui.header({model:config.model,root,mode:approval,thinking:config.thinking});continue;}
105
+ if(prompt==='/status') {ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});continue;}
97
106
  if(prompt==='/approval' || prompt.startsWith('/approval ')) {
98
- const next=prompt==='/approval'?await ask(`Approval mode: ask / auto-edit / auto-all [${approval}]: `):prompt.slice(10).trim();
107
+ const next=prompt==='/approval'?await ask(`Approval mode: ask / auto-edit / auto-all [${state.approval}]: `):prompt.slice(10).trim();
99
108
  if(!next) continue;
100
109
  if(!approvalModes.includes(next)) {ui.error('Choose ask, auto-edit, or auto-all.');continue;}
101
- approval=next;ui.success(`Approval mode: ${approval} (this session only)`);
102
- if(approval==='auto-all') ui.note('Edits and shell commands run without prompts, with full local permissions.');
110
+ state.approval=next;ui.success(`Approval mode: ${state.approval} (this session only)`);
111
+ if(state.approval==='auto-all') ui.note('Edits and shell commands run without prompts, with full local permissions.');
112
+ continue;
113
+ }
114
+ if(prompt==='/compact') {
115
+ if(!messages.length) {ui.note('Nothing to compact.');continue;}
116
+ ui.startSpinner();
117
+ try {
118
+ const r=await requestMessage({...config,thinking:false,messages:[...messages,{role:'user',content:'Summarize this conversation for continuing the work: goals, decisions, files read or changed, current state, next steps. Reply with the summary only; do not call tools.'}]});
119
+ usage.input+=r.usage?.input_tokens||0;usage.output+=r.usage?.output_tokens||0;
120
+ const summary=r.content.filter(b=>b.type==='text').map(b=>b.text).join('\n').trim();
121
+ if(!summary) throw Error('empty summary');
122
+ messages=[{role:'user',content:'Summary of our earlier conversation:\n'+summary},{role:'assistant',content:'Understood. Continuing from that context.'}];
123
+ ui.stopSpinner();ui.done('Conversation compacted (/clear starts fresh instead)');
124
+ } catch(e) {ui.stopSpinner();ui.error(`Compact failed: ${e.message}. Conversation unchanged.`);}
103
125
  continue;
104
126
  }
105
127
  if(prompt==='/usage') { show(`Session usage: ${usage.input} input / ${usage.output} output tokens (reported by API).`); continue; }
106
128
  if(prompt==='/thinking on' || prompt==='/thinking off') { config.thinking=prompt.endsWith(' on'); show(`Thinking summaries ${config.thinking?'on':'off'} for this session.`); continue; }
107
129
  if(prompt.startsWith('/')) { show('Unknown command. Use /help.'); continue; }
130
+ const before=messages.length;
108
131
  messages.push({role:'user',content:prompt});
109
132
  const start=Date.now();
133
+ turn=new AbortController();
110
134
  try {
111
- await runTurn({messages,streamed:true,output:show,onUsage:u=>{usage.input+=u.input_tokens||0;usage.output+=u.output_tokens||0;},request:async history=>{
112
- let line=false, pending='';
113
- // Hold only a suffix that could be the beginning of the API key.
114
- const flush=(text='',final=false)=>{
115
- pending+=text; pending=pending.split(config.key).join('[REDACTED]');
116
- let hold=0;
117
- if(!final) for(let n=1;n<Math.min(config.key.length,pending.length+1);n++) if(pending.endsWith(config.key.slice(0,n))) hold=n;
118
- process.stdout.write(printable(pending.slice(0,pending.length-hold)));
119
- pending=hold?pending.slice(-hold):'';
120
- };
121
- const finish=()=>{flush('',true); if(line) {console.log();line=false;} };
122
- const began=Date.now();
123
- process.stdout.write('Working…');
124
- let waiting=true;
125
- const stopWait=()=>{if(waiting){process.stdout.write('\r\x1b[2K');waiting=false;}};
126
- const timer=setInterval(()=>{if(waiting) process.stdout.write(`\rWorking… ${Math.floor((Date.now()-began)/1000)}s`);},1000);
127
- try {
128
- return await requestMessage({...config,messages:history,stream:true,onEvent:event=>{
129
- stopWait();
130
- if(event.type==='block_start') {
131
- finish();
132
- const type=event.block.type;
133
- if(type==='thinking') {ui.label('◌ Thinking summary');line=true;}
134
- if(type==='redacted_thinking') show('Thinking… (content not provided by API)');
135
- if(type==='text') {ui.label('◇ Claude');line=true;}
136
- if(type==='tool_use') show(`Preparing ${event.block.name}…`);
137
- }
138
- if(event.type==='text' || event.type==='thinking') flush(event.text);
139
- if(event.type==='block_stop') finish();
140
- }});
141
- } finally {clearInterval(timer);stopWait();finish();}
142
- },execute:async call=>{
143
- ui.label(`› ${call.name}${call.input?.path?' · '+call.input.path:''}`);
144
- try {
145
- const result=await executeTool(root,call,async (description,action)=>{
146
- if(action.tool==='write_file') {ui.note(`Edit · ${action.path}`);ui.diff(editPreview(action.before,action.after));}
147
- else ui.note(`$ ${action.command}`);
148
- if(autoApproves(approval,action.tool)) {ui.note(`Auto-approved · ${approval}`);return true;}
149
- while(true) {
150
- const answer=(await ask('Allow? [y/N] · /full shows complete change: ')).toLowerCase();
151
- if(answer==='/full') {show(description);continue;}
152
- return answer==='y';
153
- }
154
- });
155
- ui.success('✓ Completed'); return result;
156
- } catch(e) {ui.error(`✗ ${e.message}`);throw e;}
157
- }});
158
- ui.note(`Done · ${((Date.now()-start)/1000).toFixed(1)}s · session tokens ${usage.input} in / ${usage.output} out`);
135
+ const {request,execute}=turnHandlers({ui,config,root,ask,show,state,signal:turn.signal});
136
+ await runTurn({messages,streamed:true,signal:turn.signal,output:show,onUsage:u=>{usage.input+=u.input_tokens||0;usage.output+=u.output_tokens||0;},request,execute});
137
+ ui.done(`Done · ${((Date.now()-start)/1000).toFixed(1)}s · session tokens ${usage.input} in / ${usage.output} out`);
159
138
  } catch(e) {
160
- if(e.code==='ANTHROPIC_RATE_LIMIT') {
139
+ if(turn?.signal.aborted) {messages.length=before;ui.interrupted();ui.note(' Conversation rolled back to before this message.');}
140
+ else if(e.code==='ANTHROPIC_RATE_LIMIT') {
161
141
  ui.error('Claude API limit reached. This request stopped; kodenique-agent will not retry or send the failed tool back to Claude.');
162
142
  show(e.message);
163
143
  if(messages.at(-1)?.role==='user') messages.pop();
164
144
  } else show(`Error: ${e.message}\nUse /clear to reset, or try again. For unsupported thinking, use /thinking off.`);
165
- }
145
+ } finally {turn=null;}
166
146
  }
167
147
  }
168
148
  } catch(e) { show(`Error: ${e.message}`); process.exitCode=1; }
package/commands.mjs CHANGED
@@ -11,6 +11,7 @@ export const commands=[
11
11
  ['/thinking off','Disable thinking summaries'],
12
12
  ['/status','Show project, model, and approval mode'],
13
13
  ['/usage','Show session token usage'],
14
+ ['/compact','Summarize the conversation to free up context'],
14
15
  ['/clear','Start a fresh conversation'],
15
16
  ['/help','Show startup options and commands'],
16
17
  ['/exit','Close kodenique-agent'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodenique-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Your team's terminal coding agent, powered directly by the Claude API",
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,6 +20,7 @@
20
20
  "config.mjs",
21
21
  "models.mjs",
22
22
  "ui.mjs",
23
+ "render.mjs",
23
24
  "commands.mjs",
24
25
  "README.md"
25
26
  ],
package/render.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import { executeTool, printable, requestMessage } from './agent.mjs';
2
+ import { autoApproves, editPreview } from './ui.mjs';
3
+
4
+ // Short, Claude Code–style "⎿" summaries of tool results.
5
+ export function summarize(call, result) {
6
+ const text = String(result ?? '');
7
+ const count = s => (s ? s.replace(/\n$/, '').split('\n').length : 0);
8
+ if (call.name === 'read_file') return [`Read ${count(text)} lines`];
9
+ if (call.name === 'list_files') return [`Listed ${count(text)} entries`];
10
+ if (call.name === 'write_file') return [text];
11
+ if (call.name === 'run_command') {
12
+ let r; try { r = JSON.parse(text); } catch { return [text.slice(0, 200)]; }
13
+ const out = [r.stdout, r.stderr].filter(Boolean).join('\n').replace(/\n$/, '');
14
+ const rows = out ? out.split('\n') : [];
15
+ const head = rows.slice(0, 4);
16
+ if (rows.length > 4) head.push(`… +${rows.length - 4} lines`);
17
+ if (r.exitCode) head.unshift(`Exit code ${r.exitCode}`);
18
+ return head.length ? head : ['(no output)'];
19
+ }
20
+ return [text.slice(0, 200)];
21
+ }
22
+
23
+ export function turnHandlers({ ui, config, root, ask, show, state, signal, output = process.stdout }) {
24
+ const request = async history => {
25
+ let open = false, mode = null, pending = '';
26
+ const key = config.key || '\0';
27
+ const style = s => (s && mode === 'thinking' ? ui.paint('2;3', s) : s);
28
+ // Hold only a suffix that could be the beginning of the API key.
29
+ const flush = (text = '', final = false) => {
30
+ pending = (pending + text).split(key).join('[REDACTED]');
31
+ let hold = 0;
32
+ if (!final) for (let n = 1; n < Math.min(key.length, pending.length + 1); n++) if (pending.endsWith(key.slice(0, n))) hold = n;
33
+ output.write(style(printable(pending.slice(0, pending.length - hold))));
34
+ pending = hold ? pending.slice(-hold) : '';
35
+ };
36
+ const finish = () => { flush('', true); if (open) { output.write('\n'); open = false; mode = null; } };
37
+ let spinning = true;
38
+ const stop = () => { if (spinning) { ui.stopSpinner(); spinning = false; } };
39
+ ui.startSpinner();
40
+ try {
41
+ return await requestMessage({ ...config, messages: history, stream: true, signal, onEvent: event => {
42
+ stop();
43
+ if (event.type === 'block_start') {
44
+ finish();
45
+ const type = event.block.type;
46
+ if (type === 'thinking') { output.write('\n'); ui.thinking(); output.write('\n '); open = true; mode = 'thinking'; }
47
+ if (type === 'redacted_thinking') ui.note('✻ Thinking… (redacted by API)');
48
+ if (type === 'text') { output.write('\n'); ui.assistant(); open = true; mode = 'text'; }
49
+ }
50
+ if (event.type === 'text' || event.type === 'thinking') flush(event.text);
51
+ if (event.type === 'block_stop') finish();
52
+ } });
53
+ } finally { stop(); finish(); }
54
+ };
55
+
56
+ const approve = async (description, action) => {
57
+ const write = action.tool === 'write_file';
58
+ if (write) ui.diff(editPreview(action.before, action.after));
59
+ else ui.note(' $ ' + action.command);
60
+ if (autoApproves(state.approval, action.tool)) { ui.done(`Auto-approved (${state.approval})`); return true; }
61
+ ui.approvalMenu({
62
+ question: write ? `Do you want to ${action.before == null ? 'create' : 'make this edit to'} ${action.path}?` : 'Do you want to run this command?',
63
+ optionTwo: write ? 'Yes, and auto-approve edits this session' : "Yes, and don't ask again for commands this session",
64
+ });
65
+ state.approving = true;
66
+ try {
67
+ for (;;) {
68
+ const a = String((await ask(' ❯ ')) ?? '').trim().toLowerCase();
69
+ if (a === '/full') { show(description); continue; }
70
+ if (['1', 'y', 'yes'].includes(a)) return true;
71
+ if (a === '2') {
72
+ state.approval = write && state.approval !== 'auto-all' ? 'auto-edit' : 'auto-all';
73
+ ui.done(`Approval mode: ${state.approval} (this session)`);
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ } finally { state.approving = false; }
79
+ };
80
+
81
+ const execute = async call => {
82
+ output.write('\n');
83
+ ui.tool(call);
84
+ try {
85
+ const result = await executeTool(root, call, approve);
86
+ const [first, ...rest] = summarize(call, result);
87
+ ui.result(first);
88
+ for (const row of rest) ui.note(' ' + row);
89
+ return result;
90
+ } catch (e) { ui.result(e.message, false); throw e; }
91
+ };
92
+
93
+ return { request, execute };
94
+ }
package/ui.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import os from 'node:os';
2
+ export const VERSION='0.8.0';
3
+ export const nextMode=mode=>approvalModes[(approvalModes.indexOf(mode)+1)%approvalModes.length];
1
4
  export const approvalModes=['ask','auto-edit','auto-all'];
2
5
  export function autoApproves(mode,tool) {
3
6
  return mode==='auto-all' && ['write_file','run_command'].includes(tool) || mode==='auto-edit' && tool==='write_file';
@@ -23,33 +26,102 @@ export function editPreview(oldText,newText,maxLines=60) {
23
26
  const lines=[`@@ line ${prefix+1} @@`,...changed];
24
27
  return lines.length>maxLines?lines.slice(0,maxLines).join('\n')+`\n… ${lines.length-maxLines} more lines (/full shows all)`:lines.join('\n');
25
28
  }
29
+ // Friendly, Claude Code–style names for tool calls.
30
+ export function toolTitle(call) {
31
+ const input=call?.input||{};
32
+ const short=s=>{s=String(s??'').replace(/\s+/g,' ');return s.length>70?s.slice(0,69)+'…':s;};
33
+ switch(call?.name) {
34
+ case 'read_file': return `Read(${short(input.path)})`;
35
+ case 'write_file': return `Write(${short(input.path)})`;
36
+ case 'list_files': return `List(${short(input.path||'.')})`;
37
+ case 'run_command': return `Bash(${short(input.command)})`;
38
+ default: return `${call?.name}(${short(input.path||input.command||'')})`;
39
+ }
40
+ }
41
+ const ANSI=/\x1b\[[0-9;]*m/g;
42
+ const visible=s=>s.replace(ANSI,'').length;
43
+ const tildify=p=>{const home=os.homedir();return p===home||p.startsWith(home+'/')?'~'+p.slice(home.length):p;};
44
+ const SPINNER=['·','✢','✳','✶','✻','✽','✻','✶','✳','✢'];
26
45
  export function createUI(clean,output=process.stdout) {
27
46
  const color=output.isTTY && !process.env.NO_COLOR && process.env.TERM!=='dumb';
28
47
  const paint=(code,text)=>color?`\x1b[${code}m${clean(text)}\x1b[0m`:clean(text);
29
48
  const line=text=>output.write(text+'\n');
30
- return {
31
- label:text=>line(paint('1;36',text)),
49
+ const width=()=>Math.max(40,Math.min(100,(output.columns||80)-2));
50
+ const accent='38;5;209';
51
+ const modeText=mode=>mode==='auto-all'?paint('33','⏵⏵ auto-all (edits + commands)'):mode==='auto-edit'?paint('35','⏵⏵ auto-edit (edits)'):paint('2','ask before edits & commands');
52
+ const fit=(text,max)=>text.length>max?'…'+text.slice(text.length-max+1):text;
53
+ const box=(rows,code='2')=>{
54
+ const w=width(),inner=w-4;
55
+ line(paint(code,'╭'+'─'.repeat(w-2)+'╮'));
56
+ for(const row of rows) line(paint(code,'│')+' '+row+' '.repeat(Math.max(0,inner-visible(row)))+' '+paint(code,'│'));
57
+ line(paint(code,'╰'+'─'.repeat(w-2)+'╯'));
58
+ };
59
+ let spin=null;
60
+ const ui={
61
+ label:text=>line(paint('1',text)),
32
62
  note:text=>line(paint('2',text)),
33
63
  success:text=>line(paint('32',text)),
34
64
  error:text=>line(paint('31',text)),
35
- prompt:()=>`\n\x01${paint('1;36','› ')}\x02`,
65
+ prompt:()=>paint('1','> '),
66
+ modeLine:({model,root,mode,thinking})=>' '+modeText(mode)+paint('2',' (shift+tab to cycle) · '+[model,tildify(root),`thinking ${thinking?'on':'off'}`,'/ for commands'].join(' · ')),
36
67
  inputFrame:({model,root,mode,thinking})=>{
37
- const width=Math.max(54,Math.min(100,(output.columns||84)-4));
38
- const bg=text=>color?`\x1b[48;5;236m${text}\x1b[0m`:text;
39
68
  line('');
40
- line(bg(' '.repeat(width)));
41
- line(bg(' '+paint('2','Ask kodenique-agent to do anything').padEnd(width-2)));
42
- line(bg(' '.repeat(width)));
43
- line(paint('2',` ${model} · ${root} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`));
69
+ line(paint('2','─'.repeat(width())));
70
+ line(ui.modeLine({model,root,mode,thinking}));
44
71
  },
45
- status:({model,root,mode,thinking})=>paint('2',`${model} · ${root} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`),
72
+ status:({model,root,mode,thinking})=>paint('2',`${model} · ${tildify(root)} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`),
46
73
  header:({model,root,mode,thinking})=>{
47
- line('');line(paint('1;36',' ◇ KODENIQUE')+paint('2',' / terminal agent · 0.7.0'));
48
- line(paint('2',' ─────────────────────────────────────────────'));
49
- line(' '+paint('1',model));line(' '+paint('2',root));
50
- line(' '+paint(mode==='auto-all'?'33':'36',`approval: ${mode}`)+paint('2',` · thinking: ${thinking?'on':'off'}`));
51
- line('');line(paint('2',' Type / for commands · Tab to complete · /project to open a folder'));
74
+ const inner=width()-4;
75
+ line('');
76
+ box([
77
+ paint(accent,'✻')+' '+paint('1','Welcome to kodenique-agent')+paint('2',' '+VERSION),
78
+ '',
79
+ paint('2',' /help for help · /status for your current setup'),
80
+ '',
81
+ paint('2',' cwd: ')+clean(fit(tildify(root),inner-7)),
82
+ paint('2',' model: ')+clean(fit(model,inner-9)),
83
+ paint('2',' approval: ')+paint(mode==='auto-all'?'33':mode==='auto-edit'?'35':'36',`approval: ${mode}`.slice(10))+paint('2',` · thinking: ${thinking?'on':'off'}`),
84
+ ],accent);
85
+ // Keep plain "approval: <mode>" text discoverable in non-color output.
86
+ if(!color) line(`approval: ${mode}`);
87
+ line('');
88
+ line(paint('2',' Tips for getting started:'));
89
+ line(paint('2',' 1. Ask questions, edit files, or run commands in this project'));
90
+ line(paint('2',' 2. Be specific for the best results'));
91
+ line(paint('2',' 3. Type / for commands, Tab to complete, /project to switch folders'));
92
+ },
93
+ commandMenu:commands=>{
94
+ for(const [command,description] of commands) line(' '+paint('36',command.padEnd(16))+paint('2',description));
95
+ },
96
+ // Assistant / thinking block headers
97
+ assistant:()=>output.write(paint('1','⏺ ')),
98
+ thinking:()=>output.write(paint('2;3','✻ Thinking… ')),
99
+ tool:call=>line(paint('32','⏺ ')+paint('1',clean(toolTitle(call)))),
100
+ result:(text,ok=true)=>line(paint('2',' ⎿ ')+paint(ok?'2':'31',text)),
101
+ done:text=>line(paint('2',' ⎿ '+text)),
102
+ diff:text=>{for(const row of text.split('\n')) line(' '+paint(row.startsWith('+ ')?'32':row.startsWith('- ')?'31':'2',row));},
103
+ approvalMenu:({question,optionTwo})=>{
104
+ line('');
105
+ line(' '+paint('1',question));
106
+ line(' '+paint(accent,'❯ 1. Yes'));
107
+ line(' 2. '+optionTwo);
108
+ line(' 3. No');
109
+ line(paint('2',' 1/2/3 then Enter · /full shows the complete change · Enter = No'));
110
+ },
111
+ startSpinner:()=>{
112
+ ui.stopSpinner();
113
+ const began=Date.now();let i=0;
114
+ const draw=()=>output.write(`\r\x1b[2K${paint(accent,SPINNER[i++%SPINNER.length])} ${paint(accent,'Working…')} ${paint('2',`(${Math.floor((Date.now()-began)/1000)}s · esc to interrupt)`)}`);
115
+ if(!color) {output.write('Working…');spin={timer:null,plain:true};return;}
116
+ draw();spin={timer:setInterval(draw,120)};
117
+ },
118
+ stopSpinner:()=>{
119
+ if(!spin) return;
120
+ if(spin.timer) clearInterval(spin.timer);
121
+ output.write(spin.plain?'\n':'\r\x1b[2K');spin=null;
52
122
  },
53
- diff:text=>{for(const row of text.split('\n')) line(paint(row.startsWith('+ ')?'32':row.startsWith('- ')?'31':'2',row));},
123
+ interrupted:()=>line(paint('2',' ⎿ ')+paint('31','Interrupted by user')),
124
+ paint,
54
125
  };
126
+ return ui;
55
127
  }