ikie-cli 0.1.41 → 0.1.43

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/dist/agent.d.ts CHANGED
@@ -20,7 +20,7 @@ export interface AgentTurnStats {
20
20
  toolCalls: number;
21
21
  filesChanged: number;
22
22
  }
23
- export declare function estimateTokens(chars: number): number;
23
+ export { estimateTokens } from './utils.js';
24
24
  /**
25
25
  * Extract the most useful upstream error message from an OpenAI SDK error.
26
26
  * The SDK wraps provider responses; we prefer the nested error.message if it exists.
@@ -79,6 +79,8 @@ export declare class Agent {
79
79
  private mode;
80
80
  private planSteps;
81
81
  private planStepIndex;
82
+ private thinkingStartTime;
83
+ private lastThinkingDuration;
82
84
  constructor(client: OpenAI, config: IkieConfig, systemPrompt: string);
83
85
  write(text: string): void;
84
86
  clearConversation(): void;
@@ -119,4 +121,6 @@ export declare class Agent {
119
121
  private checkPermission;
120
122
  }
121
123
  export declare const PLAN_MODE_ADDENDUM = "\n\n## PLAN MODE (read-only)\nYou are currently in **plan mode**. You have ONLY read-only tools (read_file,\nlist_dir, search_files, grep, fetch_url, web_search, use_skill, ask_user). You CANNOT write files, edit\nfiles, run shell commands, or change anything \u2014 those tools are unavailable and any\nattempt will be blocked.\n\nYour job is to investigate and produce a clear, actionable plan:\n- Explore the relevant files and understand the current state before proposing anything.\n- Do NOT describe changes as if you already made them. You haven't.\n- End your response with a concise plan: a short **## Plan** heading followed by\n numbered steps. Name the specific files to change and what to change in each. Call\n out risks, assumptions, or open questions.\n- Keep it tight \u2014 enough to execute from, not an essay.\n\nAfter you present the plan, the user will be asked whether to execute it. On approval,\nyou'll be switched to agent mode and asked to carry out exactly this plan.";
124
+ export declare function isLightweightModel(model: string): boolean;
125
+ export declare function buildLightweightSystemPrompt(prompt: string): string;
122
126
  export declare function buildSystemPrompt(projectContext: string, memoryContext: string, skillsCatalog?: string): string;
package/dist/agent.js CHANGED
@@ -2,16 +2,16 @@ import chalk from 'chalk';
2
2
  import * as readline from 'node:readline';
3
3
  import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath, getMcpToolDefs } from './tools.js';
4
4
  import { IKIE_PORT } from './config.js';
5
+ import { estimateTokens } from './utils.js';
5
6
  import { renderMarkdown, extractThinkTags } from './renderer.js';
6
7
  import { getSkill, renderSkill, mapAllowedTools } from './skills.js';
7
- import { c, toolLine, toolSuccessLine, toolErrorLine, toolOutputBlock, toolDiffBlock, InlineSpinner, CH, toolMeta } from './theme.js';
8
+ import { c, toolLine, toolSuccessLine, toolErrorLine, toolOutputBlock, toolDiffBlock, InlineSpinner, CH, toolMeta, thinkingTime, actionLine, planStep, planTitle } from './theme.js';
8
9
  /** Default per-turn step budget — guards against runaway tool loops. */
9
10
  export const DEFAULT_MAX_STEPS = 60;
10
11
  /** Result synthesized for a tool call that never produced one (cancel/crash). */
11
12
  export const INTERRUPTED_TOOL_RESULT = 'Interrupted: this tool did not run to completion (the turn was cancelled).';
