klyro 0.1.42 → 0.1.44

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/cli/repl.js CHANGED
@@ -50,9 +50,15 @@ export async function startRepl(opts = {}) {
50
50
  if (providerKind === 'anthropic' && !apiKey) {
51
51
  process.stderr.write('klyro: anthropic provider detected but KLYRO_API_KEY is empty — falling back to OpenAI-compatible adapter\n');
52
52
  }
53
- const adapter = effectiveProvider === 'anthropic'
54
- ? anthropicAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 })
55
- : httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
53
+ let currentProvider = effectiveProvider;
54
+ let currentBaseUrl = baseUrl;
55
+ let currentApiKey = apiKey;
56
+ let currentMaxSteps = opts.maxSteps ?? 30;
57
+ let effortLevel = 'medium';
58
+ const buildAdapter = (prov, url, key) => prov === 'anthropic'
59
+ ? anthropicAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 })
60
+ : httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 });
61
+ let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
56
62
  const ctxBlock = await buildLevel6Context({ cwd });
57
63
  const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
58
64
  // 4.4 KLYRO.md hierarchy
@@ -64,7 +70,7 @@ export async function startRepl(opts = {}) {
64
70
  const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
65
71
  return base + t;
66
72
  };
67
- const ac = new AbortController();
73
+ let ac = new AbortController();
68
74
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
69
75
  // fall back to stdin readline. The bridge is shared between the
70
76
  // App and the runtime so the modal can resolve the runtime's ask().
@@ -133,9 +139,24 @@ export async function startRepl(opts = {}) {
133
139
  let tuiSessionId;
134
140
  if (isAltScreen)
135
141
  enterAlt();
142
+ const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
143
+ // P1 session/permission state (commands.md Priority 1)
144
+ let sessionLabel = '';
145
+ let currentBranch = '';
146
+ let fastMode = false;
147
+ let displayMode = 'default';
148
+ let lastAssistantText = '';
149
+ function queuedClear() {
150
+ if (isMounted && directHooks)
151
+ directHooks.clearTranscript();
152
+ else
153
+ pendingQueue.length = 0;
154
+ if (isMounted && directHooks)
155
+ directHooks.clearTranscript();
156
+ }
136
157
  app = render(React.createElement(App, {
137
158
  initialModel: model,
138
- maxSteps: opts.maxSteps ?? 30,
159
+ maxSteps: currentMaxSteps,
139
160
  cwd,
140
161
  initialStatus: { status: 'idle' },
141
162
  approvalBridge: tuiBridge,
@@ -190,7 +211,7 @@ export async function startRepl(opts = {}) {
190
211
  let sessionId;
191
212
  if (!isSimpleChat) {
192
213
  try {
193
- const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: opts.maxSteps ?? 30 } });
214
+ const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: currentMaxSteps } });
194
215
  sessionId = rec.id;
195
216
  tuiSessionId = rec.id;
196
217
  // Session info goes to status bar, not transcript (clean like Claude Code)
@@ -220,6 +241,7 @@ export async function startRepl(opts = {}) {
220
241
  else if (ev.kind === 'error')
221
242
  throw new Error(ev.message);
222
243
  }
244
+ lastAssistantText = simpleText;
223
245
  queuedStatus({ status: 'done' });
224
246
  return;
225
247
  }
@@ -238,7 +260,7 @@ export async function startRepl(opts = {}) {
238
260
  task: taskText,
239
261
  cwd,
240
262
  model,
241
- maxSteps: opts.maxSteps ?? 30,
263
+ maxSteps: currentMaxSteps,
242
264
  signal: ac.signal,
243
265
  nonInteractive: opts.nonInteractive ?? false,
244
266
  verify: { enabled: true, maxRepairAttempts: 3 },
@@ -337,6 +359,8 @@ export async function startRepl(opts = {}) {
337
359
  }
338
360
  },
339
361
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
362
+ if (result.finalText)
363
+ lastAssistantText = result.finalText;
340
364
  if (result.verification) {
341
365
  const v = result.verification;
342
366
  queuedAppend({
@@ -387,21 +411,19 @@ export async function startRepl(opts = {}) {
387
411
  // Listener cleanup is handled by the waitUntilExit resolver below
388
412
  return;
389
413
  case 'clear':
414
+ queuedClear();
390
415
  queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
391
416
  return;
392
417
  case 'help': {
393
418
  const helpText = [
394
- 'commands:',
395
- ' /clear — clear transcript marker',
396
- ' /diff — show git diff',
397
- ' /status show session status',
398
- ' /compact — (stub) context compaction',
399
- ' /model <id> — switch model mid-session',
400
- ' /config — show config path',
401
- ' /doctor — run diagnostics',
402
- ' /version — show version',
403
- ' /quit (/exit) — exit',
404
- `provider: ${effectiveProvider} model: ${model} cwd: ${cwd}`,
419
+ 'commands (Priority 1):',
420
+ ' session: /new /clear /compact [focus] /resume [id] /sessions /rename [n] /fork [p] /branch [n] /export [f] /copy [n] /quit',
421
+ ' model: /model [id] /models /provider [name] /effort [low|medium|high|max] /fast [on|off]',
422
+ ' project: /init /status /context /diff /plan [task] /todos /memory',
423
+ ' perms: /permissions /mode [m] /sandbox [dir] /approve /deny',
424
+ ' app: /login /logout /auth /version /update /cancel /shell (!cmd) /mention (@path) /tools /config /doctor',
425
+ ' (!cmd runs shell, @path attaches a file)',
426
+ `provider: ${currentProvider} model: ${model} effort: ${effortLevel}${fastMode ? ' fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`,
405
427
  ].join('\n');
406
428
  queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
407
429
  return;
@@ -476,7 +498,7 @@ export async function startRepl(opts = {}) {
476
498
  });
477
499
  }
478
500
  else {
479
- queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${effectiveProvider} cwd: ${cwd}`, role: 'assistant' });
501
+ queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`, role: 'assistant' });
480
502
  }
481
503
  return;
482
504
  }
