actoviq-agent-sdk 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README-zh.md +15 -2
  2. package/README.md +20 -7
  3. package/dist/src/cli/actoviq-react.js +178 -3
  4. package/dist/src/cli/actoviq-react.js.map +1 -1
  5. package/dist/src/cli/actoviq-tui.js +2 -2
  6. package/dist/src/index.d.ts +17 -0
  7. package/dist/src/index.d.ts.map +1 -1
  8. package/dist/src/index.js +17 -0
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/router/modelRouter.d.ts +46 -0
  11. package/dist/src/router/modelRouter.d.ts.map +1 -0
  12. package/dist/src/router/modelRouter.js +227 -0
  13. package/dist/src/router/modelRouter.js.map +1 -0
  14. package/dist/src/runtime/actoviqSkills.d.ts +6 -0
  15. package/dist/src/runtime/actoviqSkills.d.ts.map +1 -1
  16. package/dist/src/runtime/actoviqSkills.js +69 -1
  17. package/dist/src/runtime/actoviqSkills.js.map +1 -1
  18. package/dist/src/runtime/agentClient.d.ts +16 -0
  19. package/dist/src/runtime/agentClient.d.ts.map +1 -1
  20. package/dist/src/runtime/agentClient.js +89 -9
  21. package/dist/src/runtime/agentClient.js.map +1 -1
  22. package/dist/src/runtime/conversationEngine.d.ts +2 -0
  23. package/dist/src/runtime/conversationEngine.d.ts.map +1 -1
  24. package/dist/src/runtime/conversationEngine.js +6 -5
  25. package/dist/src/runtime/conversationEngine.js.map +1 -1
  26. package/dist/src/storage/sessionStore.d.ts.map +1 -1
  27. package/dist/src/storage/sessionStore.js +25 -6
  28. package/dist/src/storage/sessionStore.js.map +1 -1
  29. package/dist/src/team/agentPool.d.ts +26 -0
  30. package/dist/src/team/agentPool.d.ts.map +1 -0
  31. package/dist/src/team/agentPool.js +97 -0
  32. package/dist/src/team/agentPool.js.map +1 -0
  33. package/dist/src/team/modelTeam.d.ts +56 -0
  34. package/dist/src/team/modelTeam.d.ts.map +1 -0
  35. package/dist/src/team/modelTeam.js +542 -0
  36. package/dist/src/team/modelTeam.js.map +1 -0
  37. package/dist/src/team/pricing.d.ts +15 -0
  38. package/dist/src/team/pricing.d.ts.map +1 -0
  39. package/dist/src/team/pricing.js +80 -0
  40. package/dist/src/team/pricing.js.map +1 -0
  41. package/dist/src/team/teamDefinitions.d.ts +28 -0
  42. package/dist/src/team/teamDefinitions.d.ts.map +1 -0
  43. package/dist/src/team/teamDefinitions.js +151 -0
  44. package/dist/src/team/teamDefinitions.js.map +1 -0
  45. package/dist/src/tools/actoviqCoreTools.d.ts.map +1 -1
  46. package/dist/src/tools/actoviqCoreTools.js +6 -1
  47. package/dist/src/tools/actoviqCoreTools.js.map +1 -1
  48. package/dist/src/tools/enterWorktree.d.ts +11 -0
  49. package/dist/src/tools/enterWorktree.d.ts.map +1 -0
  50. package/dist/src/tools/enterWorktree.js +79 -0
  51. package/dist/src/tools/enterWorktree.js.map +1 -0
  52. package/dist/src/tools/exitWorktree.d.ts +4 -0
  53. package/dist/src/tools/exitWorktree.d.ts.map +1 -0
  54. package/dist/src/tools/exitWorktree.js +48 -0
  55. package/dist/src/tools/exitWorktree.js.map +1 -0
  56. package/dist/src/tools/tavilySearch.d.ts +12 -0
  57. package/dist/src/tools/tavilySearch.d.ts.map +1 -0
  58. package/dist/src/tools/tavilySearch.js +139 -0
  59. package/dist/src/tools/tavilySearch.js.map +1 -0
  60. package/dist/src/tui/actoviqTui.d.ts +10 -0
  61. package/dist/src/tui/actoviqTui.d.ts.map +1 -1
  62. package/dist/src/tui/actoviqTui.js +606 -35
  63. package/dist/src/tui/actoviqTui.js.map +1 -1
  64. package/dist/src/tui/editor.d.ts +2 -0
  65. package/dist/src/tui/editor.d.ts.map +1 -1
  66. package/dist/src/tui/editor.js +5 -0
  67. package/dist/src/tui/editor.js.map +1 -1
  68. package/dist/src/types.d.ts +323 -0
  69. package/dist/src/types.d.ts.map +1 -1
  70. package/dist/src/types.js.map +1 -1
  71. package/dist/src/workflow/workflowPersistence.d.ts +34 -0
  72. package/dist/src/workflow/workflowPersistence.d.ts.map +1 -0
  73. package/dist/src/workflow/workflowPersistence.js +144 -0
  74. package/dist/src/workflow/workflowPersistence.js.map +1 -0
  75. package/dist/src/workflow/workflowScriptRuntime.d.ts +22 -0
  76. package/dist/src/workflow/workflowScriptRuntime.d.ts.map +1 -0
  77. package/dist/src/workflow/workflowScriptRuntime.js +655 -0
  78. package/dist/src/workflow/workflowScriptRuntime.js.map +1 -0
  79. package/dist/src/worktree/worktreeHooks.d.ts +38 -0
  80. package/dist/src/worktree/worktreeHooks.d.ts.map +1 -0
  81. package/dist/src/worktree/worktreeHooks.js +82 -0
  82. package/dist/src/worktree/worktreeHooks.js.map +1 -0
  83. package/dist/src/worktree/worktreeInclude.d.ts +11 -0
  84. package/dist/src/worktree/worktreeInclude.d.ts.map +1 -0
  85. package/dist/src/worktree/worktreeInclude.js +65 -0
  86. package/dist/src/worktree/worktreeInclude.js.map +1 -0
  87. package/dist/src/worktree/worktreeService.d.ts +43 -0
  88. package/dist/src/worktree/worktreeService.d.ts.map +1 -0
  89. package/dist/src/worktree/worktreeService.js +356 -0
  90. package/dist/src/worktree/worktreeService.js.map +1 -0
  91. package/package.json +2 -24
@@ -6,12 +6,14 @@
6
6
  * rendering (no React/Ink).
7
7
  */
8
8
  import { execSync } from 'node:child_process';