12
- export function estimateTokens(chars) {
13
- return Math.max(1, Math.round(chars / 4));
14
- }
13
+ // Re-export so existing callers that import from agent.ts still work.
14
+ export { estimateTokens } from './utils.js';
15
15
  /**
16
16
  * Extract the most useful upstream error message from an OpenAI SDK error.
17
17
  * The SDK wraps provider responses; we prefer the nested error.message if it exists.
@@ -251,6 +251,8 @@ export class Agent {
251
251
  mode = 'agent';
252
252
  planSteps = [];
253
253
  planStepIndex = 0;
254
+ thinkingStartTime = 0;
255
+ lastThinkingDuration = 0;
254
256
  constructor(client, config, systemPrompt) {
255
257
  this.client = client;
256
258
  this.config = config;
@@ -337,9 +339,9 @@ export class Agent {
337
339
  this.planSteps = steps;
338
340
  this.planStepIndex = 0;
339
341
  if (steps.length > 0) {
340
- this.write(`\n ${c.primary.bold('Plan')} ${c.muted(`(${steps.length} steps)`)}\n`);
342
+ this.write(`\n${this.indent}${planTitle(steps.length)}\n\n`);
341
343
  for (let i = 0; i < steps.length; i++) {
342
- this.write(` ${c.muted('☐')} ${c.dim(steps[i])}\n`);
344
+ this.write(`${this.indent}${planStep(steps[i], false)}\n`);
343
345
  }
344
346
  this.write('\n');
345
347
  }
@@ -347,7 +349,7 @@ export class Agent {
347
349
  advancePlanStep() {
348
350
  if (this.planStepIndex < this.planSteps.length) {
349
351
  const step = this.planSteps[this.planStepIndex];
350
- this.write(` ${c.success('☑')} ${c.white(step)}\n`);
352
+ this.write(`${this.indent}${planStep(step, true)}\n`);
351
353
  this.planStepIndex++;
352
354
  }
353
355
  }
@@ -476,7 +478,15 @@ export class Agent {
476
478
  if (remaining.length === 1) {
477
479
  if (this.activeTurnStats)
478
480
  this.activeTurnStats.toolCalls++;
479
- this.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
481
+ // Use clean action line for file edits
482
+ if (remaining[0].name === 'edit_file' || remaining[0].name === 'write_file') {
483
+ const filePath = String(inputs[0].path || '');
484
+ const action = remaining[0].name === 'edit_file' ? 'Edit' : 'Write';
485
+ this.write(`\n${this.indent}${actionLine(action, filePath)}\n`);
486
+ }
487
+ else {
488
+ this.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
489
+ }
480
490
  const result = await this.handleToolCall(remaining[0].name, remaining[0].id, inputs[0], opts);
481
491
  pushResult(remaining[0].id, result);
482
492
  }
@@ -499,9 +509,19 @@ export class Agent {
499
509
  const groupSpinner = new InlineSpinner(`${toolPhaseLabel(remaining[0].name)} (${remaining.length} operations)`, t0, opts.signal);
500
510
  groupSpinner.start();
501
511
  const results = new Map();
512
+ // Fail-fast flag for mutating tool batches: if a write/edit fails
513
+ // with a critical error, skip subsequent tools in this batch to
514
+ // prevent writing broken/incomplete code from a half-applied change.
515
+ const isMutatingBatch = remaining[0].name === 'edit_file' || remaining[0].name === 'write_file';
516
+ let batchAborted = false;
502
517
  for (let i = 0; i < remaining.length; i++) {
503
518
  if (opts.signal?.aborted)
504
519
  break;
520
+ // Fail-fast: skip remaining mutating ops after a critical failure.
521
+ if (batchAborted) {
522
+ results.set(i, 'Skipped: a previous tool in this batch failed — batch aborted to prevent inconsistent state.');
523
+ continue;
524
+ }
505
525
  const tc = remaining[i];
506
526
  try {
507
527
  if (tc.name === 'read_file' && isRestrictedPath(String(inputs[i].path ?? ''))) {
@@ -514,13 +534,19 @@ export class Agent {
514
534
  const result = tc.name === 'use_skill'
515
535
  ? await this.handleToolCall(tc.name, tc.id, inputs[i], opts)
516
536
  : await executeTool(tc.name, inputs[i], opts.signal);
517
- if (result.startsWith('Error'))
537
+ if (result.startsWith('Error')) {
518
538
  errors++;
539
+ // Trigger fail-fast only for mutating batches on critical errors.
540
+ if (isMutatingBatch)
541
+ batchAborted = true;
542
+ }
519
543
  this.recordChangedFile(tc.name, inputs[i], result);
520
544
  results.set(i, result);
521
545
  }
522
546
  catch (err) {
523
547
  errors++;
548
+ if (isMutatingBatch)
549
+ batchAborted = true;
524
550
  results.set(i, `Tool error: ${err}`);
525
551
  }
526
552
  }
@@ -597,9 +623,20 @@ export class Agent {
597
623
  if (this.activeTurnStats)
598
624
  this.activeTurnStats.modelCalls++;
599
625
  await this.throttleModelRequest();
626
+ // Start thinking timer
627
+ this.thinkingStartTime = Date.now();
600
628
  const state = { emitted: false };
601
629
  try {
602
- return await this.callModelStreaming(opts, state);
630
+ const result = await this.callModelStreaming(opts, state);
631
+ // Record thinking duration
632
+ this.lastThinkingDuration = (Date.now() - this.thinkingStartTime) / 1000;
633
+ // Display thinking time if it was significant (>0.5s)
634
+ if (this.lastThinkingDuration > 0.5) {
635
+ this.write(`
636
+ ${this.indent}${thinkingTime(this.lastThinkingDuration)}
637
+ `);
638
+ }
639
+ return result;
603
640
  }
604
641
  catch (err) {
605
642
  if (opts.signal?.aborted)
@@ -609,7 +646,14 @@ export class Agent {
609
646
  // and double-bill — surface the error instead.
610
647
  if (state.emitted)
611
648
  throw err;
612
- return await this.callModelNonStreaming(opts);
649
+ const result = await this.callModelNonStreaming(opts);
650
+ this.lastThinkingDuration = (Date.now() - this.thinkingStartTime) / 1000;
651
+ if (this.lastThinkingDuration > 0.5) {
652
+ this.write(`
653
+ ${this.indent}${thinkingTime(this.lastThinkingDuration)}
654
+ `);
655
+ }
656
+ return result;
613
657
  }
614
658
  }
615
659
  buildParams() {
@@ -622,7 +666,11 @@ export class Agent {
622
666
  // Always include switch_mode so the agent can request a mode change.
623
667
  const switchModeTool = TOOL_DEFS.find(t => t.function.name === 'switch_mode');
624
668
  // Plan mode: only offer read-only tools, and steer toward proposing a plan.
625
- let systemContent = this.systemPrompt;
669
+ // For lightweight/local models, use a condensed system prompt to preserve
670
+ // context window budget; full prompt is used for large capable models.
671
+ let systemContent = isLightweightModel(this.config.model)
672
+ ? buildLightweightSystemPrompt(this.systemPrompt)
673
+ : this.systemPrompt;
626
674
  if (this.mode === 'plan') {
627
675
  tools = tools.filter(t => PLAN_TOOLS.has(t.function.name) || t.function.name === 'switch_mode');
628
676
  if (switchModeTool && !tools.includes(switchModeTool))
@@ -1090,6 +1138,52 @@ Your job is to investigate and produce a clear, actionable plan:
1090
1138
 
1091
1139
  After you present the plan, the user will be asked whether to execute it. On approval,
1092
1140
  you'll be switched to agent mode and asked to carry out exactly this plan.`;
1141
+ export function isLightweightModel(model) {
1142
+ const m = model.toLowerCase();
1143
+ return (m.includes('haiku') ||
1144
+ m.includes('flash') ||
1145
+ m.includes('mini') ||
1146
+ m.includes('llama') ||
1147
+ m.includes('gemma') ||
1148
+ m.includes('phi') ||
1149
+ m.includes('qwen') ||
1150
+ m.includes('local'));
1151
+ }
1152
+ export function buildLightweightSystemPrompt(prompt) {
1153
+ const lines = prompt.split('\n');
1154
+ const result = [];
1155
+ let skipping = false;
1156
+ for (let i = 0; i < lines.length; i++) {
1157
+ const line = lines[i];
1158
+ // Check if we should start/stop skipping a verbose section
1159
+ if (line.startsWith('## Response Formatting') ||
1160
+ line.startsWith('## Modern Design') ||
1161
+ line.startsWith('## Tool-Use Discipline') ||
1162
+ line.startsWith('## Tools Available') ||
1163
+ line.startsWith('## MCP (Model Context Protocol) System')) {
1164
+ skipping = true;
1165
+ continue;
1166
+ }
1167
+ if (line.startsWith('## Identity') ||
1168
+ line.startsWith('## Confidentiality') ||
1169
+ line.startsWith('## Working Style') ||
1170
+ line.startsWith('## Engineering Principles') ||
1171
+ line.startsWith('## Approach') ||
1172
+ line.startsWith('## Project Context') ||
1173
+ line.startsWith('## Memory') ||
1174
+ line.startsWith('## Available Skills')) {
1175
+ skipping = false;
1176
+ }
1177
+ if (!skipping) {
1178
+ result.push(line);
1179
+ }
1180
+ }
1181
+ // Inject a condensed response formatting instruction at the top
1182
+ const corePrompt = result.join('\n');
1183
+ return `You are Ikie, an elite terminal coding assistant. Be direct, concise, and pragmatic. Use markdown in responses.
1184
+
1185
+ ${corePrompt}`;
1186
+ }
1093
1187
  export function buildSystemPrompt(projectContext, memoryContext, skillsCatalog = '') {
1094
1188
  const parts = [
1095
1189
  `You are Ikie, an elite agentic software engineer running in the terminal.
package/dist/context.d.ts CHANGED
@@ -9,5 +9,5 @@ export interface ProjectContext {
9
9
  manifest?: string;
10
10
  instructions?: string;
11
11
  }
12
- export declare function detectProjectContext(): ProjectContext;
12
+ export declare function detectProjectContext(projectRoot?: string): ProjectContext;
13
13
  export declare function formatContextForPrompt(ctx: ProjectContext): string;
package/dist/context.js CHANGED
@@ -82,8 +82,8 @@ function readManifest(cwd) {
82
82
  }
83
83
  return undefined;
84
84
  }
85
- export function detectProjectContext() {
86
- const cwd = process.cwd();
85
+ export function detectProjectContext(projectRoot) {
86
+ const cwd = projectRoot || process.cwd();
87
87
  const name = basename(cwd);
88
88
  const types = detectTypes(cwd);
89
89
  let readme;
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import minimist from 'minimist';
5
5
  import { loadConfig, getApiKey, IKIE_API_BASE, IKIE_HOST, DEFAULT_MODEL, hasExplicitModel } from './config.js';
6
6
  import { detectProjectContext, formatContextForPrompt } from './context.js';
7
7
  import { loadAllMemory, formatMemoryForPrompt } from './memory.js';
8
+ import { findProjectRoot } from './utils.js';
8
9
  import { discoverSkills, formatSkillsForPrompt } from './skills.js';
9
10
  import { buildSystemPrompt, Agent } from './agent.js';
10
11
  import { startREPL } from './repl.js';
@@ -203,11 +204,12 @@ ${errorLine('Not signed in.')}
203
204
  baseURL: config.baseURL ?? IKIE_API_BASE,
204
205
  timeout: 60000, // 60 seconds timeout to prevent indefinite hangs
205
206
  });
206
- const projectCtx = detectProjectContext();
207
+ const projectRoot = findProjectRoot(process.cwd());
208
+ const projectCtx = detectProjectContext(projectRoot);
207
209
  const projectContextStr = formatContextForPrompt(projectCtx);
208
- const memory = loadAllMemory();
210
+ const memory = loadAllMemory(projectRoot);
209
211
  const memoryStr = formatMemoryForPrompt(memory);
210
- const skills = discoverSkills();
212
+ const skills = discoverSkills(projectRoot);
211
213
  const skillsStr = formatSkillsForPrompt(skills);
212
214
  const systemPrompt = buildSystemPrompt(projectContextStr, memoryStr, skillsStr);
213
215
  if (argv.verbose) {
@@ -219,9 +221,8 @@ ${errorLine('Not signed in.')}
219
221
  }
220
222
  const agent = new Agent(client, config, systemPrompt);
221
223
  // Connect configured MCP servers before the first turn.
222
- const cwd = process.cwd();
223
224
  try {
224
- await getMcpManager().connectAll(cwd);
225
+ await getMcpManager().connectAll(projectRoot);
225
226
  }
226
227
  catch {
227
228
  // Failures are recorded per-server; don't crash the REPL.
package/dist/memory.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  export declare function loadGlobalMemory(): string;
2
- export declare function loadProjectMemory(): string;
2
+ export declare function loadProjectMemory(projectRoot?: string): string;
3
3
  export declare function saveGlobalMemory(content: string): void;
4
- export declare function saveProjectMemory(content: string): void;
4
+ export declare function saveProjectMemory(content: string, projectRoot?: string): void;
5
5
  export declare function appendGlobalMemory(note: string): void;
6
- export declare function appendProjectMemory(note: string): void;
6
+ export declare function appendProjectMemory(note: string, projectRoot?: string): void;
7
7
  export interface MemoryContext {
8
8
  global: string;
9
9
  project: string;
10
10
  }
11
- export declare function loadAllMemory(): MemoryContext;
11
+ export declare function loadAllMemory(projectRoot?: string): MemoryContext;
12
12
  export declare function formatMemoryForPrompt(mem: MemoryContext): string;
13
- export declare function getProjectMemoryPath(): string;
13
+ export declare function getProjectMemoryPath(projectRoot?: string): string;
14
14
  export declare function getGlobalMemoryPath(): string;
package/dist/memory.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join, resolve } from 'path';
3
3
  import { GLOBAL_MEMORY_FILE } from './config.js';
4
- const PROJECT_MEMORY_DIR = '.ikie';
5
- const PROJECT_MEMORY_FILE = join(PROJECT_MEMORY_DIR, 'memory.md');
6
- function ensureProjectDir() {
7
- if (!existsSync(PROJECT_MEMORY_DIR)) {
8
- mkdirSync(PROJECT_MEMORY_DIR, { recursive: true });
4
+ import { findProjectRoot } from './utils.js';
5
+ function getProjectMemoryFile(projectRoot) {
6
+ const root = projectRoot || findProjectRoot(process.cwd());
7
+ return join(root, '.ikie', 'memory.md');
8
+ }
9
+ function ensureProjectDir(projectRoot) {
10
+ const root = projectRoot || findProjectRoot(process.cwd());
11
+ const dir = join(root, '.ikie');
12
+ if (!existsSync(dir)) {
13
+ mkdirSync(dir, { recursive: true });
9
14
  }
10
15
  }
11
16
  export function loadGlobalMemory() {
@@ -17,10 +22,11 @@ export function loadGlobalMemory() {
17
22
  catch { }
18
23
  return '';
19
24
  }
20
- export function loadProjectMemory() {
25
+ export function loadProjectMemory(projectRoot) {
21
26
  try {
22
- if (existsSync(PROJECT_MEMORY_FILE)) {
23
- return readFileSync(PROJECT_MEMORY_FILE, 'utf-8').trim();
27
+ const file = getProjectMemoryFile(projectRoot);
28
+ if (existsSync(file)) {
29
+ return readFileSync(file, 'utf-8').trim();
24
30
  }
25
31
  }
26
32
  catch { }
@@ -29,9 +35,9 @@ export function loadProjectMemory() {
29
35
  export function saveGlobalMemory(content) {
30
36
  writeFileSync(GLOBAL_MEMORY_FILE, content.trim() + '\n');
31
37
  }
32
- export function saveProjectMemory(content) {
33
- ensureProjectDir();
34
- writeFileSync(PROJECT_MEMORY_FILE, content.trim() + '\n');
38
+ export function saveProjectMemory(content, projectRoot) {
39
+ ensureProjectDir(projectRoot);
40
+ writeFileSync(getProjectMemoryFile(projectRoot), content.trim() + '\n');
35
41
  }
36
42
  export function appendGlobalMemory(note) {
37
43
  const existing = loadGlobalMemory();
@@ -40,17 +46,17 @@ export function appendGlobalMemory(note) {
40
46
  : note.trim();
41
47
  saveGlobalMemory(updated);
42
48
  }
43
- export function appendProjectMemory(note) {
44
- const existing = loadProjectMemory();
49
+ export function appendProjectMemory(note, projectRoot) {
50
+ const existing = loadProjectMemory(projectRoot);
45
51
  const updated = existing
46
52
  ? `${existing}\n\n${note.trim()}`
47
53
  : note.trim();
48
- saveProjectMemory(updated);
54
+ saveProjectMemory(updated, projectRoot);
49
55
  }
50
- export function loadAllMemory() {
56
+ export function loadAllMemory(projectRoot) {
51
57
  return {
52
58
  global: loadGlobalMemory(),
53
- project: loadProjectMemory(),
59
+ project: loadProjectMemory(projectRoot),
54
60
  };
55
61
  }
56
62
  export function formatMemoryForPrompt(mem) {
@@ -63,8 +69,8 @@ export function formatMemoryForPrompt(mem) {
63
69
  }
64
70
  return parts.join('\n\n');
65
71
  }
66
- export function getProjectMemoryPath() {
67
- return resolve(PROJECT_MEMORY_FILE);
72
+ export function getProjectMemoryPath(projectRoot) {
73
+ return resolve(getProjectMemoryFile(projectRoot));
68
74
  }
69
75
  export function getGlobalMemoryPath() {
70
76
  return GLOBAL_MEMORY_FILE;
package/dist/repl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as readline from 'node:readline';
2
2
  import { execSync, exec } from 'child_process';
3
3
  import { restoreStdinListeners, extractUpstreamError } from './agent.js';
4
- import { c, PROMPT, CONTINUE_PROMPT, PROMPT_ARROW, printPromptHeader, promptHeaderText, modeTag, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, contextRing, renderSlashMenu, supportsVT, CH, } from './theme.js';
4
+ import { c, PROMPT, CONTINUE_PROMPT, PROMPT_ARROW, printPromptHeader, promptHeaderText, modeTag, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, contextRing, renderSlashMenu, supportsVT, CH, planApprovalPrompt, planHeader, } from './theme.js';
5
5
  import { renderMarkdown } from './renderer.js';
6
6
  import { loadAllMemory } from './memory.js';
7
7
  import { HOME_DIR, saveConfig, DEFAULT_MODEL, IKIE_HOST, IKIE_API_BASE, isLoggedIn, getApiKey, loadConfig } from './config.js';
@@ -285,13 +285,15 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
285
285
  }
286
286
  else {
287
287
  const { appendProjectMemory } = await import('./memory.js');
288
- appendProjectMemory(note);
288
+ const { findProjectRoot } = await import('./utils.js');
289
+ appendProjectMemory(note, findProjectRoot(process.cwd()));
289
290
  }
290
291
  console.log(successLine(`Saved to ${scope} memory.`));
291
292
  }
292
293
  }
293
294
  else {
294
- const mem = loadAllMemory();
295
+ const { findProjectRoot } = await import('./utils.js');
296
+ const mem = loadAllMemory(findProjectRoot(process.cwd()));
295
297
  if (mem.global || mem.project) {
296
298
  if (mem.global) {
297
299
  console.log(`\n${c.primary.bold('Global Memory')} ${c.muted('(~/.ikie/memory.md)')}`);
@@ -469,6 +471,8 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
469
471
  }
470
472
  case 'skills': {
471
473
  const sub = (args[0] ?? 'list').toLowerCase();
474
+ const { findProjectRoot } = await import('./utils.js');
475
+ const projectRoot = findProjectRoot(process.cwd());
472
476
  const { discoverSkills, getSkill, listSkillFiles, installSkill, removeSkill } = await import('./skills.js');
473
477
  if (sub === 'install' || sub === 'add') {
474
478
  const rest = args.slice(1).filter(a => a !== '--force');
@@ -517,7 +521,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
517
521
  console.log(errorLine('Usage: /skills show <name>'));
518
522
  return true;
519
523
  }
520
- const skill = getSkill(nameArg);
524
+ const skill = getSkill(nameArg, projectRoot);
521
525
  if (!skill) {
522
526
  console.log(errorLine(`No skill named "${nameArg}".`));
523
527
  return true;
@@ -529,7 +533,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
529
533
  console.log(renderMarkdown(skill.body || '(no instructions)'));
530
534
  return true;
531
535
  }
532
- const skills = discoverSkills();
536
+ const skills = discoverSkills(projectRoot);
533
537
  if (!skills.length) {
534
538
  console.log(infoLine('No skills installed.'));
535
539
  console.log(`\n ${c.muted('Install one with')} ${c.warning('/skills install <git-url|path>')}`);
@@ -943,14 +947,12 @@ function confirmExecutePlan() {
943
947
  resolve(false);
944
948
  return;
945
949
  }
946
- process.stdout.write(`\n ${c.primary('▸')} ${c.white.bold('Plan ready.')} ${c.muted('Execute it now?')} ` +
947
- `${c.success.bold('y')} ${c.muted('yes')} ${c.error.bold('n')} ${c.muted('keep planning')}\n` +
948
- ` ${c.muted('❯')} `);
950
+ process.stdout.write(planApprovalPrompt());
949
951
  const onKey = (d) => {
950
952
  process.stdin.removeListener('data', onKey);
951
953
  const k = d.toString().toLowerCase();
952
954
  const yes = k === 'y' || k === '\r' || k === '\n';
953
- process.stdout.write(yes ? c.success('y\n') : c.muted('n\n'));
955
+ process.stdout.write(yes ? c.success('y\n') : c.error('N\n'));
954
956
  resolve(yes);
955
957
  };
956
958
  process.stdin.on('data', onKey);
@@ -1130,6 +1132,30 @@ function parsePlanSteps(conversation) {
1130
1132
  }
1131
1133
  export async function startREPL(agent, config, projectContext, oneShot) {
1132
1134
  void HISTORY_FILE;
1135
+ const setBracketedPaste = (on) => {
1136
+ if (process.stdout.isTTY)
1137
+ process.stdout.write(on ? '\x1b[?2004h' : '\x1b[?2004l');
1138
+ };
1139
+ const restoreTerminal = () => {
1140
+ setBracketedPaste(false);
1141
+ if (process.stdin.isTTY) {
1142
+ process.stdin.setRawMode(false);
1143
+ process.stdin.pause();
1144
+ }
1145
+ };
1146
+ const onFatalError = (err) => {
1147
+ restoreTerminal();
1148
+ console.error('\n' + errorLine(`Uncaught exception: ${err instanceof Error ? err.stack || err.message : String(err)}`));
1149
+ process.exit(1);
1150
+ };
1151
+ const onUnhandledRejection = (reason) => {
1152
+ restoreTerminal();
1153
+ console.error('\n' + errorLine(`Unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`));
1154
+ process.exit(1);
1155
+ };
1156
+ process.on('uncaughtException', onFatalError);
1157
+ process.on('unhandledRejection', onUnhandledRejection);
1158
+ process.on('exit', restoreTerminal);
1133
1159
  // Context window size — default 131072 (128k), updated silently from model info.
1134
1160
  let contextWindow = 131072;
1135
1161
  fetchModelsFromServer(config).then(models => {
@@ -1183,12 +1209,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1183
1209
  // \x1b[200~ … \x1b[201~ markers. This lets a multi-chunk paste (a long
1184
1210
  // paragraph the TTY delivers across several reads) be coalesced into ONE
1185
1211
  // paste instead of being split into several. Disabled again on exit.
1186
- const setBracketedPaste = (on) => {
1187
- if (process.stdout.isTTY)
1188
- process.stdout.write(on ? '\x1b[?2004h' : '\x1b[?2004l');
1189
- };
1190
1212
  setBracketedPaste(true);
1191
- process.on('exit', () => setBracketedPaste(false));
1192
1213
  let multilineBuffer = '';
1193
1214
  let busy = false;
1194
1215
  let ctrlCCount = 0;
@@ -1648,6 +1669,10 @@ export async function startREPL(agent, config, projectContext, oneShot) {
1648
1669
  };
1649
1670
  process.stdin.on('data', cancelHandler);
1650
1671
  try {
1672
+ // Display mode status for plan mode
1673
+ if (agent.getMode() === 'plan') {
1674
+ process.stdout.write(`\n${planHeader('plan mode', agent.getLastTurnStats().filesChanged)}\n\n`);
1675
+ }
1651
1676
  await agent.send(userContent, {
1652
1677
  autoApprove: config.autoApprove,
1653
1678
  signal: abortController.signal,
package/dist/theme.d.ts CHANGED
@@ -50,6 +50,9 @@ declare const CH: {
50
50
  cont: string;
51
51
  arrow: string;
52
52
  dot: string;
53
+ sparkle: string;
54
+ check: string;
55
+ box: string;
53
56
  };
54
57
  export { CH };
55
58
  /**
@@ -109,3 +112,54 @@ export declare function errorLine(msg: string): string;
109
112
  export declare function warnLine(msg: string): string;
110
113
  export declare function infoLine(msg: string): string;
111
114
  export declare function permissionPrompt(toolName: string, preview: string): string;
115
+ /**
116
+ * Draw a rounded box around text (like command input in images)
117
+ */
118
+ export declare function roundedBox(content: string, opts?: {
119
+ width?: number;
120
+ padding?: number;
121
+ }): string;
122
+ /**
123
+ * Status indicator with sparkle icon
124
+ */
125
+ export declare function sparkleStatus(message: string, color?: keyof typeof c): string;
126
+ /**
127
+ * Thinking time display
128
+ */
129
+ export declare function thinkingTime(seconds: number): string;
130
+ /**
131
+ * Action indicator (e.g., "✦ Edit src/api/checkout.ts")
132
+ */
133
+ export declare function actionLine(action: string, target: string): string;
134
+ /**
135
+ * Plan step display
136
+ */
137
+ export declare function planStep(text: string, completed?: boolean): string;
138
+ /**
139
+ * Plan header
140
+ */
141
+ export declare function planHeader(mode: string, filesChanged?: number): string;
142
+ /**
143
+ * Proposed plan title
144
+ */
145
+ export declare function planTitle(stepCount: number): string;
146
+ /**
147
+ * Bottom status bar (full width)
148
+ */
149
+ export declare function bottomBar(leftText: string, rightText?: string): string;
150
+ /**
151
+ * Code line with number and optional highlight
152
+ */
153
+ export declare function codeLine(lineNum: number, code: string, highlight?: boolean): string;
154
+ /**
155
+ * File edit header
156
+ */
157
+ export declare function fileEditHeader(filePath: string): string;
158
+ /**
159
+ * Approval prompt for plan execution
160
+ */
161
+ export declare function planApprovalPrompt(): string;
162
+ /**
163
+ * Command echo in rounded box
164
+ */
165
+ export declare function commandBox(command: string): string;
package/dist/theme.js CHANGED
@@ -284,8 +284,8 @@ const IS_WIN = process.platform === 'win32';
284
284
  /** True when the terminal supports VT100 escape sequences (cursor save/restore, etc). */