@@ -577,14 +599,23 @@ export async function startRepl(opts = {}) {
577
599
  return;
578
600
  }
579
601
  case 'compact': {
580
- queuedAppend({ id: `compact-${Date.now()}`, kind: 'text', text: 'Compacting context…', role: 'assistant' });
581
- queuedStatus({ status: 'running' });
602
+ const focus = cmd.focus?.trim();
603
+ queuedClear();
604
+ queuedAppend({
605
+ id: `compact-${Date.now()}`,
606
+ kind: 'text',
607
+ text: focus ? `Context compacted — transcript cleared (focus: ${focus}). Continuing fresh.` : 'Context compacted — transcript cleared. Continuing fresh.',
608
+ role: 'assistant',
609
+ });
610
+ queuedStatus({ status: 'done' });
582
611
  return;
583
612
  }
584
613
  case 'model': {
585
614
  const next = cmd.model?.trim();
586
615
  if (!next) {
587
- queuedAppend({ id: `mdl-${Date.now()}`, kind: 'text', text: `current model: ${model}`, role: 'assistant' });
616
+ const { MODEL_REGISTRY } = await import('../providers/model-info.js');
617
+ const known = Object.keys(MODEL_REGISTRY).join(', ');
618
+ queuedAppend({ id: `mdl-${Date.now()}`, kind: 'text', text: `current model: ${model}\nknown: ${known}\nusage: /model <id>`, role: 'assistant' });
588
619
  }
589
620
  else {
590
621
  queuedStatus({ model: next });
@@ -593,6 +624,404 @@ export async function startRepl(opts = {}) {
593
624
  }
594
625
  return;
595
626
  }
627
+ case 'provider': {
628
+ const next = cmd.provider?.trim().toLowerCase();
629
+ if (!next) {
630
+ queuedAppend({ id: `prov-${Date.now()}`, kind: 'text', text: `current provider: ${currentProvider}\nbaseURL: ${currentBaseUrl}\nusage: /provider <openai|anthropic>`, role: 'assistant' });
631
+ }
632
+ else if (next !== 'openai' && next !== 'anthropic') {
633
+ queuedAppend({ id: `prov-err-${Date.now()}`, kind: 'error', message: `unknown provider: ${next} (expected openai|anthropic)` });
634
+ }
635
+ else {
636
+ if (next === 'anthropic' && !currentApiKey) {
637
+ queuedAppend({ id: `prov-warn-${Date.now()}`, kind: 'text', text: 'warning: no API key set — anthropic adapter may 401. Set KLYRO_API_KEY or use /login.', role: 'assistant' });
638
+ }
639
+ currentProvider = next;
640
+ adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
641
+ queuedAppend({ id: `prov2-${Date.now()}`, kind: 'text', text: `provider switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
642
+ }
643
+ return;
644
+ }
645
+ case 'effort': {
646
+ const level = cmd.level?.trim().toLowerCase();
647
+ if (!level) {
648
+ queuedAppend({ id: `eff-${Date.now()}`, kind: 'text', text: `current effort: ${effortLevel} (${currentMaxSteps} steps)\nlevels: low (10) | medium (30) | high (50) | max (100)\nusage: /effort <level>`, role: 'assistant' });
649
+ }
650
+ else if (!EFFORT_STEPS[level]) {
651
+ queuedAppend({ id: `eff-err-${Date.now()}`, kind: 'error', message: `unknown effort: ${level} (expected low|medium|high|max)` });
652
+ }
653
+ else {
654
+ effortLevel = level;
655
+ currentMaxSteps = EFFORT_STEPS[level];
656
+ queuedStatus({ maxSteps: currentMaxSteps });
657
+ queuedAppend({ id: `eff2-${Date.now()}`, kind: 'text', text: `effort set to ${level} (${currentMaxSteps} max steps)`, role: 'assistant' });
658
+ }
659
+ return;
660
+ }
661
+ case 'login': {
662
+ const { runLogin } = await import('./auth.js');
663
+ const code = await runLogin();
664
+ queuedAppend({ id: `login-${Date.now()}`, kind: 'text', text: code === 0 ? 'login saved (0600)' : 'login failed', role: 'assistant' });
665
+ return;
666
+ }
667
+ case 'logout': {
668
+ const { runLogout } = await import('./auth.js');
669
+ const code = await runLogout();
670
+ queuedAppend({ id: `logout-${Date.now()}`, kind: 'text', text: code === 0 ? 'logged out' : 'logout failed', role: 'assistant' });
671
+ return;
672
+ }
673
+ case 'init': {
674
+ const { writeFileSync, existsSync } = await import('node:fs');
675
+ const { join } = await import('node:path');
676
+ const target = join(cwd, 'KLYRO.md');
677
+ if (existsSync(target)) {
678
+ queuedAppend({ id: `init-${Date.now()}`, kind: 'text', text: `KLYRO.md already exists at ${target}`, role: 'assistant' });
679
+ }
680
+ else {
681
+ try {
682
+ const { runScan } = await import('./scan.js');
683
+ let out = '';
684
+ const orig = process.stdout.write.bind(process.stdout);
685
+ process.stdout.write = ((c) => { out += String(c); return true; });
686
+ await runScan({ cwd, json: false });
687
+ process.stdout.write = orig;
688
+ writeFileSync(target, `# KLYRO.md\n\nProject: ${cwd}\n\n## Stack\n\n${out.slice(0, 2000)}\n\n## Conventions\n\n- Prefer smallest change that solves the task.\n- Run verification after edits.\n`);
689
+ queuedAppend({ id: `init2-${Date.now()}`, kind: 'text', text: `created ${target}`, role: 'assistant' });
690
+ }
691
+ catch (err) {
692
+ queuedAppend({ id: `init-err-${Date.now()}`, kind: 'error', message: `init failed: ${err instanceof Error ? err.message : String(err)}` });
693
+ }
694
+ }
695
+ return;
696
+ }
697
+ case 'plan': {
698
+ if (cmd.task) {
699
+ queuedAppend({ id: `plan-mode-${Date.now()}`, kind: 'text', text: `Plan mode: "${cmd.task}" — the agent will plan first (todo_write) before editing. Current plan below:`, role: 'assistant' });
700
+ }
701
+ try {
702
+ const { readFileSync, existsSync } = await import('node:fs');
703
+ const { join } = await import('node:path');
704
+ const todosPath = join(cwd, '.klyro', 'plans', 'todos.json');
705
+ if (!existsSync(todosPath)) {
706
+ queuedAppend({ id: `plan-${Date.now()}`, kind: 'text', text: 'No active plan (no .klyro/plans/todos.json). The agent creates one via todo_write when planning.', role: 'assistant' });
707
+ }
708
+ else {
709
+ const raw = readFileSync(todosPath, 'utf-8').slice(0, 2000);
710
+ queuedAppend({ id: `plan2-${Date.now()}`, kind: 'text', text: `Plan (todos.json):\n${raw}`, role: 'assistant' });
711
+ }
712
+ }
713
+ catch (err) {
714
+ queuedAppend({ id: `plan-err-${Date.now()}`, kind: 'error', message: `plan failed: ${err instanceof Error ? err.message : String(err)}` });
715
+ }
716
+ return;
717
+ }
718
+ case 'todos': {
719
+ try {
720
+ const { readFileSync, existsSync } = await import('node:fs');
721
+ const { join } = await import('node:path');
722
+ const todosPath = join(cwd, '.klyro', 'plans', 'todos.json');
723
+ if (!existsSync(todosPath)) {
724
+ queuedAppend({ id: `todos-${Date.now()}`, kind: 'text', text: 'No todos (no .klyro/plans/todos.json yet).', role: 'assistant' });
725
+ }
726
+ else {
727
+ const arr = JSON.parse(readFileSync(todosPath, 'utf-8'));
728
+ const lines = arr.map((t) => `${t.status === 'done' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]'} ${t.title} (${t.id})`);
729
+ queuedAppend({ id: `todos2-${Date.now()}`, kind: 'text', text: `Todos (${arr.length}):\n${lines.join('\n').slice(0, 3000)}`, role: 'assistant' });
730
+ }
731
+ }
732
+ catch (err) {
733
+ queuedAppend({ id: `todos-err-${Date.now()}`, kind: 'error', message: `todos failed: ${err instanceof Error ? err.message : String(err)}` });
734
+ }
735
+ return;
736
+ }
737
+ case 'new': {
738
+ queuedClear();
739
+ sessionLabel = '';
740
+ currentBranch = '';
741
+ try {
742
+ const rec = await tuiStore.create({ cwd, task: 'new session', config: { model, maxSteps: currentMaxSteps } });
743
+ tuiSessionId = rec.id;
744
+ queuedAppend({ id: `new-${Date.now()}`, kind: 'text', text: `new session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
745
+ }
746
+ catch {
747
+ queuedAppend({ id: `new2-${Date.now()}`, kind: 'text', text: 'new session started', role: 'assistant' });
748
+ }
749
+ queuedStatus({ status: 'idle', step: 0 });
750
+ return;
751
+ }
752
+ case 'models': {
753
+ const { MODEL_REGISTRY } = await import('../providers/model-info.js');
754
+ const lines = Object.values(MODEL_REGISTRY).map((m) => ` ${m.id} ctx ${(m.contextWindow / 1000).toFixed(0)}k $${m.inputPricePer1k}/$${m.outputPricePer1k} per 1k`);
755
+ queuedAppend({ id: `models-${Date.now()}`, kind: 'text', text: `Available models (current: ${model}):\n${lines.join('\n')}\nusage: /model <id>`, role: 'assistant' });
756
+ return;
757
+ }
758
+ case 'fast': {
759
+ const s = cmd.state?.trim().toLowerCase();
760
+ if (!s) {
761
+ queuedAppend({ id: `fast-${Date.now()}`, kind: 'text', text: `fast mode: ${fastMode ? 'on' : 'off'} (${currentMaxSteps} steps)\nusage: /fast on|off`, role: 'assistant' });
762
+ }
763
+ else if (s === 'on') {
764
+ fastMode = true;
765
+ currentMaxSteps = 10;
766
+ queuedStatus({ maxSteps: currentMaxSteps });
767
+ queuedAppend({ id: `fast2-${Date.now()}`, kind: 'text', text: 'fast mode on (10 max steps)', role: 'assistant' });
768
+ }
769
+ else if (s === 'off') {
770
+ fastMode = false;
771
+ currentMaxSteps = EFFORT_STEPS[effortLevel];
772
+ queuedStatus({ maxSteps: currentMaxSteps });
773
+ queuedAppend({ id: `fast3-${Date.now()}`, kind: 'text', text: `fast mode off (restored ${effortLevel}: ${currentMaxSteps} steps)`, role: 'assistant' });
774
+ }
775
+ else {
776
+ queuedAppend({ id: `fast-err-${Date.now()}`, kind: 'error', message: `unknown /fast value: ${s} (expected on|off)` });
777
+ }
778
+ return;
779
+ }
780
+ case 'permissions': {
781
+ const cfg = policy.config;
782
+ const lines = [
783
+ `mode: ${displayMode} (engine: ${String(cfg.mode ?? 'default')})`,
784
+ `shellAllow: ${(cfg.shellAllow ?? []).length} prefixes`,
785
+ `shellDeny: ${(cfg.shellDeny ?? []).length} patterns`,
786
+ `allow: ${JSON.stringify(cfg.allow ?? [])}`,
787
+ `deny: ${JSON.stringify(cfg.deny ?? [])}`,
788
+ `ask: ${JSON.stringify(cfg.ask ?? [])}`,
789
+ `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}`,
790
+ ];
791
+ queuedAppend({ id: `perm-${Date.now()}`, kind: 'text', text: `Permissions:\n${lines.join('\n')}\nchange via /mode <manual|accept-edits|plan|auto|yolo>`, role: 'assistant' });
792
+ return;
793
+ }
794
+ case 'mode': {
795
+ const m = cmd.mode?.trim().toLowerCase();
796
+ if (!m) {
797
+ queuedAppend({ id: `mode-${Date.now()}`, kind: 'text', text: `current mode: ${displayMode}\nmodes: manual | accept-edits | plan | auto | yolo\nusage: /mode <mode>`, role: 'assistant' });
798
+ }
799
+ else {
800
+ const map = { manual: 'default', 'accept-edits': 'accept-edits', plan: 'plan', auto: 'auto', yolo: 'auto' };
801
+ const engineMode = map[m];
802
+ if (!engineMode) {
803
+ queuedAppend({ id: `mode-err-${Date.now()}`, kind: 'error', message: `unknown mode: ${m} (expected manual|accept-edits|plan|auto|yolo)` });
804
+ }
805
+ else {
806
+ policy.config.mode = engineMode;
807
+ displayMode = m;
808
+ queuedAppend({ id: `mode2-${Date.now()}`, kind: 'text', text: `mode set to ${m}${m === 'yolo' ? ' (auto-approve everything — careful)' : ''}${m === 'plan' ? ' (writes blocked)' : ''}`, role: 'assistant' });
809
+ }
810
+ }
811
+ return;
812
+ }
813
+ case 'sandbox': {
814
+ const cfg = policy.config;
815
+ const p = cmd.policy?.trim();
816
+ if (!p) {
817
+ queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (adds an allowed directory)`, role: 'assistant' });
818
+ }
819
+ else {
820
+ cfg.additionalDirs = [...(cfg.additionalDirs ?? []), p];
821
+ queuedAppend({ id: `sb2-${Date.now()}`, kind: 'text', text: `sandbox: added allowed dir ${p}`, role: 'assistant' });
822
+ }
823
+ return;
824
+ }
825
+ case 'approve': {
826
+ const ok = tuiBridge.resolve('allow');
827
+ queuedAppend({ id: `appr-${Date.now()}`, kind: 'text', text: ok ? 'approved pending action' : 'no pending approval', role: 'assistant' });
828
+ return;
829
+ }
830
+ case 'deny': {
831
+ const ok = tuiBridge.resolve('deny');
832
+ queuedAppend({ id: `deny-${Date.now()}`, kind: 'text', text: ok ? 'denied pending action' : 'no pending approval', role: 'assistant' });
833
+ return;
834
+ }
835
+ case 'resume': {
836
+ try {
837
+ const { resolveSessionId } = await import('../persistence/session.js');
838
+ const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
839
+ const full = cmd.id ? await resolveSessionId(tuiStore, cmd.id) : all[0]?.id;
840
+ if (!full) {
841
+ queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: cmd.id ? `session not found: ${cmd.id}` : 'no previous session in this cwd' });
842
+ return;
843
+ }
844
+ const rec = await tuiStore.get(full);
845
+ const msgs = await tuiStore.loadMessages(full);
846
+ tuiSessionId = full;
847
+ sessionLabel = rec?.task.slice(0, 40) ?? '';
848
+ queuedAppend({ id: `resume-${Date.now()}`, kind: 'text', text: `resumed session ${full.slice(0, 8)} — "${rec?.task}" (${msgs.length} messages, status ${rec?.status})`, role: 'assistant' });
849
+ queuedStatus({ status: 'idle' });
850
+ }
851
+ catch (err) {
852
+ queuedAppend({ id: `resume-err2-${Date.now()}`, kind: 'error', message: `resume failed: ${err instanceof Error ? err.message : String(err)}` });
853
+ }
854
+ return;
855
+ }
856
+ case 'sessions': {
857
+ try {
858
+ const { formatSession } = await import('../persistence/session.js');
859
+ const all = (await tuiStore.list()).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 20);
860
+ if (all.length === 0) {
861
+ queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: 'No sessions yet.', role: 'assistant' });
862
+ }
863
+ else {
864
+ const lines = all.map((r) => `${r.id.slice(0, 8) === tuiSessionId?.slice(0, 8) ? '*' : ' '} ${formatSession(r)}`);
865
+ queuedAppend({ id: `sess2-${Date.now()}`, kind: 'text', text: `Sessions (* = current):\n${lines.join('\n')}\nusage: /resume <id>`, role: 'assistant' });
866
+ }
867
+ }
868
+ catch (err) {
869
+ queuedAppend({ id: `sess-err-${Date.now()}`, kind: 'error', message: `sessions failed: ${err instanceof Error ? err.message : String(err)}` });
870
+ }
871
+ return;
872
+ }
873
+ case 'rename': {
874
+ if (!cmd.name) {
875
+ queuedAppend({ id: `ren-${Date.now()}`, kind: 'text', text: `current session label: ${sessionLabel || '(none)'}\nusage: /rename <name>`, role: 'assistant' });
876
+ }
877
+ else {
878
+ sessionLabel = cmd.name;
879
+ queuedAppend({ id: `ren2-${Date.now()}`, kind: 'text', text: `session renamed to "${cmd.name}"`, role: 'assistant' });
880
+ }
881
+ return;
882
+ }
883
+ case 'fork': {
884
+ try {
885
+ const base = sessionLabel || 'session';
886
+ const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
887
+ tuiSessionId = rec.id;
888
+ queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
889
+ }
890
+ catch (err) {
891
+ queuedAppend({ id: `fork-err-${Date.now()}`, kind: 'error', message: `fork failed: ${err instanceof Error ? err.message : String(err)}` });
892
+ }
893
+ return;
894
+ }
895
+ case 'branch': {
896
+ if (!cmd.name) {
897
+ queuedAppend({ id: `br-${Date.now()}`, kind: 'text', text: `current branch: ${currentBranch || '(none)'}\nusage: /branch <name>`, role: 'assistant' });
898
+ }
899
+ else {
900
+ currentBranch = cmd.name;
901
+ queuedAppend({ id: `br2-${Date.now()}`, kind: 'text', text: `branch "${cmd.name}" created — conversation continues here`, role: 'assistant' });
902
+ }
903
+ return;
904
+ }
905
+ case 'export': {
906
+ try {
907
+ const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
908
+ const target = tuiSessionId ?? all[0]?.id;
909
+ if (!target) {
910
+ queuedAppend({ id: `exp-err-${Date.now()}`, kind: 'error', message: 'nothing to export (no session)' });
911
+ return;
912
+ }
913
+ const rec = await tuiStore.get(target);
914
+ const msgs = await tuiStore.loadMessages(target);
915
+ const out = cmd.file ?? `${target.slice(0, 8)}.export.json`;
916
+ await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs }, null, 2), 'utf-8');
917
+ queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: `exported ${target.slice(0, 8)} → ${out} (${msgs.length} messages)`, role: 'assistant' });
918
+ }
919
+ catch (err) {
920
+ queuedAppend({ id: `exp-err2-${Date.now()}`, kind: 'error', message: `export failed: ${err instanceof Error ? err.message : String(err)}` });
921
+ }
922
+ return;
923
+ }
924
+ case 'copy': {
925
+ if (!lastAssistantText) {
926
+ queuedAppend({ id: `copy-err-${Date.now()}`, kind: 'error', message: 'nothing to copy yet (no assistant response this session)' });
927
+ return;
928
+ }
929
+ const n = cmd.n ? parseInt(cmd.n, 10) : NaN;
930
+ const text = Number.isFinite(n) && n > 0 ? lastAssistantText.slice(0, n) : lastAssistantText;
931
+ try {
932
+ const { execSync } = await import('node:child_process');
933
+ const clip = process.platform === 'win32' ? 'clip' : process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
934
+ execSync(clip, { input: text });
935
+ queuedAppend({ id: `copy-${Date.now()}`, kind: 'text', text: `copied ${text.length} chars to clipboard`, role: 'assistant' });
936
+ }
937
+ catch {
938
+ queuedAppend({ id: `copy2-${Date.now()}`, kind: 'text', text: `clipboard unavailable — response preview (${text.length} chars):\n${text.slice(0, 500)}`, role: 'assistant' });
939
+ }
940
+ return;
941
+ }
942
+ case 'auth': {
943
+ const { getStoredKey } = await import('./auth.js');
944
+ const rows = ['openai', 'anthropic'].map((p) => {
945
+ const hasFile = !!getStoredKey(p);
946
+ const hasEnv = !!(p === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY) || !!process.env.KLYRO_API_KEY;
947
+ return ` ${p}: ${hasFile ? 'stored key (0600)' : hasEnv ? 'env key' : '—'}`;
948
+ });
949
+ queuedAppend({ id: `auth-${Date.now()}`, kind: 'text', text: `Auth:\n${rows.join('\n')}\ncurrent provider: ${currentProvider}\nmanage via /login /logout`, role: 'assistant' });
950
+ return;
951
+ }
952
+ case 'update': {
953
+ const { runUpdate } = await import('./update.js');
954
+ const origWrite = process.stdout.write.bind(process.stdout);
955
+ let out = '';
956
+ process.stdout.write = ((chunk) => { out += String(chunk); return true; });
957
+ await runUpdate();
958
+ process.stdout.write = origWrite;
959
+ queuedAppend({ id: `upd-${Date.now()}`, kind: 'text', text: out || 'update check done', role: 'assistant' });
960
+ return;
961
+ }
962
+ case 'cancel': {
963
+ ac.abort();
964
+ ac = new AbortController();
965
+ queuedStatus({ status: 'aborted' });
966
+ queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: 'cancelled current operation', role: 'assistant' });
967
+ return;
968
+ }
969
+ case 'shell': {
970
+ if (!cmd.command) {
971
+ queuedAppend({ id: `sh-${Date.now()}`, kind: 'text', text: 'usage: /shell <command> (or !<command>)', role: 'assistant' });
972
+ return;
973
+ }
974
+ try {
975
+ const r = await registry.execute('shell_exec', { command: cmd.command }, { cwd, env: process.env, nonInteractive: true });
976
+ if (!r.ok) {
977
+ queuedAppend({ id: `sh-err-${Date.now()}`, kind: 'error', message: `shell failed: ${r.error.message ?? r.error.code}` });
978
+ }
979
+ else {
980
+ const v = r.value;
981
+ const body = (v.stdout + (v.stderr ? `\n[stderr]\n${v.stderr}` : '')).slice(0, 4000) || '(no output)';
982
+ queuedAppend({ id: `sh2-${Date.now()}`, kind: 'text', text: `$ ${cmd.command}\nexit ${v.exitCode}\n${body}`, role: 'assistant' });
983
+ }
984
+ }
985
+ catch (err) {
986
+ queuedAppend({ id: `sh-err2-${Date.now()}`, kind: 'error', message: `shell failed: ${err instanceof Error ? err.message : String(err)}` });
987
+ }
988
+ return;
989
+ }
990
+ case 'mention': {
991
+ if (!cmd.path) {
992
+ queuedAppend({ id: `men-${Date.now()}`, kind: 'text', text: 'usage: /mention <path> (or @<path>)', role: 'assistant' });
993
+ return;
994
+ }
995
+ try {
996
+ const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
997
+ if (!r.ok) {
998
+ queuedAppend({ id: `men-err-${Date.now()}`, kind: 'error', message: `mention failed: ${r.error.message ?? r.error.code}` });
999
+ }
1000
+ else {
1001
+ const v = r.value;
1002
+ const body = String(v.content ?? v.text ?? JSON.stringify(v)).slice(0, 6000);
1003
+ queuedAppend({ id: `men2-${Date.now()}`, kind: 'text', text: `attached ${cmd.path} (${body.length} chars):\n${body}`, role: 'assistant' });
1004
+ }
1005
+ }
1006
+ catch (err) {
1007
+ queuedAppend({ id: `men-err2-${Date.now()}`, kind: 'error', message: `mention failed: ${err instanceof Error ? err.message : String(err)}` });
1008
+ }
1009
+ return;
1010
+ }
1011
+ case 'tools': {
1012
+ const lines = registry.list().map((t) => ` ${t.name} — ${t.description.slice(0, 80)}`);
1013
+ queuedAppend({ id: `tools-${Date.now()}`, kind: 'text', text: `Tools (${lines.length}):\n${lines.join('\n')}`, role: 'assistant' });
1014
+ return;
1015
+ }
1016
+ case 'settings': {
1017
+ const { getConfigPath } = await import('./config.js');
1018
+ queuedAppend({ id: `set-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()} (alias of /config)`, role: 'assistant' });
1019
+ return;
1020
+ }
1021
+ case 'prompt': {
1022
+ // Regular prompts never reach onSlash — no-op for exhaustiveness.
1023
+ return;
1024
+ }
596
1025
  case 'unknown':
597
1026
  queuedAppend({
598
1027
  id: `unk-${Date.now()}`,
@@ -16,11 +16,25 @@
16
16
  */
17
17
  export type SlashCommand = {
18
18
  kind: 'clear';
19
+ } | {
20
+ kind: 'new';
19
21
  } | {
20
22
  kind: 'compact';
23
+ focus?: string;
21
24
  } | {
22
25
  kind: 'model';
23
26
  model: string;
27
+ } | {
28
+ kind: 'models';
29
+ } | {
30
+ kind: 'provider';
31
+ provider: string;
32
+ } | {
33
+ kind: 'effort';
34
+ level: string;
35
+ } | {
36
+ kind: 'fast';
37
+ state: string;
24
38
  } | {
25
39
  kind: 'diff';
26
40
  } | {
@@ -29,6 +43,9 @@ export type SlashCommand = {
29
43
  kind: 'rewind';
30
44
  } | {
31
45
  kind: 'plan';
46
+ task?: string;
47
+ } | {
48
+ kind: 'todos';
32
49
  } | {
33
50
  kind: 'status';
34
51
  } | {
@@ -37,10 +54,14 @@ export type SlashCommand = {
37
54
  kind: 'help';
38
55
  } | {
39
56
  kind: 'config';
57
+ } | {
58
+ kind: 'settings';
40
59
  } | {
41
60
  kind: 'doctor';
42
61
  } | {
43
62
  kind: 'version';
63
+ } | {
64
+ kind: 'update';
44
65
  } | {
45
66
  kind: 'cost';
46
67
  } | {
@@ -56,8 +77,56 @@ export type SlashCommand = {
56
77
  } | {
57
78
  kind: 'context';
58
79
  } | {
59
- kind: 'compact';
60
- focus?: string;
80
+ kind: 'login';
81
+ } | {
82
+ kind: 'logout';
83
+ } | {
84
+ kind: 'auth';
85
+ } | {
86
+ kind: 'init';
87
+ } | {
88
+ kind: 'cancel';
89
+ } | {
90
+ kind: 'shell';
91
+ command: string;
92
+ } | {
93
+ kind: 'mention';
94
+ path: string;
95
+ } | {
96
+ kind: 'tools';
97
+ } | {
98
+ kind: 'permissions';
99
+ } | {
100
+ kind: 'mode';
101
+ mode: string;
102
+ } | {
103
+ kind: 'sandbox';
104
+ policy: string;
105
+ } | {
106
+ kind: 'approve';
107
+ } | {
108
+ kind: 'deny';
109
+ } | {
110
+ kind: 'resume';
111
+ id?: string;
112
+ } | {
113
+ kind: 'sessions';
114
+ sub?: string;
115
+ } | {
116
+ kind: 'rename';
117
+ name: string;
118
+ } | {
119
+ kind: 'fork';
120
+ prompt?: string;
121
+ } | {
122
+ kind: 'branch';
123
+ name: string;
124
+ } | {
125
+ kind: 'export';
126
+ file?: string;
127
+ } | {
128
+ kind: 'copy';
129
+ n?: string;
61
130
  } | {
62
131
  kind: 'prompt';
63
132
  text: string;
@@ -14,9 +14,16 @@
14
14
  * Anything not starting with "/" is a regular prompt and yields
15
15
  * { kind: 'prompt', text }.
16
16
  */
17
- const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'verify', 'project', 'context', 'compact', 'exit', 'clear'];
17
+ const KNOWN = ['help', 'clear', 'new', 'exit', 'quit', 'q', 'compact', 'resume', 'sessions', 'rename', 'fork', 'branch', 'export', 'copy', 'model', 'm', 'models', 'provider', 'p', 'effort', 'e', 'fast', 'init', 'status', 'context', 'diff', 'plan', 'todos', 'memory', 'permissions', 'mode', 'sandbox', 'approve', 'deny', 'login', 'logout', 'auth', 'version', 'update', 'cancel', 'shell', 'mention', 'tools', 'config', 'settings', 'doctor', 'version', 'cost', 'thinking', 'jobs', 'verify', 'project', 'undo', 'rewind'];
18
18
  export function parse(input) {
19
19
  const trimmed = input.trim();
20
+ // `!cmd` alias for /shell, `@path` alias for /mention (commands.md P1)
21
+ if (trimmed.startsWith('!')) {
22
+ return { kind: 'shell', command: trimmed.slice(1).trim() };
23
+ }
24
+ if (trimmed.startsWith('@')) {
25
+ return { kind: 'mention', path: trimmed.slice(1).trim() };
26
+ }
20
27
  if (!trimmed.startsWith('/')) {
21
28
  return { kind: 'prompt', text: trimmed };
22
29
  }
@@ -25,11 +32,13 @@ export function parse(input) {
25
32
  const rest = space === -1 ? '' : trimmed.slice(space + 1).trim();
26
33
  switch (name) {
27
34
  case 'clear': return { kind: 'clear' };
28
- case 'compact': return { kind: 'compact' };
35
+ case 'new': return { kind: 'new' };
36
+ case 'compact': return { kind: 'compact', focus: rest || undefined };
29
37
  case 'diff': return { kind: 'diff' };
30
38
  case 'undo': return { kind: 'undo' };
31
39
  case 'rewind': return { kind: 'rewind' };
32
- case 'plan': return { kind: 'plan' };
40
+ case 'plan': return { kind: 'plan', task: rest || undefined };
41
+ case 'todos': return { kind: 'todos' };
33
42
  case 'status': return { kind: 'status' };
34
43
  case 'cost': return { kind: 'cost' };
35
44
  case 'thinking': return { kind: 'thinking' };
@@ -38,7 +47,33 @@ export function parse(input) {
38
47
  case 'verify': return { kind: 'verify' };
39
48
  case 'project': return { kind: 'project' };
40
49
  case 'context': return { kind: 'context' };
41
- case 'compact': return { kind: 'compact', focus: rest || undefined };
50
+ case 'login': return { kind: 'login' };
51
+ case 'logout': return { kind: 'logout' };
52
+ case 'auth': return { kind: 'auth' };
53
+ case 'init': return { kind: 'init' };
54
+ case 'cancel':
55
+ case 'interrupt': return { kind: 'cancel' };
56
+ case 'shell': return { kind: 'shell', command: rest };
57
+ case 'mention': return { kind: 'mention', path: rest };
58
+ case 'tools': return { kind: 'tools' };
59
+ case 'permissions': return { kind: 'permissions' };
60
+ case 'mode': return { kind: 'mode', mode: rest };
61
+ case 'sandbox': return { kind: 'sandbox', policy: rest };
62
+ case 'approve': return { kind: 'approve' };
63
+ case 'deny': return { kind: 'deny' };
64
+ case 'resume': return { kind: 'resume', id: rest || undefined };
65
+ case 'sessions': return { kind: 'sessions', sub: rest || undefined };
66
+ case 'rename':
67
+ case 'title': return { kind: 'rename', name: rest };
68
+ case 'fork': return { kind: 'fork', prompt: rest || undefined };
69
+ case 'branch': return { kind: 'branch', name: rest };
70
+ case 'export': return { kind: 'export', file: rest || undefined };
71
+ case 'copy': return { kind: 'copy', n: rest || undefined };
72
+ case 'update':
73
+ case 'upgrade': return { kind: 'update' };
74
+ case 'models': return { kind: 'models' };
75
+ case 'fast': return { kind: 'fast', state: rest };
76
+ case 'settings': return { kind: 'settings' };
42
77
  case 'quit':
43
78
  case 'exit':
44
79
  case 'q': return { kind: 'quit' };
@@ -47,10 +82,16 @@ export function parse(input) {
47
82
  case 'config': return { kind: 'config' };
48
83
  case 'doctor': return { kind: 'doctor' };
49
84
  case 'version': return { kind: 'version' };
85
+ case 'provider':
86
+ case 'p': {
87
+ return { kind: 'provider', provider: rest };
88
+ }
89
+ case 'effort':
90
+ case 'e': {
91
+ return { kind: 'effort', level: rest };
92
+ }
50
93
  case 'model':
51
94
  case 'm': {
52
- if (!rest)
53
- return { kind: 'unknown', raw: trimmed };
54
95
  return { kind: 'model', model: rest };
55
96
  }
56
97
  default: return { kind: 'unknown', raw: trimmed };
package/dist/tui/app.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface AppProps {
21
21
  appendDelta: (text: string) => void;
22
22
  updateStatus: (s: Partial<StatusSnapshot>) => void;
23
23
  updatePlan: (p: PlanStep[]) => void;
24
+ clearTranscript: () => void;
24
25
  }) => void;
25
26
  version?: string;
26
27
  isFullscreen?: boolean;
package/dist/tui/app.js CHANGED
@@ -233,9 +233,10 @@ export function App(props) {
233
233
  streamingIdRef.current = null; }, [status.status]);
234
234
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
235
235
  const updatePlan = useCallback((p) => setPlan(p), []);
236
+ const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
236
237
  const onMountedRef = useRef(props.onMounted);
237
238
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
238
- useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan]);
239
+ useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript]);
239
240
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
240
241
  n.delete(id);
241
242
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",