9
+ import fs from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import * as readline from 'node:readline';
11
- import { createActoviqCoreTools, createAgentSdk, loadDefaultActoviqSettings, loadJsonConfigFile, } from '../index.js';
12
+ import { createActoviqCoreTools, createAgentSdk, loadDefaultActoviqSettings, loadJsonConfigFile, listWorkflows, loadWorkflow, listTeamDefinitions, loadTeamDefinition, createModelTeam, createTeamTool, listRouterProfiles, loadRouterProfile, resolveRoutedRun, WorktreeService, } from '../index.js';
12
13
  import { persistActoviqSettingsStore, resolveActoviqSettingsStore, } from '../config/actoviqSettingsStore.js';
13
14
  import { isRecord } from '../runtime/helpers.js';
14
- import { A, stringWidth, truncateToWidth } from './ansi.js';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { A, stringWidth, truncateToWidth, wrapToWidth } from './ansi.js';
15
17
  import { InputEditor } from './editor.js';
16
18
  import { discoverActoviqPlugins } from './pluginCatalog.js';
17
19
  import { TuiScreen } from './screen.js';
@@ -49,6 +51,9 @@ export const TUI_SLASH_COMMANDS = {
49
51
  mcp: 'Inspect MCP servers and tools',
50
52
  plugins: 'Browse discovered Clean plugins',
51
53
  dream: 'Inspect or run memory consolidation',
54
+ workflows: 'Browse saved dynamic workflows',
55
+ worktree: 'Enter, exit, or list git worktrees',
56
+ team: 'List or run Model Team definitions',
52
57
  exit: 'Quit',
53
58
  };
54
59
  export function filterSlashCommands(input) {
@@ -60,6 +65,56 @@ export function filterSlashCommands(input) {
60
65
  const partial = head.toLowerCase();
61
66
  return Object.keys(TUI_SLASH_COMMANDS).filter((name) => name.startsWith(partial));
62
67
  }
68
+ /**
69
+ * Detect an active "@file" mention at the cursor for path completion. Returns
70
+ * the partial token typed after the '@' plus the '@' offset, or null when the
71
+ * cursor is not inside a mention. The '@' only opens a mention at the start of
72
+ * input or after whitespace, and the token ends at the first whitespace.
73
+ */
74
+ export function activeAtToken(text, cursor) {
75
+ for (let i = cursor - 1; i >= 0; i -= 1) {
76
+ const ch = text[i];
77
+ if (/\s/.test(ch))
78
+ return null;
79
+ if (ch === '@') {
80
+ if (i === 0 || /\s/.test(text[i - 1])) {
81
+ return { token: text.slice(i + 1, cursor), start: i };
82
+ }
83
+ return null;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Render free-form text for the scrollback: width-aware word wrapping with
90
+ * markdown-lite heading highlighting. Used for workflow/team results and the
91
+ * expert-panel member reports so long output reads cleanly instead of dumping
92
+ * raw lines. Optionally caps very long output with a "… (N more lines)" note.
93
+ */
94
+ function renderRichText(text, width, opts = {}) {
95
+ const cols = Math.max(20, width - 2);
96
+ const out = [];
97
+ for (const raw of text.replace(/\r/g, '').split('\n')) {
98
+ const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
99
+ if (heading) {
100
+ out.push(`${A.bold}${A.cyan}${heading[2]}${A.reset}`);
101
+ continue;
102
+ }
103
+ if (raw.trim() === '') {
104
+ out.push('');
105
+ continue;
106
+ }
107
+ for (const line of wrapToWidth(raw, cols))
108
+ out.push(line);
109
+ }
110
+ const maxLines = opts.maxLines ?? 0;
111
+ if (maxLines > 0 && out.length > maxLines) {
112
+ const kept = out.slice(0, maxLines);
113
+ kept.push(`${A.dim}… (${out.length - maxLines} more lines)${A.reset}`);
114
+ return kept;
115
+ }
116
+ return out;
117
+ }
63
118
  function buildSystemPrompt(workDir) {
64
119
  let isGit = false;
65
120
  try {
@@ -133,11 +188,20 @@ export async function runActoviqTui(options = {}) {
133
188
  let running = false;
134
189
  let commandBusy = false;
135
190
  let shuttingDown = false;
191
+ // Active team tool the main agent may call (toggled via /team). null = no team.
192
+ let activeTeamTool = null;
193
+ let activeTeamName = null;
194
+ // /model router: when set, each user turn is classified and routed to a model.
195
+ let activeRouter = null;
196
+ let routedModelLabel = null;
136
197
  let abortCtrl = null;
137
198
  let dialog = null;
138
199
  let selectionDialog = null;
139
200
  let textInputDialog = null;
140
201
  let menuSelected = 0;
202
+ // @-mention file completion: highlighted candidate + lazily-cached file list.
203
+ let atSelected = 0;
204
+ let workspaceFiles = null;
141
205
  let spinnerFrame = 0;
142
206
  let spinnerTimer = null;
143
207
  let dynamicRenderTimer = null;
@@ -302,8 +366,9 @@ export async function runActoviqTui(options = {}) {
302
366
  const lines = [];
303
367
  lines.push(promptDivider());
304
368
  if (editor.isEmpty()) {
305
- const placeholder = truncateToWidth('Try "write a test for <filepath>"', editorWidth - 2);
306
- lines.push(`${A.magenta}${PROMPT_GLYPH}${A.reset} ${A.dim}${placeholder}${A.reset}`);
369
+ // Always-visible block caret on the empty input so the box reads as active.
370
+ const placeholder = truncateToWidth('Try "write a test for <filepath>"', editorWidth - 4);
371
+ lines.push(`${A.magenta}${PROMPT_GLYPH}${A.reset} ${A.inverse} ${A.reset} ${A.dim}${placeholder}${A.reset}`);
307
372
  }
308
373
  else {
309
374
  const visual = editor.visualLines(editorWidth - 1);
@@ -316,21 +381,152 @@ export async function runActoviqTui(options = {}) {
316
381
  lines.push(promptDivider());
317
382
  return lines;
318
383
  }
384
+ // ── @-mention file completion ──────────────────────────────────────
385
+ // Prefer git's view (tracked + untracked, honoring .gitignore) so the list
386
+ // matches what the agent actually sees; fall back to a bounded fs walk for
387
+ // non-git workspaces. Cached for the session and invalidated after each run.
388
+ function loadWorkspaceFiles() {
389
+ if (workspaceFiles)
390
+ return workspaceFiles;
391
+ try {
392
+ const out = execSync('git ls-files --cached --others --exclude-standard', {
393
+ cwd: workDir,
394
+ encoding: 'utf8',
395
+ maxBuffer: 32 * 1024 * 1024,
396
+ stdio: ['ignore', 'pipe', 'ignore'],
397
+ });
398
+ workspaceFiles = out.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 8000);
399
+ }
400
+ catch {
401
+ workspaceFiles = walkWorkspaceFiles(workDir, 8000);
402
+ }
403
+ return workspaceFiles;
404
+ }
405
+ function walkWorkspaceFiles(root, limit) {
406
+ const skip = new Set(['node_modules', '.git', 'dist', '.codegraph', '.next', 'coverage']);
407
+ const out = [];
408
+ const stack = [root];
409
+ while (stack.length > 0 && out.length < limit) {
410
+ const dir = stack.pop();
411
+ let entries;
412
+ try {
413
+ entries = fs.readdirSync(dir, { withFileTypes: true });
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ for (const entry of entries) {
419
+ if (entry.isDirectory()) {
420
+ if (!skip.has(entry.name) && !entry.name.startsWith('.'))
421
+ stack.push(path.join(dir, entry.name));
422
+ }
423
+ else if (entry.isFile()) {
424
+ out.push(path.relative(root, path.join(dir, entry.name)).split(path.sep).join('/'));
425
+ if (out.length >= limit)
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ return out;
431
+ }
432
+ function atCompletions(token) {
433
+ const files = loadWorkspaceFiles();
434
+ const query = token.toLowerCase();
435
+ if (!query)
436
+ return files.slice(0, 200);
437
+ const matches = files.filter((file) => file.toLowerCase().includes(query));
438
+ matches.sort((a, b) => {
439
+ const aBase = a.slice(a.lastIndexOf('/') + 1).toLowerCase();
440
+ const bBase = b.slice(b.lastIndexOf('/') + 1).toLowerCase();
441
+ const aScore = aBase.startsWith(query) ? 0 : aBase.includes(query) ? 1 : 2;
442
+ const bScore = bBase.startsWith(query) ? 0 : bBase.includes(query) ? 1 : 2;
443
+ if (aScore !== bScore)
444
+ return aScore - bScore;
445
+ return a.length - b.length;
446
+ });
447
+ return matches.slice(0, 200);
448
+ }
449
+ function buildAtMenu() {
450
+ const active = activeAtToken(editor.text, editor.cursor);
451
+ if (!active)
452
+ return [];
453
+ const matches = atCompletions(active.token);
454
+ if (matches.length === 0) {
455
+ return [`${A.dim} @${active.token} — no matching files${A.reset}`];
456
+ }
457
+ if (atSelected >= matches.length)
458
+ atSelected = matches.length - 1;
459
+ if (atSelected < 0)
460
+ atSelected = 0;
461
+ const windowStart = Math.max(0, Math.min(atSelected - MENU_MAX_ROWS + 1, matches.length - MENU_MAX_ROWS));
462
+ const visible = matches.slice(windowStart, windowStart + MENU_MAX_ROWS);
463
+ const lines = visible.map((file, i) => {
464
+ const index = windowStart + i;
465
+ const display = truncateToWidth(file, Math.max(screen.width - 6, 12));
466
+ return index === atSelected ? `${A.inverse} @${display} ${A.reset}` : ` ${A.cyan}@${display}${A.reset}`;
467
+ });
468
+ const hiddenAbove = windowStart;
469
+ const hiddenBelow = matches.length - (windowStart + visible.length);
470
+ if (hiddenAbove > 0 || hiddenBelow > 0) {
471
+ const parts = [];
472
+ if (hiddenAbove > 0)
473
+ parts.push(`↑${hiddenAbove}`);
474
+ if (hiddenBelow > 0)
475
+ parts.push(`↓${hiddenBelow}`);
476
+ lines.push(`${A.dim} ${parts.join(' ')} more · ${atSelected + 1}/${matches.length} (↑/↓ · Tab/Enter to insert)${A.reset}`);
477
+ }
478
+ return lines;
479
+ }
480
+ /** Replace the active @-token with the highlighted file path. */
481
+ function applyAtCompletion() {
482
+ const active = activeAtToken(editor.text, editor.cursor);
483
+ if (!active)
484
+ return false;
485
+ const matches = atCompletions(active.token);
486
+ if (matches.length === 0)
487
+ return false;
488
+ const file = matches[Math.min(atSelected, matches.length - 1)];
489
+ const before = editor.text.slice(0, active.start);
490
+ const after = editor.text.slice(editor.cursor);
491
+ const mention = `@${file} `;
492
+ editor.setTextWithCursor(`${before}${mention}${after}`, before.length + mention.length);
493
+ atSelected = 0;
494
+ return true;
495
+ }
319
496
  function buildMenu() {
320
497
  const matches = filterSlashCommands(editor.text);
321
498
  if (matches.length === 0)
322
499
  return [];
323
500
  if (menuSelected >= matches.length)
324
501
  menuSelected = matches.length - 1;
502
+ if (menuSelected < 0)
503
+ menuSelected = 0;
325
504
  const commandWidth = Math.min(28, Math.max(14, Math.floor(screen.width * 0.28)));
326
505
  const descriptionWidth = Math.max(screen.width - commandWidth - 4, 12);
327
- return matches.slice(0, MENU_MAX_ROWS).map((name, index) => {
506
+ // Scroll a window of MENU_MAX_ROWS so the highlighted item stays visible and
507
+ // commands past the cap (e.g. /workflows, /worktree, /team) are reachable
508
+ // with the arrow keys instead of being clipped off the bottom.
509
+ const windowStart = Math.max(0, Math.min(menuSelected - MENU_MAX_ROWS + 1, matches.length - MENU_MAX_ROWS));
510
+ const visible = matches.slice(windowStart, windowStart + MENU_MAX_ROWS);
511
+ const lines = visible.map((name, i) => {
512
+ const index = windowStart + i;
328
513
  const selected = index === menuSelected;
329
514
  const command = `/${name}`.padEnd(commandWidth);
330
515
  const label = selected ? `${A.inverse}${command}${A.reset}` : command;
331
516
  const description = truncateToWidth(TUI_SLASH_COMMANDS[name] ?? '', descriptionWidth);
332
517
  return `${label} ${A.dim}${description}${A.reset}`;
333
518
  });
519
+ const hiddenAbove = windowStart;
520
+ const hiddenBelow = matches.length - (windowStart + visible.length);
521
+ if (hiddenAbove > 0 || hiddenBelow > 0) {
522
+ const parts = [];
523
+ if (hiddenAbove > 0)
524
+ parts.push(`↑${hiddenAbove}`);
525
+ if (hiddenBelow > 0)
526
+ parts.push(`↓${hiddenBelow}`);
527
+ lines.push(`${A.dim} ${parts.join(' ')} more · ${menuSelected + 1}/${matches.length} (↑/↓ to scroll)${A.reset}`);
528
+ }
529
+ return lines;
334
530
  }
335
531
  function buildDialog() {
336
532
  if (!dialog)
@@ -405,25 +601,48 @@ export async function runActoviqTui(options = {}) {
405
601
  lines.push(`${A.dim} enter confirm · esc cancel${textInputDialog.secret ? ' · value hidden' : ''}${A.reset}`);
406
602
  return lines;
407
603
  }
604
+ /** Friendly permission label matching the /permissions presets. */
605
+ function permissionLabel() {
606
+ const m = currentPermissionMode();
607
+ if (m === 'bypassPermissions')
608
+ return 'full-access';
609
+ if (m === 'acceptEdits')
610
+ return 'workspace';
611
+ if (m === 'default' && session.permissionContext.permissions.some((p) => p.behavior === 'deny'))
612
+ return 'read-only';
613
+ return m;
614
+ }
615
+ /** Always-visible mode + live context-usage line (usage shown as % of the window). */
616
+ function buildModeLine() {
617
+ const used = lastTokenEstimate ?? 0;
618
+ const window = sdk.config.compact?.contextWindowTokens ?? 200_000;
619
+ const pct = window > 0 ? Math.min(100, Math.round((used / window) * 100)) : 0;
620
+ const usedK = used >= 1000 ? `${(used / 1000).toFixed(used >= 100_000 ? 0 : 1)}k` : `${used}`;
621
+ const ctxColor = pct >= 90 ? A.red : pct >= 70 ? A.yellow : A.dim;
622
+ const modelLabel = activeRouter
623
+ ? `router:${activeRouter.name}${routedModelLabel ? ` → ${routedModelLabel}` : ''}`
624
+ : session.model;
625
+ const left = `${modelLabel} · ${permissionLabel()} · effort:${currentEffort() ?? 'auto'} · team:${activeTeamName ?? 'none'} · `;
626
+ return `${A.dim} ${left}${A.reset}${ctxColor}ctx ${pct}% (${usedK})${A.reset}`;
627
+ }
408
628
  function buildStatusLine() {
629
+ const modeLine = buildModeLine();
409
630
  if (!running)
410
- return [];
631
+ return [modeLine];
411
632
  const elapsed = Math.max(Math.round((Date.now() - runStartedAt) / 1000), 0);
412
633
  const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
413
- const context = lastTokenEstimate && lastTokenEstimate >= 1000
414
- ? ` · ~${Math.round(lastTokenEstimate / 1000)}k ctx`
415
- : '';
416
634
  const note = statusNote ? ` · ${statusNote}` : '';
417
635
  const queued = queuedInputs.length > 0 ? ` · ${queuedInputs.length} queued` : '';
418
636
  return [
419
- `${A.cyan}${frame}${A.reset} ${A.bold}Working…${A.reset}${A.dim} (${elapsed}s · ${runToolCount} tool${runToolCount === 1 ? '' : 's'}${context}${note}${queued} · esc to interrupt)${A.reset}`,
637
+ `${A.cyan}${frame}${A.reset} ${A.bold}Working…${A.reset}${A.dim} (${elapsed}s · ${runToolCount} tool${runToolCount === 1 ? '' : 's'}${note}${queued} · esc to interrupt)${A.reset}`,
638
+ modeLine,
420
639
  ];
421
640
  }
422
641
  function buildHintLine() {
423
642
  if (running) {
424
643
  return [`${A.dim} enter to queue a steering message · esc interrupt · ctrl+c twice to exit${A.reset}`];
425
644
  }
426
- return [`${A.dim} ? for shortcuts · / for commands · \\↵ newline · ↑↓ history · ctrl+c clear/exit${A.reset}`];
645
+ return [`${A.dim} ? shortcuts · / commands · @ files · \\↵ newline · ↑↓ history · ctrl+c clear/exit${A.reset}`];
427
646
  }
428
647
  function renderDynamic() {
429
648
  const lines = [];
@@ -443,8 +662,14 @@ export async function runActoviqTui(options = {}) {
443
662
  }
444
663
  else {
445
664
  lines.push(...buildPromptBar());
446
- const menu = buildMenu();
447
- lines.push(...(menu.length > 0 ? menu : buildHintLine()));
665
+ const atMenu = buildAtMenu();
666
+ if (atMenu.length > 0) {
667
+ lines.push(...atMenu);
668
+ }
669
+ else {
670
+ const menu = buildMenu();
671
+ lines.push(...(menu.length > 0 ? menu : buildHintLine()));
672
+ }
448
673
  }
449
674
  screen.setDynamic(lines);
450
675
  }
@@ -466,6 +691,55 @@ export async function runActoviqTui(options = {}) {
466
691
  screen.appendStatic(lines);
467
692
  }
468
693
  // ── Agent run ──────────────────────────────────────────────────────
694
+ // /model router: pick a saved router profile (or turn routing off). When
695
+ // active, each user turn is classified and routed to a model in startRun().
696
+ async function chooseRouter(arg) {
697
+ const turnOff = () => {
698
+ activeRouter = null;
699
+ routedModelLabel = null;
700
+ appendStatic([...formatInfoLine('router off — using the fixed model'), '']);
701
+ };
702
+ if (arg === 'off' || arg === 'none') {
703
+ turnOff();
704
+ return;
705
+ }
706
+ if (arg) {
707
+ const found = loadRouterProfile(arg, sdk.config.workDir);
708
+ if (!found) {
709
+ appendStatic([...formatErrorLine(`router profile not found: ${arg}`), '']);
710
+ return;
711
+ }
712
+ activeRouter = found.profile;
713
+ routedModelLabel = null;
714
+ appendStatic([...formatInfoLine(`router active: ${found.profile.name} — each turn is classified by ${found.profile.routerModel.model} and routed`), '']);
715
+ return;
716
+ }
717
+ const profiles = listRouterProfiles(sdk.config.workDir);
718
+ if (profiles.length === 0) {
719
+ appendStatic([
720
+ ...formatInfoLine('no router profiles found. Create one at ~/.actoviq/routers/<name>.json (routerModel + routes:[{ when, model, provider?, baseURL?, apiKey? }] + fallback).'),
721
+ '',
722
+ ]);
723
+ return;
724
+ }
725
+ const items = [
726
+ { id: '__off__', label: activeRouter ? `Turn router off (active: ${activeRouter.name})` : 'Router off (current)', description: 'use the fixed model for every turn' },
727
+ ...profiles.map((p) => ({ id: `profile:${p.name}`, label: p.name, description: `${p.profile.routes.length} routes · classifier ${p.profile.routerModel.model} · ${p.source}` })),
728
+ ];
729
+ const choice = await selectItem({ title: 'Model router', subtitle: 'classify each turn and route to a model (may be cross-provider)', items });
730
+ if (!choice)
731
+ return;
732
+ if (choice === '__off__') {
733
+ turnOff();
734
+ return;
735
+ }
736
+ const found = loadRouterProfile(choice.slice('profile:'.length), sdk.config.workDir);
737
+ if (found) {
738
+ activeRouter = found.profile;
739
+ routedModelLabel = null;
740
+ appendStatic([...formatInfoLine(`router active: ${found.profile.name} — turns routed by ${found.profile.routerModel.model}`), '']);
741
+ }
742
+ }
469
743
  async function startRun(text) {
470
744
  running = true;
471
745
  runStartedAt = Date.now();
@@ -480,6 +754,20 @@ export async function runActoviqTui(options = {}) {
480
754
  }, 120);
481
755
  appendStatic(formatUserPrompt(text));
482
756
  renderDynamic();
757
+ // /model router: classify this turn and route it to the chosen model
758
+ // (possibly on a different provider). The turn then runs normally.
759
+ let routed;
760
+ if (activeRouter) {
761
+ try {
762
+ const decision = await resolveRoutedRun(activeRouter, text, abortCtrl.signal);
763
+ routed = { model: decision.model, modelApi: decision.modelApi };
764
+ routedModelLabel = `${decision.label} (${decision.model})`;
765
+ appendStatic(formatInfoLine(`router → ${routedModelLabel}`));
766
+ }
767
+ catch (error) {
768
+ appendStatic(formatInfoLine(`router classification failed (${error.message}); using ${session.model}`));
769
+ }
770
+ }
483
771
  try {
484
772
  const stream = session.stream(text, {
485
773
  systemPrompt,
@@ -487,6 +775,8 @@ export async function runActoviqTui(options = {}) {
487
775
  permissionMode: currentPermissionMode(),
488
776
  effort: currentEffort(),
489
777
  approver,
778
+ ...(routed ? { model: routed.model, modelApi: routed.modelApi } : {}),
779
+ ...(activeTeamTool ? { tools: [...tools, activeTeamTool] } : {}),
490
780
  ...(canUseTool ? { canUseTool } : {}),
491
781
  drainQueuedInputs: () => {
492
782
  const drained = queuedInputs.splice(0);
@@ -523,6 +813,7 @@ export async function runActoviqTui(options = {}) {
523
813
  finally {
524
814
  running = false;
525
815
  abortCtrl = null;
816
+ workspaceFiles = null; // the agent may have created/removed files — refresh @-completion
526
817
  if (spinnerTimer) {
527
818
  clearInterval(spinnerTimer);
528
819
  spinnerTimer = null;
@@ -619,7 +910,7 @@ export async function runActoviqTui(options = {}) {
619
910
  clear: '/clear',
620
911
  compact: '/compact [summary instructions]',
621
912
  memory: '/memory',
622
- model: '/model [model|min|medium|max|default|config]',
913
+ model: '/model [model|min|medium|max|default|config|router]',
623
914
  effort: '/effort [low|medium|high|max|auto]',
624
915
  permissions: '/permissions [default|acceptEdits|plan|bypassPermissions|auto]',
625
916
  sessions: '/sessions',
@@ -630,6 +921,9 @@ export async function runActoviqTui(options = {}) {
630
921
  mcp: '/mcp',
631
922
  plugins: '/plugins',
632
923
  dream: '/dream [run|status]',
924
+ workflows: '/workflows [run <name>|list]',
925
+ worktree: '/worktree [enter <name>|exit|list]',
926
+ team: '/team [ask <name> <prompt>|list]',
633
927
  exit: '/exit',
634
928
  };
635
929
  return usage[name] ?? `/${name}`;
@@ -884,15 +1178,17 @@ export async function runActoviqTui(options = {}) {
884
1178
  title: 'Skills',
885
1179
  items: skills.map(skill => ({
886
1180
  id: skill.name,
887
- label: skill.name,
888
- description: `${skill.source} · ${skill.context}`,
1181
+ label: skill.displayName ? `${skill.displayName} (${skill.name})` : skill.name,
1182
+ description: `${skill.source} · ${skill.context}${skill.version ? ` · v${skill.version}` : ''}`,
889
1183
  detail: `${skill.description} ${skill.whenToUse ?? ''}`,
890
1184
  })),
891
1185
  });
892
1186
  const skill = skills.find(item => item.name === selected);
893
1187
  if (skill) {
1188
+ const heading = skill.displayName ? `${skill.displayName} (/${skill.name})` : `/${skill.name}`;
1189
+ const ver = skill.version ? ` v${skill.version}` : '';
894
1190
  appendStatic([
895
- `${A.cyan}/${skill.name}${A.reset} ${skill.description}`,
1191
+ `${A.cyan}${heading}${A.reset}${A.dim}${ver}${A.reset} ${skill.description}`,
896
1192
  `${A.dim}${skill.whenToUse ?? `source: ${skill.source} · context: ${skill.context}`}${A.reset}`,
897
1193
  '',
898
1194
  ]);
@@ -1059,6 +1355,10 @@ export async function runActoviqTui(options = {}) {
1059
1355
  await configureModelSettings();
1060
1356
  return;
1061
1357
  }
1358
+ if (args === 'router' || args.startsWith('router ')) {
1359
+ await chooseRouter(args.slice('router'.length).trim());
1360
+ return;
1361
+ }
1062
1362
  await session.setModel(args === 'default' ? sdk.config.model : args);
1063
1363
  appendStatic([...formatInfoLine(`model set to: ${session.model}`), '']);
1064
1364
  return;
@@ -1070,25 +1370,45 @@ export async function runActoviqTui(options = {}) {
1070
1370
  await setEffort(args.toLowerCase());
1071
1371
  return;
1072
1372
  case 'permissions': {
1073
- const requested = args;
1074
- if (!requested) {
1075
- const state = session.permissionContext;
1076
- appendStatic([
1077
- ...formatInfoLine(`permissions: ${state.mode ?? permissionMode} · ${state.permissions.length} session rules`),
1078
- '',
1079
- ]);
1080
- return;
1373
+ // Three presets, selectable or named directly:
1374
+ // read-only → deny mutating tools (read / search / web only)
1375
+ // workspace → acceptEdits (auto-accept edits in the workspace)
1376
+ // full → bypassPermissions (no prompts)
1377
+ const READONLY_DENY = ['Bash', 'Write', 'Edit', 'NotebookEdit', 'PowerShell'];
1378
+ const presets = {
1379
+ 'read-only': {
1380
+ mode: 'default',
1381
+ rules: READONLY_DENY.map((t) => ({ toolName: t, behavior: 'deny', source: 'permissions-preset' })),
1382
+ label: 'Read-only',
1383
+ },
1384
+ workspace: { mode: 'acceptEdits', rules: [], label: 'Workspace access' },
1385
+ full: { mode: 'bypassPermissions', rules: [], label: 'Full access' },
1386
+ };
1387
+ let key = args.trim().toLowerCase().replace(/[ _]/g, '-');
1388
+ if (!key) {
1389
+ const choice = await selectItem({
1390
+ title: 'Permission mode',
1391
+ subtitle: `current: ${session.permissionContext.mode ?? permissionMode}`,
1392
+ items: [
1393
+ { id: 'read-only', label: 'Read-only', description: 'Read, search, and web only — deny Write/Edit/Bash/NotebookEdit/PowerShell' },
1394
+ { id: 'workspace', label: 'Workspace access', description: 'Auto-accept edits in the workspace (acceptEdits)' },
1395
+ { id: 'full', label: 'Full access', description: 'No prompts — run any tool (bypassPermissions)' },
1396
+ ],
1397
+ });
1398
+ if (!choice)
1399
+ return;
1400
+ key = choice;
1081
1401
  }
1082
- if (!PERMISSION_MODES.has(requested)) {
1083
- appendStatic([...formatErrorLine(`unknown permission mode: ${requested}`), '']);
1402
+ const preset = presets[key];
1403
+ if (!preset) {
1404
+ appendStatic([...formatErrorLine(`unknown permission preset: ${key} (read-only | workspace | full)`), '']);
1084
1405
  return;
1085
1406
  }
1086
- await session.setPermissionContext({
1087
- mode: requested,
1088
- permissions: session.permissionContext.permissions,
1089
- approver,
1090
- });
1091
- appendStatic([...formatInfoLine(`permissions set to: ${requested}`), '']);
1407
+ await session.setPermissionContext({ mode: preset.mode, permissions: preset.rules, approver });
1408
+ appendStatic([
1409
+ ...formatInfoLine(`permissions: ${preset.label} — ${preset.mode}${preset.rules.length ? ` · ${preset.rules.length} deny rules` : ''}`),
1410
+ '',
1411
+ ]);
1092
1412
  return;
1093
1413
  }
1094
1414
  case 'sessions': {
@@ -1169,6 +1489,227 @@ export async function runActoviqTui(options = {}) {
1169
1489
  case 'plugins':
1170
1490
  await showPlugins();
1171
1491
  return;
1492
+ // ── v0.5.0: Dynamic Workflows ────────────────────────────
1493
+ case 'workflows': {
1494
+ const runSavedWorkflow = async (wfName, wfTask) => {
1495
+ const wf = loadWorkflow(wfName, sdk.config.workDir);
1496
+ if (!wf) {
1497
+ appendStatic([...formatErrorLine(`workflow not found: ${wfName}`), '']);
1498
+ return;
1499
+ }
1500
+ appendStatic([
1501
+ ...formatInfoLine(`running workflow: ${wfName}`),
1502
+ ...formatInfoLine(`phases: ${wf.meta?.phases?.map((p) => p.title).join(', ') ?? 'none'}`),
1503
+ '',
1504
+ ]);
1505
+ try {
1506
+ const { WorkflowScriptRuntime } = await import('../workflow/workflowScriptRuntime.js');
1507
+ const runtime = new WorkflowScriptRuntime({
1508
+ sdk: sdk,
1509
+ args: wfTask,
1510
+ onEvent: (e) => {
1511
+ if (e.type === 'workflow.phase.start') {
1512
+ appendStatic([`${A.bold}${A.magenta}▶ ${e.title}${A.reset}`]);
1513
+ }
1514
+ else if (e.type === 'workflow.agent.start') {
1515
+ appendStatic([`${A.dim} ⚡ ${e.label ?? e.agentId}${e.cached ? ' (cached)' : ''}${A.reset}`]);
1516
+ }
1517
+ else if (e.type === 'workflow.agent.done') {
1518
+ const secs = e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : '';
1519
+ appendStatic([`${A.dim} ✓ ${e.label ?? e.agentId}${secs}${A.reset}`]);
1520
+ }
1521
+ else if (e.type === 'workflow.log') {
1522
+ appendStatic([`${A.dim} │ ${e.message}${A.reset}`]);
1523
+ }
1524
+ else if (e.type === 'workflow.script.done') {
1525
+ const secs = e.durationMs ? ` · ${Math.round(e.durationMs / 1000)}s` : '';
1526
+ appendStatic([
1527
+ `${A.green}✓ workflow done${A.reset}${A.dim} · ${e.agentCount} agents · ${e.totalTokens} tokens${secs}${A.reset}`,
1528
+ '',
1529
+ ]);
1530
+ }
1531
+ },
1532
+ });
1533
+ const output = await runtime.execute(wf.script);
1534
+ if (typeof output.result === 'string' && output.result.trim()) {
1535
+ appendStatic([...formatInfoLine('workflow result:'), ...renderRichText(output.result, screen.width), '']);
1536
+ }
1537
+ if (output.state.errors.length > 0) {
1538
+ appendStatic([...formatErrorLine(`${output.state.errors.length} errors during workflow execution`), '']);
1539
+ }
1540
+ }
1541
+ catch (error) {
1542
+ appendStatic([...formatErrorLine(`workflow error: ${error.message}`), '']);
1543
+ }
1544
+ };
1545
+ if (args.startsWith('run ')) {
1546
+ const runRest = args.slice(4).trim();
1547
+ const runSpace = runRest.indexOf(' ');
1548
+ await runSavedWorkflow(runSpace === -1 ? runRest : runRest.slice(0, runSpace), runSpace === -1 ? undefined : runRest.slice(runSpace + 1).trim());
1549
+ return;
1550
+ }
1551
+ // No sub-command → selection picker.
1552
+ const saved = listWorkflows(sdk.config.workDir);
1553
+ const items = [
1554
+ ...saved.map((w) => ({
1555
+ id: `run:${w.name}`,
1556
+ label: w.name,
1557
+ description: `${w.source} · ${w.description}`.slice(0, 80),
1558
+ })),
1559
+ {
1560
+ id: '__orchestrate__',
1561
+ label: '+ ask the agent to orchestrate a new workflow',
1562
+ description: 'describe a task in the prompt box; the agent designs & runs a workflow, then you can save it',
1563
+ },
1564
+ ];
1565
+ const choice = await selectItem({
1566
+ title: 'Workflows',
1567
+ subtitle: 'run a saved workflow, or have the agent build a new one',
1568
+ items,
1569
+ });
1570
+ if (!choice)
1571
+ return;
1572
+ if (choice.startsWith('run:')) {
1573
+ const name = choice.slice('run:'.length);
1574
+ const task = await promptText({ title: `Run /${name}`, label: 'Task / input (optional — Enter to skip)' });
1575
+ await runSavedWorkflow(name, task && task.trim() ? task.trim() : undefined);
1576
+ }
1577
+ else if (choice === '__orchestrate__') {
1578
+ appendStatic([
1579
+ ...formatInfoLine('Type your task in the prompt box and ask: "orchestrate a workflow to <task>".'),
1580
+ `${A.dim}After it runs and works, ask me to save it as a reusable workflow.${A.reset}`,
1581
+ '',
1582
+ ]);
1583
+ }
1584
+ return;
1585
+ }
1586
+ // ── v0.5.0: Worktrees ────────────────────────────────────
1587
+ case 'worktree': {
1588
+ const ws = new WorktreeService(sdk.config.workDir);
1589
+ if (args === 'list') {
1590
+ await ws.init();
1591
+ const trees = await ws.listWorktrees();
1592
+ if (trees.length === 0) {
1593
+ appendStatic([...formatInfoLine('no worktrees'), '']);
1594
+ }
1595
+ else {
1596
+ appendStatic([
1597
+ ...trees.map((t) => `${A.dim}${t.path}${A.reset} · ${t.isDirty ? `${A.yellow}dirty${A.reset}` : `${A.green}clean${A.reset}`}`),
1598
+ '',
1599
+ ]);
1600
+ }
1601
+ return;
1602
+ }
1603
+ if (args === 'exit') {
1604
+ try {
1605
+ ws.exitWorktree();
1606
+ appendStatic([...formatInfoLine(`exited worktree, cwd: ${ws.currentWorkDir}`), '']);
1607
+ }
1608
+ catch (error) {
1609
+ appendStatic([...formatErrorLine(error.message), '']);
1610
+ }
1611
+ return;
1612
+ }
1613
+ if (args.startsWith('enter ')) {
1614
+ const wfName = args.slice(6).trim();
1615
+ try {
1616
+ await ws.init();
1617
+ await ws.createAndEnterWorktree({ name: wfName });
1618
+ appendStatic([...formatInfoLine(`entered worktree: ${wfName} (${ws.currentWorkDir})`), '']);
1619
+ }
1620
+ catch (error) {
1621
+ appendStatic([...formatErrorLine(error.message), '']);
1622
+ }
1623
+ return;
1624
+ }
1625
+ appendStatic([...formatInfoLine('usage: /worktree [enter <name>|exit|list]'), '']);
1626
+ return;
1627
+ }
1628
+ // ── v0.5.0: Model Team ───────────────────────────────────
1629
+ case 'team': {
1630
+ if (args.startsWith('ask ')) {
1631
+ const rest = args.slice(4).trim();
1632
+ const spaceIdx = rest.indexOf(' ');
1633
+ if (spaceIdx === -1) {
1634
+ appendStatic([...formatErrorLine('usage: /team ask <name> <prompt>'), '']);
1635
+ return;
1636
+ }
1637
+ const teamName = rest.slice(0, spaceIdx);
1638
+ const prompt = rest.slice(spaceIdx + 1).trim();
1639
+ const loaded = loadTeamDefinition(teamName, sdk.config.workDir);
1640
+ if (!loaded) {
1641
+ appendStatic([...formatErrorLine(`team not found: ${teamName}`), '']);
1642
+ return;
1643
+ }
1644
+ appendStatic([
1645
+ ...formatInfoLine(`asking team "${teamName}" (${loaded.definition.mode} mode)`),
1646
+ `${A.dim}convening: ${loaded.definition.members?.map((m) => m.model).join(', ') ?? 'configured members'}${A.reset}`,
1647
+ '',
1648
+ ]);
1649
+ try {
1650
+ const team = createModelTeam(loaded.definition);
1651
+ const result = await team.ask(prompt);
1652
+ appendStatic([
1653
+ `${A.green}✓ team response${A.reset}${A.dim} · ${result.mode} · ${Math.round(result.durationMs / 1000)}s${A.reset}`,
1654
+ `${A.dim}cost: ${result.cost.estimatedCost !== null ? `$${result.cost.estimatedCost.toFixed(4)}` : 'N/A'} · ${result.cost.totalInputTokens + result.cost.totalOutputTokens} tokens${A.reset}`,
1655
+ '',
1656
+ ...renderRichText(result.answer, screen.width),
1657
+ '',
1658
+ ]);
1659
+ }
1660
+ catch (error) {
1661
+ appendStatic([...formatErrorLine(`team error: ${error.message}`), '']);
1662
+ }
1663
+ return;
1664
+ }
1665
+ // No sub-command → picker that toggles which team the agent may call.
1666
+ const member = (sp) => ({ model: session.model, systemPrompt: sp });
1667
+ const buildDefault = (mode) => {
1668
+ switch (mode) {
1669
+ case 'panel-analysis':
1670
+ return { name: 'panel-analysis', mode: 'panel-analysis', members: [member('Expert researcher. Investigate with read-only tools; cite sources.'), member('Rigorous skeptic. Verify with sources; challenge assumptions.')], primary: member('Synthesizer. Reconcile the panel findings into the best answer and decide when they suffice.'), timeoutMs: 300000, maxIterations: 12 };
1671
+ case 'analysis':
1672
+ return { name: 'analysis-panel', mode: 'analysis', members: [member('Expert researcher. Deep, source-grounded analysis.'), member('Rigorous skeptic. Verify with sources; challenge assumptions.')], timeoutMs: 300000, maxIterations: 12 };
1673
+ case 'reviewer':
1674
+ return { name: 'reviewer', mode: 'reviewer', members: [], reviewer: member('Meticulous reviewer. Surface only genuine, verifiable issues with file:line evidence; never speculate.'), timeoutMs: 300000, maxIterations: 16 };
1675
+ default:
1676
+ return undefined;
1677
+ }
1678
+ };
1679
+ const saved = listTeamDefinitions(sdk.config.workDir);
1680
+ const items = [
1681
+ { id: '__none__', label: activeTeamTool ? `No team — remove "${activeTeamName}"` : 'No team (individual) — current', description: 'the agent works solo, no team tool attached' },
1682
+ ...saved.map((t) => ({ id: `saved:${t.name}`, label: t.name, description: `saved · ${t.definition.mode} · ${t.definition.members?.length ?? 0} members` })),
1683
+ ...['panel-analysis', 'analysis', 'reviewer'].map((m) => ({ id: `mode:${m}`, label: `+ new ${m} team`, description: `built-in ${m} mode · default ${session.model} members` })),
1684
+ ];
1685
+ const choice = await selectItem({ title: 'Team', subtitle: 'attach a team the agent can call as a tool, or remove it', items });
1686
+ if (!choice)
1687
+ return;
1688
+ if (choice === '__none__') {
1689
+ activeTeamTool = null;
1690
+ activeTeamName = null;
1691
+ appendStatic([...formatInfoLine('team: none — the agent works individually'), '']);
1692
+ return;
1693
+ }
1694
+ let def;
1695
+ if (choice.startsWith('saved:'))
1696
+ def = loadTeamDefinition(choice.slice('saved:'.length), sdk.config.workDir)?.definition;
1697
+ else if (choice.startsWith('mode:'))
1698
+ def = buildDefault(choice.slice('mode:'.length));
1699
+ if (!def) {
1700
+ appendStatic([...formatErrorLine('could not load team definition'), '']);
1701
+ return;
1702
+ }
1703
+ try {
1704
+ activeTeamTool = createTeamTool(def);
1705
+ activeTeamName = def.name;
1706
+ appendStatic([...formatInfoLine(`team active: ${def.name} (${def.mode}) — the agent can now call "${def.name}" as a tool when it helps`), '']);
1707
+ }
1708
+ catch (error) {
1709
+ appendStatic([...formatErrorLine(`team error: ${error.message}`), '']);
1710
+ }
1711
+ return;
1712
+ }
1172
1713
  default:
1173
1714
  appendStatic([...formatErrorLine(`unknown command: /${name} — type /help`), '']);
1174
1715
  return;
@@ -1181,6 +1722,10 @@ export async function runActoviqTui(options = {}) {
1181
1722
  }
1182
1723
  // ── Submit / key handling ──────────────────────────────────────────
1183
1724
  async function submit() {
1725
+ if (!running && applyAtCompletion()) {
1726
+ renderDynamic();
1727
+ return;
1728
+ }
1184
1729
  const matches = filterSlashCommands(editor.text);
1185
1730
  if (matches.length > 0 && !running) {
1186
1731
  const selected = matches[Math.min(menuSelected, matches.length - 1)];
@@ -1469,6 +2014,7 @@ export async function runActoviqTui(options = {}) {
1469
2014
  case 'backspace':
1470
2015
  editor.backspace();
1471
2016
  menuSelected = 0;
2017
+ atSelected = 0;
1472
2018
  break;
1473
2019
  case 'delete':
1474
2020
  editor.deleteForward();
@@ -1492,8 +2038,13 @@ export async function runActoviqTui(options = {}) {
1492
2038
  editor.moveEnd();
1493
2039
  break;
1494
2040
  case 'up': {
2041
+ const atToken = activeAtToken(editor.text, editor.cursor);
2042
+ const atCount = atToken ? atCompletions(atToken.token).length : 0;
1495
2043
  const menu = filterSlashCommands(editor.text);
1496
- if (menu.length > 0) {
2044
+ if (atCount > 0) {
2045
+ atSelected = (atSelected + atCount - 1) % atCount;
2046
+ }
2047
+ else if (menu.length > 0) {
1497
2048
  menuSelected = (menuSelected + menu.length - 1) % menu.length;
1498
2049
  }
1499
2050
  else if (!editor.onFirstLine()) {
@@ -1505,8 +2056,13 @@ export async function runActoviqTui(options = {}) {
1505
2056
  break;
1506
2057
  }
1507
2058
  case 'down': {
2059
+ const atToken = activeAtToken(editor.text, editor.cursor);
2060
+ const atCount = atToken ? atCompletions(atToken.token).length : 0;
1508
2061
  const menu = filterSlashCommands(editor.text);
1509
- if (menu.length > 0) {
2062
+ if (atCount > 0) {
2063
+ atSelected = (atSelected + 1) % atCount;
2064
+ }
2065
+ else if (menu.length > 0) {
1510
2066
  menuSelected = (menuSelected + 1) % menu.length;
1511
2067
  }
1512
2068
  else if (!editor.onLastLine()) {
@@ -1518,6 +2074,8 @@ export async function runActoviqTui(options = {}) {
1518
2074
  break;
1519
2075
  }
1520
2076
  case 'tab': {
2077
+ if (applyAtCompletion())
2078
+ break;
1521
2079
  const menu = filterSlashCommands(editor.text);
1522
2080
  if (menu.length > 0) {
1523
2081
  const selected = menu[Math.min(menuSelected, menu.length - 1)];
@@ -1535,6 +2093,7 @@ export async function runActoviqTui(options = {}) {
1535
2093
  if (cleaned) {
1536
2094
  editor.insert(cleaned);
1537
2095
  menuSelected = 0;
2096
+ atSelected = 0;
1538
2097
  }
1539
2098
  }
1540
2099
  break;
@@ -1591,4 +2150,16 @@ export async function runActoviqTui(options = {}) {
1591
2150
  // Keep the process alive until shutdown() exits it.
1592
2151
  await new Promise(() => { });
1593
2152
  }
2153
+ // Allow running this module directly (`npx tsx src/tui/actoviqTui.ts`), not only
2154
+ // via the cli/ wrapper. Requires an interactive terminal.
2155
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2156
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2157
+ process.stderr.write('actoviq TUI requires an interactive terminal (TTY). Run it directly in your terminal — not piped or through another tool.\n');
2158
+ process.exit(1);
2159
+ }
2160
+ runActoviqTui().catch((error) => {
2161
+ process.stderr.write(`Fatal: ${error.stack ?? error.message}\n`);
2162
+ process.exit(1);
2163
+ });
2164
+ }
1594
2165
  //# sourceMappingURL=actoviqTui.js.map