285
285
  export const supportsVT = !IS_WIN;
286
286
  const CH = IS_WIN
287
- ? { tl: '+-', prompt: '-> ', cont: '| ', arrow: '>', dot: 'o' }
288
- : { tl: '╭─', prompt: '╰─❯', cont: '│ ', arrow: '❯', dot: '●' };
287
+ ? { tl: '+-', prompt: '-> ', cont: '| ', arrow: '>', dot: 'o', sparkle: '*', check: 'v', box: '[ ]' }
288
+ : { tl: '╭─', prompt: '╰─❯', cont: '│ ', arrow: '❯', dot: '●', sparkle: '✦', check: '✓', box: '☐' };
289
289
  export { CH };
290
290
  /**
291
291
  * Builds the prompt header line (e.g. `╭─ ikie · agent theme aurora in <cwd>`)
@@ -297,7 +297,16 @@ export function promptHeaderText(mode = 'agent') {
297
297
  const branch = getGitBranchFast();
298
298
  const gitSegment = branch ? ` ${c.muted('on')} ${c.secondary(branch)}` : '';
299
299
  const themeSegment = ` ${c.muted('theme')} ${c.secondary(activeTheme.name)}`;
300
- return `${c.primary(CH.tl)} ${c.primary.bold('ikie')} ${c.muted('·')} ${modeTag(mode)}${gitSegment}${themeSegment} ${c.muted('in')} ${c.accent(cwdName)}`;
300
+ const leftStr = `${c.primary(CH.tl)} ${c.primary.bold('ikie')} ${c.muted('·')} ${modeTag(mode)}${gitSegment}${themeSegment} ${c.muted('in')} ${c.accent(cwdName)}`;
301
+ const rightStr = `${mode} · shift+tab to toggle`;
302
+ const cols = process.stdout.columns ?? 80;
303
+ const leftLen = stripAnsi(leftStr).length;
304
+ const rightLen = rightStr.length;
305
+ const space = cols - leftLen - rightLen - 1; // -1 safety to prevent soft-wrap
306
+ if (space > 0) {
307
+ return `${leftStr}${' '.repeat(space)}${c.dim(rightStr)}`;
308
+ }
309
+ return leftStr;
301
310
  }
302
311
  export function printPromptHeader(mode = 'agent') {
303
312
  process.stdout.write(`\n${promptHeaderText(mode)}\n`);
@@ -667,3 +676,127 @@ export function permissionPrompt(toolName, preview) {
667
676
  `${c.muted.bold('!')} ${c.muted('never')}\n` +
668
677
  ` ${c.muted(CH.arrow)} `);
669
678
  }
679
+ // ──────────────────────────────────────────────────────────────────────────────
680
+ // Modern UX Components
681
+ // ──────────────────────────────────────────────────────────────────────────────
682
+ /**
683
+ * Draw a rounded box around text (like command input in images)
684
+ */
685
+ export function roundedBox(content, opts) {
686
+ const cols = opts?.width ?? Math.min(process.stdout.columns ?? 80, 120);
687
+ const padding = opts?.padding ?? 1;
688
+ const innerWidth = cols - 4 - (padding * 2);
689
+ const lines = content.split('\n').flatMap(line => {
690
+ if (stripAnsi(line).length <= innerWidth)
691
+ return [line];
692
+ // Simple wrapping for long lines
693
+ const chunks = [];
694
+ let current = '';
695
+ for (const word of line.split(' ')) {
696
+ if (stripAnsi(current + ' ' + word).length > innerWidth) {
697
+ if (current)
698
+ chunks.push(current);
699
+ current = word;
700
+ }
701
+ else {
702
+ current = current ? current + ' ' + word : word;
703
+ }
704
+ }
705
+ if (current)
706
+ chunks.push(current);
707
+ return chunks;
708
+ });
709
+ const tl = '╭';
710
+ const tr = '╮';
711
+ const bl = '╰';
712
+ const br = '╯';
713
+ const h = '─';
714
+ const v = '│';
715
+ const pad = ' '.repeat(padding);
716
+ const top = c.muted(`${tl}${h.repeat(cols - 2)}${tr}`);
717
+ const bottom = c.muted(`${bl}${h.repeat(cols - 2)}${br}`);
718
+ const contentLines = lines.map(line => {
719
+ const visible = stripAnsi(line).length;
720
+ const spaces = innerWidth - visible;
721
+ return `${c.muted(v)}${pad}${line}${' '.repeat(spaces)}${pad}${c.muted(v)}`;
722
+ });
723
+ return [top, ...contentLines, bottom].join('\n');
724
+ }
725
+ /**
726
+ * Status indicator with sparkle icon
727
+ */
728
+ export function sparkleStatus(message, color) {
729
+ const sparkleColor = color ? c[color] : c.warning;
730
+ return `${sparkleColor(CH.sparkle)} ${c.muted(message)}`;
731
+ }
732
+ /**
733
+ * Thinking time display
734
+ */
735
+ export function thinkingTime(seconds) {
736
+ return sparkleStatus(`Thought for ${seconds.toFixed(1)}s`, 'warning');
737
+ }
738
+ /**
739
+ * Action indicator (e.g., "✦ Edit src/api/checkout.ts")
740
+ */
741
+ export function actionLine(action, target) {
742
+ return `${c.warning(CH.sparkle)} ${c.white(action)} ${c.accent(target)}`;
743
+ }
744
+ /**
745
+ * Plan step display
746
+ */
747
+ export function planStep(text, completed = false) {
748
+ const icon = completed ? c.success(CH.check) : c.muted(CH.box);
749
+ return `${icon} ${completed ? c.white(text) : c.dim(text)}`;
750
+ }
751
+ /**
752
+ * Plan header
753
+ */
754
+ export function planHeader(mode, filesChanged = 0) {
755
+ return sparkleStatus(`${mode} · read-only · ${filesChanged} files changed`, 'warning');
756
+ }
757
+ /**
758
+ * Proposed plan title
759
+ */
760
+ export function planTitle(stepCount) {
761
+ return `${c.white('Proposed plan')} ${c.muted('·')} ${c.secondary(`${stepCount} steps`)}`;
762
+ }
763
+ /**
764
+ * Bottom status bar (full width)
765
+ */
766
+ export function bottomBar(leftText, rightText) {
767
+ const cols = process.stdout.columns ?? 80;
768
+ const left = stripAnsi(leftText);
769
+ const right = rightText ? stripAnsi(rightText) : '';
770
+ const spacing = cols - left.length - right.length;
771
+ return (c.muted(leftText) +
772
+ ' '.repeat(Math.max(0, spacing)) +
773
+ (rightText ? c.dim(rightText) : ''));
774
+ }
775
+ /**
776
+ * Code line with number and optional highlight
777
+ */
778
+ export function codeLine(lineNum, code, highlight = false) {
779
+ const num = c.dim(String(lineNum).padStart(4) + ' ');
780
+ const content = highlight ? chalk.bgHex('#1a3a2e')(code) : code;
781
+ return `${num} ${content}`;
782
+ }
783
+ /**
784
+ * File edit header
785
+ */
786
+ export function fileEditHeader(filePath) {
787
+ return actionLine('Edit', filePath);
788
+ }
789
+ /**
790
+ * Approval prompt for plan execution
791
+ */
792
+ export function planApprovalPrompt() {
793
+ return `
794
+ ${c.muted('Approve to run in agent mode?')} ${c.success.bold('[y')}/${c.error('N]')}
795
+ `;
796
+ }
797
+ /**
798
+ * Command echo in rounded box
799
+ */
800
+ export function commandBox(command) {
801
+ return roundedBox(`${c.primary('>')} ${c.white(command)}`);
802
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Estimate the number of tokens in a string.
3
+ *
4
+ * Heuristic rationale:
5
+ * - Prose English: ~4 chars / token (GPT BPE baseline)
6
+ * - Code/symbols: ~3 chars / token (more tokens per char due to operators,
7
+ * punctuation, identifiers broken at sub-word
8
+ * boundaries by BPE tokenisers)
9
+ *
10
+ * We classify a string as "code-heavy" when the density of typical code
11
+ * characters (braces, semicolons, brackets, operators, indentation runs) is
12
+ * above a threshold. This gives a noticeably better estimate for tool outputs
13
+ * and file contents while remaining O(n) and zero-dependency.
14
+ *
15
+ * The function is designed as a drop-in for a proper BPE tokeniser:
16
+ * swap the body for `tiktoken.encode(text).length` when you want exactness.
17
+ */
18
+ export declare function estimateTokens(chars: number, text?: string): number;
19
+ /**
20
+ * Walk up the directory tree from `startDir`, looking for a `.git` directory
21
+ * or a `package.json` file. Returns the absolute path of the detected root,
22
+ * or `startDir` itself if nothing is found (graceful fallback).
23
+ *
24
+ * This lets `ikie` be invoked from a deeply nested subdirectory and still
25
+ * resolve project-scoped memory/config files correctly.
26
+ */
27
+ export declare function findProjectRoot(startDir: string): string;
package/dist/utils.js ADDED
@@ -0,0 +1,77 @@
1
+ import { existsSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ // ── Token estimation ──────────────────────────────────────────────────────────
4
+ /**
5
+ * Estimate the number of tokens in a string.
6
+ *
7
+ * Heuristic rationale:
8
+ * - Prose English: ~4 chars / token (GPT BPE baseline)
9
+ * - Code/symbols: ~3 chars / token (more tokens per char due to operators,
10
+ * punctuation, identifiers broken at sub-word
11
+ * boundaries by BPE tokenisers)
12
+ *
13
+ * We classify a string as "code-heavy" when the density of typical code
14
+ * characters (braces, semicolons, brackets, operators, indentation runs) is
15
+ * above a threshold. This gives a noticeably better estimate for tool outputs
16
+ * and file contents while remaining O(n) and zero-dependency.
17
+ *
18
+ * The function is designed as a drop-in for a proper BPE tokeniser:
19
+ * swap the body for `tiktoken.encode(text).length` when you want exactness.
20
+ */
21
+ export function estimateTokens(chars, text) {
22
+ if (chars <= 0)
23
+ return 1;
24
+ let charsPerToken = 4; // prose default
25
+ if (text && text.length > 0) {
26
+ // Count code-characteristic characters
27
+ let codeChars = 0;
28
+ for (let i = 0; i < text.length; i++) {
29
+ const c = text.charCodeAt(i);
30
+ // { } ( ) [ ] ; : = < > ! & | ^ ~ + - * / \ . , @ # % ` and tab
31
+ if (c === 123 || c === 125 || // { }
32
+ c === 40 || c === 41 || // ( )
33
+ c === 91 || c === 93 || // [ ]
34
+ c === 59 || c === 58 || // ; :
35
+ c === 61 || c === 60 || c === 62 || // = < >
36
+ c === 33 || c === 38 || c === 124 || // ! & |
37
+ c === 94 || c === 126 || c === 43 || // ^ ~ +
38
+ c === 45 || c === 42 || c === 47 || // - * /
39
+ c === 92 || c === 46 || c === 44 || // \ . ,
40
+ c === 64 || c === 35 || c === 37 || // @ # %
41
+ c === 96 || c === 9 // ` tab
42
+ ) {
43
+ codeChars++;
44
+ }
45
+ }
46
+ const codeDensity = codeChars / text.length;
47
+ if (codeDensity > 0.12) {
48
+ charsPerToken = 3; // code-heavy — more tokens per char
49
+ }
50
+ }
51
+ return Math.max(1, Math.round(chars / charsPerToken));
52
+ }
53
+ // ── Project root detection ────────────────────────────────────────────────────
54
+ /**
55
+ * Walk up the directory tree from `startDir`, looking for a `.git` directory
56
+ * or a `package.json` file. Returns the absolute path of the detected root,
57
+ * or `startDir` itself if nothing is found (graceful fallback).
58
+ *
59
+ * This lets `ikie` be invoked from a deeply nested subdirectory and still
60
+ * resolve project-scoped memory/config files correctly.
61
+ */
62
+ export function findProjectRoot(startDir) {
63
+ let current = startDir;
64
+ // Safety cap: don't walk more than 20 levels to avoid infinite loops on
65
+ // pathological setups or file systems where the root is never reached.
66
+ for (let depth = 0; depth < 20; depth++) {
67
+ if (existsSync(join(current, '.git')) ||
68
+ existsSync(join(current, 'package.json'))) {
69
+ return current;
70
+ }
71
+ const parent = dirname(current);
72
+ if (parent === current)
73
+ break; // reached filesystem root
74
+ current = parent;
75
+ }
76
+ return startDir; // fallback — treat cwd as the project root
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {