kodenique-agent 0.8.0 → 0.8.1

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.8.0.tgz`.
17
+ A release archive can also be installed with `npm install -g ./kodenique-agent-0.8.1.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
 
@@ -59,11 +59,15 @@ node /path/to/kodenique-agent/cli.mjs /path/to/project
59
59
  ❯ 1. Yes
60
60
  2. Yes, and auto-approve edits this session
61
61
  3. No
62
+ 4. Show full change
63
+ ↑/↓ to select · Enter to confirm · Esc = No
62
64
 
63
65
  ⏺ Fixed: getDay() returned the weekday, not the day of month.
64
66
  ⎿ Done · 9.4s · session tokens 5120 in / 640 out
65
67
  ```
66
68
 
69
+ Menus never need typing: approvals, `/model`, `/approval` and `/thinking` open a list. Move with **↑/↓** (or j/k), confirm with **Enter**, press a number to pick directly, and **Esc** cancels (for approvals, Esc means No). Typing `/` shows matching commands under the prompt; use ↑/↓ to highlight one, **Tab** to fill it in, **Enter** to run it.
70
+
67
71
  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
72
 
69
73
  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.
@@ -84,7 +88,7 @@ Thinking is optional. Enable with `/thinking on`, disable with `/thinking off`.
84
88
  | `/help` | Show help |
85
89
  | `/compact` | Replace the conversation with a model-written summary to free up context |
86
90
  | `/clear` | Reset conversation context |
87
- | `/thinking on` or `/thinking off` | Toggle thinking for this session |
91
+ | `/thinking` (or `/thinking on`/`off`) | Choose thinking on or off for this session |
88
92
  | `/usage` | Show reported session input/output tokens |
89
93
  | `/exit` or Ctrl+C | Exit |
90
94
 
@@ -112,7 +116,7 @@ The package allowlist contains only the CLI, agent, stream parser, config and mo
112
116
 
113
117
  ## Validation and protocol
114
118
 
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.
119
+ Sixteen 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.
116
120
 
117
121
  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.
118
122
 
package/cli.mjs CHANGED
@@ -5,11 +5,13 @@ import {commands,completeCommand,resolveProject} from './commands.mjs';
5
5
  import { executeTool, printable, requestMessage, runTurn } from './agent.mjs';
6
6
  import {createUI,parseArgs,approvalModes,nextMode,VERSION} from './ui.mjs';
7
7
  import {turnHandlers} from './render.mjs';
8
+ import {createInputFrame} from './input.mjs';
9
+ import {createKeys,select} from './select.mjs';
8
10
  import { pickModel } from './models.mjs';
9
11
  import { configPath, loadConfig, saveConfig, resolveConfig } from './config.mjs';
10
12
 
11
13
  const args=process.argv.slice(2);
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.`;
14
+ 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.`;
13
15
  if(args.includes('--help')) { console.log(help); process.exit(0); }
14
16
  if(args.includes('--version')) { console.log(`kodenique-agent ${VERSION}`); process.exit(0); }
15
17
  let options;
@@ -17,11 +19,12 @@ try { options=parseArgs(args); } catch(e) {console.error(e.message);process.exit
17
19
  const state={approval:options.mode,approving:false};
18
20
  let turn=null;
19
21
  if(!process.stdin.isTTY) { console.error('Run in an interactive terminal.'); process.exit(1); }
20
- let muted=false;
21
- const terminalOutput=new Writable({write(chunk,encoding,done){ if(!muted) process.stdout.write(chunk); done(); }});
22
+ let muted=false,input=null;
23
+ const terminalOutput=new Writable({write(chunk,encoding,done){ if(!muted) {process.stdout.write(chunk);input?.schedule();} done(); }});
22
24
  terminalOutput.isTTY=true;
23
- const rl=createInterface({input:process.stdin,output:terminalOutput,terminal:true,completer:completeCommand});
24
- rl.on('SIGINT',()=>{ muted=false; console.log('\nExited.'); rl.close(); process.exit(130); });
25
+ const keys=createKeys();
26
+ const rl=createInterface({input:keys.input,output:terminalOutput,terminal:true,completer:completeCommand});
27
+ rl.on('SIGINT',()=>{ muted=false; process.stdout.write('\r\n\x1b[0J'); console.log('Exited.'); rl.close(); process.exit(130); });
25
28
  const ask=async(prompt,secret=false)=>{
26
29
  if(!secret) return (await rl.question(prompt)).trim();
27
30
  process.stdout.write(prompt); muted=true;
@@ -32,18 +35,13 @@ let config;
32
35
  const clean=value=>printable(config?.key ? String(value).split(config.key).join('[REDACTED]') : value);
33
36
  const show=value=>console.log(clean(value));
34
37
  const ui=createUI(clean);
38
+ const choose=opts=>select({keys,ui,...opts});
39
+ input=createInputFrame({rl,ui,keys,onShiftTab:()=>{state.approval=nextMode(state.approval);},getStatus:()=>ui.modeLine({...frame,mode:state.approval})});
35
40
  const commandMenu=()=>{ui.label('Commands · type a command, Tab completes');for(const [command,description] of commands) show(` ${command.padEnd(16)} ${description}`);};
36
41
  let chatPrompt=false;
37
- let frame=null;
42
+ let frame={model:'',root:'',thinking:false};
38
43
  process.stdin.on('keypress',(text,key)=>{
39
44
  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
- }
44
- if(chatPrompt && text==='/' && rl.line==='/') {
45
- process.stdout.write('\n'); commandMenu(); rl.prompt(true);
46
- }
47
45
  });
48
46
  try {
49
47
  const saved=await loadConfig(); config=resolveConfig(saved);
@@ -53,7 +51,7 @@ try {
53
51
  if(!key) throw Error('API key required.');
54
52
  const workspaceId=await ask(`Anthropic workspace ID [${config.workspaceId||'optional for scoped keys'}] (- clears): `);
55
53
  const selectedWorkspace=workspaceId==='-'?'':workspaceId||config.workspaceId||'';
56
- const model=await pickModel({...config,key,workspaceId:selectedWorkspace},{ask,show});
54
+ const model=await pickModel({...config,key,workspaceId:selectedWorkspace},{ask,show,choose});
57
55
  const thinking=await ask(`Enable Claude thinking summaries? [${config.thinking?'Y/n':'y/N'}]: `);
58
56
  const next={key,workspaceId:selectedWorkspace,model:model||config.model,thinking:thinking?thinking.toLowerCase()==='y':config.thinking};
59
57
  await saveConfig(next); config=resolveConfig(next);
@@ -68,8 +66,9 @@ try {
68
66
  while(!rl.closed) {
69
67
  chatPrompt=true;
70
68
  frame={model:config.model,root,thinking:config.thinking};
71
- ui.inputFrame({...frame,mode:state.approval});
69
+ input.open();
72
70
  const prompt=await ask(ui.prompt(state.approval));
71
+ input.close(prompt);
73
72
  chatPrompt=false;
74
73
  if(!prompt) continue;
75
74
  if(prompt==='/exit') break;
@@ -92,7 +91,7 @@ try {
92
91
  if(prompt==='/help') { show(help); continue; }
93
92
  if(prompt==='/clear') { messages=[]; show('Conversation cleared.'); continue; }
94
93
  if(prompt==='/model' || prompt==='/models') {
95
- const model=await pickModel(config,{ask,show});
94
+ const model=await pickModel(config,{ask,show,choose});
96
95
  if(model!==config.model) {
97
96
  try { await saveConfig({...await loadConfig(),model}); }
98
97
  catch(e) { show(`Could not save model: ${e.message}. Current model unchanged.`);continue; }
@@ -104,7 +103,7 @@ try {
104
103
  }
105
104
  if(prompt==='/status') {ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});continue;}
106
105
  if(prompt==='/approval' || prompt.startsWith('/approval ')) {
107
- const next=prompt==='/approval'?await ask(`Approval mode: ask / auto-edit / auto-all [${state.approval}]: `):prompt.slice(10).trim();
106
+ const next=prompt==='/approval'?approvalModes[await choose({title:'Approval mode',initial:approvalModes.indexOf(state.approval),options:[{label:'ask',hint:'ask before edits & commands'},{label:'auto-edit',hint:'auto-approve file edits, ask for commands'},{label:'auto-all',hint:'auto-approve edits and shell commands'}]})]:prompt.slice(10).trim();
108
107
  if(!next) continue;
109
108
  if(!approvalModes.includes(next)) {ui.error('Choose ask, auto-edit, or auto-all.');continue;}
110
109
  state.approval=next;ui.success(`Approval mode: ${state.approval} (this session only)`);
@@ -125,6 +124,7 @@ try {
125
124
  continue;
126
125
  }
127
126
  if(prompt==='/usage') { show(`Session usage: ${usage.input} input / ${usage.output} output tokens (reported by API).`); continue; }
127
+ if(prompt==='/thinking') {const i=await choose({title:'Thinking summaries',initial:config.thinking?0:1,options:[{label:'On',hint:'show thinking summaries (uses more tokens)'},{label:'Off'}]});if(i>=0){config.thinking=i===0;ui.done(`Thinking summaries ${config.thinking?'on':'off'} for this session`);}continue;}
128
128
  if(prompt==='/thinking on' || prompt==='/thinking off') { config.thinking=prompt.endsWith(' on'); show(`Thinking summaries ${config.thinking?'on':'off'} for this session.`); continue; }
129
129
  if(prompt.startsWith('/')) { show('Unknown command. Use /help.'); continue; }
130
130
  const before=messages.length;
@@ -132,7 +132,7 @@ try {
132
132
  const start=Date.now();
133
133
  turn=new AbortController();
134
134
  try {
135
- const {request,execute}=turnHandlers({ui,config,root,ask,show,state,signal:turn.signal});
135
+ const {request,execute}=turnHandlers({ui,config,root,choose,show,state,signal:turn.signal});
136
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
137
  ui.done(`Done · ${((Date.now()-start)/1000).toFixed(1)}s · session tokens ${usage.input} in / ${usage.output} out`);
138
138
  } catch(e) {
package/commands.mjs CHANGED
@@ -7,8 +7,7 @@ export const commands=[
7
7
  ['/files','List files in the current project'],
8
8
  ['/model','Fetch and choose a Claude model'],
9
9
  ['/approval','Choose ask, auto-edit, or auto-all'],
10
- ['/thinking on','Enable thinking summaries'],
11
- ['/thinking off','Disable thinking summaries'],
10
+ ['/thinking','Turn thinking summaries on or off'],
12
11
  ['/status','Show project, model, and approval mode'],
13
12
  ['/usage','Show session token usage'],
14
13
  ['/compact','Summarize the conversation to free up context'],
package/input.mjs ADDED
@@ -0,0 +1,93 @@
1
+ import { commands } from './commands.mjs';
2
+
3
+ const ANSI = /\x1b\[[0-9;]*m/g;
4
+ const plain = s => String(s).replace(ANSI, '');
5
+
6
+ // Commands matching what's typed so far ("/co" → /compact).
7
+ export function commandMatches(line) {
8
+ if (!line.startsWith('/') || line.includes(' ')) return [];
9
+ return commands.filter(([c]) => c.startsWith(line));
10
+ }
11
+
12
+ // Claude Code–style input: a rule above and below the prompt, with the mode
13
+ // line (or selectable /command suggestions) underneath. Readline clears
14
+ // everything below the cursor on refresh, so the footer is redrawn after writes.
15
+ export function createInputFrame({ rl, ui, getStatus, keys, onShiftTab, output = process.stdout }) {
16
+ let active = false, pending = false, selected = 0, lastLine = '', unhook = null;
17
+ const cols = () => Math.max(20, output.columns || 80);
18
+ const rule = () => ui.paint('2', '─'.repeat(cols() - 1));
19
+ const fit = row => (plain(row).length < cols() ? row : ui.paint('2', plain(row).slice(0, cols() - 2) + '…'));
20
+ const rowsOf = text => {
21
+ try { return rl._getDisplayPos(text).rows; } catch { return Math.floor(plain(text).length / cols()); }
22
+ };
23
+ const matches = () => {
24
+ const line = rl.line || '';
25
+ if (line !== lastLine) { lastLine = line; selected = 0; }
26
+ const found = commandMatches(line).slice(0, 8);
27
+ if (selected >= found.length) selected = 0;
28
+ return found;
29
+ };
30
+
31
+ const footer = () => {
32
+ const line = rl.line || '';
33
+ const found = matches();
34
+ const rows = found.length
35
+ ? found.map(([c, d], i) => (i === selected ? ui.paint('38;5;209', '❯ ' + c.padEnd(16)) : ' ' + ui.paint('36', c.padEnd(16))) + ui.paint('2', d))
36
+ : line.startsWith('/') && !line.includes(' ') ? [ui.paint('2', ' No matching commands')] : [getStatus()];
37
+ if (found.length) rows.push(ui.paint('2', ' ↑/↓ to select · Tab to fill in · Enter to run'));
38
+ return [rule(), ...rows].map(fit);
39
+ };
40
+
41
+ const draw = () => {
42
+ pending = false;
43
+ if (!active) return;
44
+ const { rows, cols: col } = rl.getCursorPos();
45
+ const end = Math.max(rows, rowsOf(rl.getPrompt() + (rl.line || '')));
46
+ const f = footer();
47
+ output.write(
48
+ (end > rows ? `\x1b[${end - rows}B` : '') + '\r\n' +
49
+ f.map(r => '\x1b[2K' + r).join('\r\n') + '\x1b[0J' +
50
+ `\x1b[${f.length + end - rows}A\x1b[${col + 1}G`,
51
+ );
52
+ };
53
+
54
+ // Replace the typed line as if the user had typed it.
55
+ const setLine = text => {
56
+ rl.write(null, { ctrl: true, name: 'e' });
57
+ rl.write(null, { ctrl: true, name: 'u' });
58
+ rl.write(text);
59
+ lastLine = rl.line || ''; selected = 0;
60
+ };
61
+
62
+ const hook = (s, key = {}) => {
63
+ if (key.name === 'tab' && key.shift) { onShiftTab?.(); frame.schedule(); return true; }
64
+ const found = matches();
65
+ if (!found.length) return false;
66
+ if (key.name === 'up') { selected = (selected - 1 + found.length) % found.length; frame.schedule(); return true; }
67
+ if (key.name === 'down') { selected = (selected + 1) % found.length; frame.schedule(); return true; }
68
+ if (key.name === 'tab') { setLine(found[selected][0]); return true; }
69
+ if (key.name === 'return' || key.name === 'enter') {
70
+ if (!commands.some(([c]) => c === (rl.line || '').trim())) setLine(found[selected][0]);
71
+ return false; // let readline submit the line
72
+ }
73
+ return false;
74
+ };
75
+
76
+ const frame = {
77
+ schedule() { if (active && !pending) { pending = true; setImmediate(draw); } },
78
+ open() {
79
+ output.write('\n' + rule() + '\n');
80
+ active = true; selected = 0; lastLine = '';
81
+ unhook = keys?.push(hook) ?? null;
82
+ },
83
+ // After Enter: remove the frame and echo the message like Claude Code.
84
+ close(answer) {
85
+ active = false; unhook?.(); unhook = null;
86
+ const up = rowsOf(rl.getPrompt() + answer) + 2;
87
+ output.write(`\x1b[${up}A\r\x1b[0J`);
88
+ if (answer) output.write(ui.paint('2', '> ') + plain(ui.paint('0', answer)) + '\n');
89
+ },
90
+ };
91
+ output.on?.('resize', () => frame.schedule());
92
+ return frame;
93
+ }
package/models.mjs CHANGED
@@ -20,12 +20,17 @@ export async function listModels(config) {
20
20
  throw Error('Model list exceeded pagination limit');
21
21
  }
22
22
 
23
- export async function pickModel(config,{ask,show}) {
23
+ export async function pickModel(config,{ask,show,choose}) {
24
24
  show('Fetching models from Claude…');
25
25
  let models;
26
26
  try { models=await listModels(config); }
27
27
  catch(e) { show(`Could not fetch models: ${e.message}`); return config.model; }
28
28
  if(!models.length) { show('No available models returned. Keeping current model.');return config.model; }
29
+ if(choose) {
30
+ const current=Math.max(0,models.findIndex(m=>m.id===config.model));
31
+ const i=await choose({title:'Select a model',initial:current,options:models.map(m=>({label:m.display_name||m.id,hint:m.id+(m.id===config.model?' · current':'')+(m.lifecycle==='deprecated'?' · deprecated':'')}))});
32
+ return i<0?config.model:models[i].id;
33
+ }
29
34
  models.forEach((m,i)=>show(`${i+1}. ${m.display_name||m.id} · ${m.id}${m.id===config.model?' [current]':''}${m.lifecycle==='deprecated'?' [deprecated]':''}`));
30
35
  while(true) {
31
36
  const choice=await ask(`Choose number or model ID (Enter keeps ${config.model}): `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodenique-agent",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Your team's terminal coding agent, powered directly by the Claude API",
5
5
  "type": "module",
6
6
  "engines": {
@@ -21,6 +21,8 @@
21
21
  "models.mjs",
22
22
  "ui.mjs",
23
23
  "render.mjs",
24
+ "input.mjs",
25
+ "select.mjs",
24
26
  "commands.mjs",
25
27
  "README.md"
26
28
  ],
package/render.mjs CHANGED
@@ -20,7 +20,7 @@ export function summarize(call, result) {
20
20
  return [text.slice(0, 200)];
21
21
  }
22
22
 
23
- export function turnHandlers({ ui, config, root, ask, show, state, signal, output = process.stdout }) {
23
+ export function turnHandlers({ ui, config, root, choose, show, state, signal, output = process.stdout }) {
24
24
  const request = async history => {
25
25
  let open = false, mode = null, pending = '';
26
26
  const key = config.key || '\0';
@@ -58,22 +58,21 @@ export function turnHandlers({ ui, config, root, ask, show, state, signal, outpu
58
58
  if (write) ui.diff(editPreview(action.before, action.after));
59
59
  else ui.note(' $ ' + action.command);
60
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
- });
61
+ const question = write ? `Do you want to ${action.before == null ? 'create' : 'make this edit to'} ${action.path}?` : 'Do you want to run this command?';
62
+ const options = ['Yes', write ? 'Yes, and auto-approve edits this session' : "Yes, and don't ask again for commands this session", 'No', { label: 'Show full change', hint: write ? 'complete before/after' : 'complete command' }];
65
63
  state.approving = true;
66
64
  try {
67
65
  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') {
66
+ output.write('\n');
67
+ const i = await choose({ title: question, options, footer: '↑/↓ to select · Enter to confirm · Esc = No' });
68
+ if (i === 3) { show(description); continue; }
69
+ if (i === 0) { ui.done('Approved'); return true; }
70
+ if (i === 1) {
72
71
  state.approval = write && state.approval !== 'auto-all' ? 'auto-edit' : 'auto-all';
73
- ui.done(`Approval mode: ${state.approval} (this session)`);
72
+ ui.done(`Approved · approval mode: ${state.approval} (this session)`);
74
73
  return true;
75
74
  }
76
- return false;
75
+ ui.done('Denied'); return false;
77
76
  }
78
77
  } finally { state.approving = false; }
79
78
  };
package/select.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+
4
+ // Readline reads keys from this proxy, so menus can claim keys first.
5
+ // Ctrl+C always reaches readline so exiting keeps working.
6
+ export function createKeys(stdin = process.stdin) {
7
+ emitKeypressEvents(stdin);
8
+ const input = new EventEmitter();
9
+ input.isTTY = true;
10
+ input.setRawMode = mode => { stdin.setRawMode?.(mode); return input; };
11
+ input.resume = () => { stdin.resume(); return input; };
12
+ input.pause = () => { stdin.pause(); return input; };
13
+ const hooks = [];
14
+ const dispatch = (s, key) => {
15
+ if (!(key?.ctrl && key.name === 'c')) for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i](s, key)) return;
16
+ input.emit('keypress', s, key);
17
+ };
18
+ stdin.on('keypress', dispatch);
19
+ return {
20
+ input,
21
+ dispatch,
22
+ // A hook returns true to swallow the key. Returns a remover.
23
+ push(hook) { hooks.push(hook); return () => { const i = hooks.lastIndexOf(hook); if (i >= 0) hooks.splice(i, 1); }; },
24
+ };
25
+ }
26
+
27
+ // Arrow-key menu. Resolves to the chosen index, or -1 on Esc.
28
+ // options: strings or {label, hint}.
29
+ export function select({ keys, ui, output = process.stdout, title, options, initial = 0, footer = '↑/↓ to select · Enter to confirm · Esc to cancel', max = 10 }) {
30
+ const items = options.map(o => (typeof o === 'string' ? { label: o } : o));
31
+ let index = Math.min(Math.max(initial, 0), items.length - 1), drawn = 0;
32
+ const render = () => {
33
+ const start = Math.max(0, Math.min(index - Math.floor(max / 2), items.length - max));
34
+ const visible = items.slice(start, start + max);
35
+ const rows = [];
36
+ if (title) rows.push(' ' + ui.paint('1', title));
37
+ if (start > 0) rows.push(ui.paint('2', ` ↑ ${start} more`));
38
+ visible.forEach((item, n) => {
39
+ const i = start + n, text = `${i + 1}. ${item.label}`;
40
+ const hint = item.hint ? ui.paint('2', ' ' + item.hint) : '';
41
+ rows.push(i === index ? ' ' + ui.paint('38;5;209', '❯ ' + text) + hint : ' ' + text + hint);
42
+ });
43
+ if (start + max < items.length) rows.push(ui.paint('2', ` ↓ ${items.length - start - max} more`));
44
+ rows.push(ui.paint('2', ' ' + footer));
45
+ const up = drawn > 1 ? `\x1b[${drawn - 1}A` : '';
46
+ output.write((drawn ? up + '\r' : '\x1b[?25l') + rows.map(r => '\x1b[2K' + r).join('\n') + '\x1b[0J');
47
+ drawn = rows.length;
48
+ };
49
+ return new Promise(resolve => {
50
+ const finish = value => {
51
+ remove();
52
+ output.write((drawn > 1 ? `\x1b[${drawn - 1}A` : '') + '\r\x1b[0J\x1b[?25h');
53
+ resolve(value);
54
+ };
55
+ const remove = keys.push((s, key = {}) => {
56
+ if (key.name === 'up' || key.name === 'k' || (key.name === 'p' && key.ctrl)) { index = (index - 1 + items.length) % items.length; render(); }
57
+ else if (key.name === 'down' || key.name === 'j' || (key.name === 'n' && key.ctrl)) { index = (index + 1) % items.length; render(); }
58
+ else if (key.name === 'return' || key.name === 'enter') finish(index);
59
+ else if (key.name === 'escape') finish(-1);
60
+ else if (/^[1-9]$/.test(s || '') && Number(s) <= items.length) { index = Number(s) - 1; render(); finish(index); }
61
+ return true; // menus swallow every other key
62
+ });
63
+ render();
64
+ });
65
+ }
package/ui.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import os from 'node:os';
2
- export const VERSION='0.8.0';
2
+ export const VERSION='0.8.1';
3
3
  export const nextMode=mode=>approvalModes[(approvalModes.indexOf(mode)+1)%approvalModes.length];
4
4
  export const approvalModes=['ask','auto-edit','auto-all'];
5
5
  export function autoApproves(mode,tool) {
@@ -88,7 +88,7 @@ export function createUI(clean,output=process.stdout) {
88
88
  line(paint('2',' Tips for getting started:'));
89
89
  line(paint('2',' 1. Ask questions, edit files, or run commands in this project'));
90
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'));
91
+ line(paint('2',' 3. Type / for commands (↑/↓ to pick, Enter to run) · shift+tab switches approval mode'));
92
92
  },
93
93
  commandMenu:commands=>{
94
94
  for(const [command,description] of commands) line(' '+paint('36',command.padEnd(16))+paint('2',description));