swarmlancer-cli 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -1,13 +1,12 @@
1
- import { ProcessTerminal, TUI, Container } from "@mariozechner/pi-tui";
1
+ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
2
2
  import { getConfig, getAgents, getAgent, saveAgent, deleteAgent, createAgent, migrateLegacyAgent, } from "./config.js";
3
3
  import { login } from "./login.js";
4
4
  import { setActiveModel, setAgentInstructions } from "./inference.js";
5
5
  import { resolveModel, getAvailableModels, isProviderAuthenticated, saveProviderKey, PROVIDERS, } from "./providers.js";
6
6
  import { startAgent, stopAgent, sendToServer } from "./agent.js";
7
7
  import { colors } from "./theme.js";
8
- import { BannerComponent } from "./screens/banner.js";
9
- import { SetupWizardScreen } from "./screens/setup-wizard.js";
10
8
  import { DashboardScreen } from "./screens/dashboard.js";
9
+ import { MenuScreen } from "./screens/menu-screen.js";
11
10
  import { AgentConfigScreen } from "./screens/agent-config.js";
12
11
  import { AgentPickerScreen } from "./screens/agent-picker.js";
13
12
  import { AgentEditorScreen } from "./screens/agent-editor.js";
@@ -16,11 +15,11 @@ import { SettingsScreen } from "./screens/settings.js";
16
15
  import { DiscoverySettingsScreen } from "./screens/discovery-settings.js";
17
16
  import { ModelPickerScreen } from "./screens/model-picker.js";
18
17
  import { NameEditorScreen } from "./screens/name-editor.js";
19
- import { SessionGoalScreen } from "./screens/session-goal.js";
20
18
  import { MessageScreen } from "./screens/message.js";
21
19
  import { screenProfiles } from "./screening.js";
22
20
  let terminal;
23
21
  let tui;
22
+ let runningAgentId; // tracks which agent is online (⚡)
24
23
  function setScreen(component) {
25
24
  tui.clear();
26
25
  tui.addChild(component);
@@ -34,69 +33,29 @@ function showMessage(title, lines, style = "info") {
34
33
  setScreen(screen);
35
34
  });
36
35
  }
37
- // ── Setup ─────────────────────────────────────────────────
38
- async function runSetup() {
36
+ function confirm(question) {
37
+ return new Promise((resolve) => {
38
+ const items = [
39
+ { value: "yes", label: "Yes" },
40
+ { value: "no", label: "No" },
41
+ ];
42
+ const screen = new MenuScreen(tui, question, items);
43
+ screen.onSelect = (v) => resolve(v === "yes");
44
+ screen.onBack = () => resolve(false);
45
+ setScreen(screen);
46
+ });
47
+ }
48
+ // ── Silent setup ──────────────────────────────────────────
49
+ async function silentSetup() {
39
50
  migrateLegacyAgent();
40
- const banner = new BannerComponent();
41
- const wizard = new SetupWizardScreen(tui);
42
- const wrapper = new Container();
43
- wrapper.addChild(banner);
44
- wrapper.addChild(wizard);
45
- tui.clear();
46
- tui.addChild(wrapper);
47
- tui.setFocus(wizard);
48
- tui.requestRender(true);
49
- // Step 1: Auth
51
+ // Auto-login if not logged in
50
52
  const config = getConfig();
51
- wizard.setStep(0, "running");
52
- if (config.token) {
53
- wizard.setStep(0, "done", "logged in");
54
- }
55
- else {
56
- wizard.setStep(0, "running", "not logged in — opening browser...");
53
+ if (!config.token) {
57
54
  try {
58
55
  await login();
59
- wizard.setStep(0, "done", "logged in");
60
- }
61
- catch (err) {
62
- wizard.setStep(0, "failed", err instanceof Error ? err.message : "login failed");
63
56
  }
57
+ catch { }
64
58
  }
65
- // Step 2: Agents
66
- wizard.setStep(1, "running");
67
- const agents = getAgents();
68
- if (agents.length > 0) {
69
- wizard.setStep(1, "done", `${agents.length} agent${agents.length === 1 ? "" : "s"}`);
70
- }
71
- else {
72
- wizard.setStep(1, "skipped", "no agents yet");
73
- const shouldCreate = await wizard.askConfirm("You have no agents. Create one now?");
74
- if (shouldCreate) {
75
- const name = await askName("New Agent Name", "Give your first agent a name.");
76
- if (name) {
77
- createAgent(name);
78
- wizard.setStep(1, "done", "1 agent created");
79
- }
80
- else {
81
- wizard.setStep(1, "skipped", "skipped");
82
- }
83
- tui.clear();
84
- tui.addChild(wrapper);
85
- tui.setFocus(wizard);
86
- tui.requestRender(true);
87
- }
88
- }
89
- // Step 3: Providers
90
- wizard.setStep(2, "running");
91
- const models = getAvailableModels();
92
- if (models.length > 0) {
93
- const providers = [...new Set(models.map((m) => m.provider))];
94
- wizard.setStep(2, "done", providers.join(", "));
95
- }
96
- else {
97
- wizard.setStep(2, "skipped", "no API keys — add in Swarm → Providers");
98
- }
99
- await new Promise((r) => setTimeout(r, 800));
100
59
  }
101
60
  // ── Sub-screens ───────────────────────────────────────────
102
61
  function askName(title, subtitle, current = "") {
@@ -165,19 +124,10 @@ function editModel(agent) {
165
124
  setScreen(screen);
166
125
  });
167
126
  }
168
- function askSessionGoal() {
169
- return new Promise((resolve) => {
170
- const screen = new SessionGoalScreen(tui);
171
- screen.onSubmit = (goal) => resolve(goal.trim());
172
- screen.onSkip = () => resolve("");
173
- setScreen(screen);
174
- });
175
- }
176
127
  function pickAgent() {
177
128
  const agents = getAgents();
178
- if (agents.length === 0) {
129
+ if (agents.length === 0)
179
130
  return showMessage("No agents", ["Create an agent first."], "error").then(() => null);
180
- }
181
131
  if (agents.length === 1)
182
132
  return Promise.resolve(agents[0]);
183
133
  return new Promise((resolve) => {
@@ -187,24 +137,6 @@ function pickAgent() {
187
137
  setScreen(screen);
188
138
  });
189
139
  }
190
- // ── Provider login ────────────────────────────────────────
191
- async function handleProviderLogin(providerId) {
192
- const provider = PROVIDERS.find((p) => p.id === providerId);
193
- if (!provider)
194
- return;
195
- if (!provider.keyBased) {
196
- await showMessage("Coming soon", [`${provider.label} is not yet supported.`], "info");
197
- return;
198
- }
199
- if (isProviderAuthenticated(provider.id)) {
200
- await showMessage("Already configured", [`${provider.label} is already logged in.`, "", "To change the API key, re-enter it below."], "info");
201
- }
202
- const key = await askName(`${provider.label}`, "Paste your API key:");
203
- if (key) {
204
- saveProviderKey(provider.id, key);
205
- await showMessage("Saved", [`${provider.label} API key saved.`], "success");
206
- }
207
- }
208
140
  // ── Agent config ──────────────────────────────────────────
209
141
  async function runAgentConfig(agentId) {
210
142
  while (true) {
@@ -218,7 +150,7 @@ async function runAgentConfig(agentId) {
218
150
  });
219
151
  switch (action) {
220
152
  case "edit-name": {
221
- const newName = await askName("Rename Agent", "Enter a new name for this agent.", agent.name);
153
+ const newName = await askName("Rename Agent", "Enter a new name.", agent.name);
222
154
  if (newName) {
223
155
  agent.name = newName;
224
156
  saveAgent(agent);
@@ -238,15 +170,114 @@ async function runAgentConfig(agentId) {
238
170
  await editModel(agent);
239
171
  break;
240
172
  case "delete": {
241
- await showMessage("Deleted", [`Agent "${agent.name}" has been deleted.`], "info");
242
- deleteAgent(agent.id);
243
- return;
173
+ const sure = await confirm(`Delete "${agent.name}"?`);
174
+ if (sure) {
175
+ deleteAgent(agent.id);
176
+ return;
177
+ }
178
+ break;
244
179
  }
245
180
  case "back": return;
246
181
  }
247
182
  }
248
183
  }
249
- // ── Running agent ─────────────────────────────────────────
184
+ // ── Swarm screen ──────────────────────────────────────────
185
+ async function runManageTop() {
186
+ while (true) {
187
+ const action = await new Promise((resolve) => {
188
+ const items = [
189
+ { value: "swarm", label: "Swarm", description: "" },
190
+ { value: "providers", label: "Providers", description: "" },
191
+ ];
192
+ const screen = new MenuScreen(tui, "Manage", items);
193
+ screen.onSelect = (v) => resolve(v);
194
+ screen.onBack = () => resolve(null);
195
+ setScreen(screen);
196
+ });
197
+ if (!action)
198
+ return;
199
+ switch (action) {
200
+ case "swarm":
201
+ await runSwarm();
202
+ break;
203
+ case "providers":
204
+ await runProviders();
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ // ── Manage screen ─────────────────────────────────────────
210
+ async function runSwarm() {
211
+ while (true) {
212
+ const agents = getAgents();
213
+ const items = [
214
+ { value: "__create__", label: "+ Create", description: "" },
215
+ ];
216
+ for (const agent of agents) {
217
+ const isRunning = agent.id === runningAgentId;
218
+ const suffix = isRunning ? " [⚡]" : "";
219
+ items.push({
220
+ value: agent.id,
221
+ label: `${agent.name}${suffix}`,
222
+ description: "",
223
+ });
224
+ }
225
+ const action = await new Promise((resolve) => {
226
+ const screen = new MenuScreen(tui, "Swarm", items);
227
+ screen.onSelect = (v) => resolve(v);
228
+ screen.onBack = () => resolve(null);
229
+ setScreen(screen);
230
+ });
231
+ if (!action)
232
+ return;
233
+ if (action === "__create__") {
234
+ const name = await askName("New Agent", "Give your agent a name.");
235
+ if (name) {
236
+ const agent = createAgent(name);
237
+ await runAgentConfig(agent.id);
238
+ }
239
+ }
240
+ else {
241
+ await runAgentConfig(action);
242
+ }
243
+ }
244
+ }
245
+ // ── Providers screen ──────────────────────────────────────
246
+ async function runProviders() {
247
+ while (true) {
248
+ const items = PROVIDERS.map((p) => {
249
+ const loggedIn = isProviderAuthenticated(p.id);
250
+ const prefix = loggedIn ? "→ " : " ";
251
+ const suffix = loggedIn ? " ✓ logged in" : "";
252
+ return {
253
+ value: p.id,
254
+ label: `${prefix}${p.label}${suffix}`,
255
+ description: "",
256
+ };
257
+ });
258
+ const action = await new Promise((resolve) => {
259
+ const screen = new MenuScreen(tui, "Providers", items);
260
+ screen.onSelect = (v) => resolve(v);
261
+ screen.onBack = () => resolve(null);
262
+ setScreen(screen);
263
+ });
264
+ if (!action)
265
+ return;
266
+ const provider = PROVIDERS.find((p) => p.id === action);
267
+ if (!provider)
268
+ continue;
269
+ if (!provider.keyBased) {
270
+ await showMessage("Coming soon", [`${provider.label} is not yet supported.`], "info");
271
+ continue;
272
+ }
273
+ const key = await askName(provider.label, "Paste your API key:");
274
+ if (key) {
275
+ saveProviderKey(provider.id, key);
276
+ await showMessage("Saved", [`${provider.label} API key saved.`], "success");
277
+ }
278
+ }
279
+ }
280
+ // ── Go online ─────────────────────────────────────────────
250
281
  async function fetchCandidates(discovery) {
251
282
  const config = getConfig();
252
283
  const params = new URLSearchParams();
@@ -265,14 +296,17 @@ async function fetchCandidates(discovery) {
265
296
  });
266
297
  if (!res.ok)
267
298
  throw new Error(`Discover API error: ${res.status}`);
268
- const data = (await res.json());
269
- return data.profiles;
299
+ return (await res.json()).profiles;
270
300
  }
271
- async function runAgentSession() {
301
+ async function goOnline() {
302
+ const conf = getConfig();
303
+ if (!conf.token) {
304
+ await showMessage("Not logged in", ["Run login first."], "error");
305
+ return;
306
+ }
272
307
  const agent = await pickAgent();
273
308
  if (!agent)
274
309
  return;
275
- // Resolve model
276
310
  const resolved = resolveModel(agent.modelPattern);
277
311
  if (!resolved) {
278
312
  await showMessage("No model", ["No provider API keys configured.", "Add one in Swarm → Providers."], "error");
@@ -280,18 +314,14 @@ async function runAgentSession() {
280
314
  }
281
315
  setActiveModel(resolved.provider, resolved.model);
282
316
  setAgentInstructions(agent.instructions);
283
- const modelInfo = {
284
- provider: resolved.provider,
285
- id: resolved.model,
286
- name: resolved.model,
287
- };
288
- const sessionGoal = "";
317
+ runningAgentId = agent.id;
318
+ const modelInfo = { provider: resolved.provider, id: resolved.model, name: resolved.model };
289
319
  const config = getConfig();
290
320
  const discovery = agent.discovery;
291
321
  const limits = agent.limits;
292
- const screen = new AgentRunningScreen(tui, modelInfo, config.serverUrl, agent.name, sessionGoal);
322
+ const screen = new AgentRunningScreen(tui, modelInfo, config.serverUrl, agent.name);
293
323
  const done = new Promise((resolve) => {
294
- screen.onStop = () => { stopAgent(); resolve(); };
324
+ screen.onStop = () => { stopAgent(); runningAgentId = undefined; resolve(); };
295
325
  });
296
326
  setScreen(screen);
297
327
  const log = (line) => screen.addLog(line);
@@ -307,7 +337,7 @@ async function runAgentSession() {
307
337
  else {
308
338
  log(colors.lime(`📡 Found ${candidates.length} candidates, screening...`));
309
339
  screen.setStatus(`screening ${candidates.length} profiles...`);
310
- const results = await screenProfiles(candidates, agent.instructions, sessionGoal, discovery.matchThreshold, discovery.maxScreenPerSession, (screened, total, result) => {
340
+ const results = await screenProfiles(candidates, agent.instructions, "", discovery.matchThreshold, discovery.maxScreenPerSession, (screened, total, result) => {
311
341
  const icon = result.score >= discovery.matchThreshold ? colors.lime("✓") : colors.gray("⊘");
312
342
  const scoreStr = result.score >= discovery.matchThreshold
313
343
  ? colors.lime(`${result.score}/10`) : colors.gray(`${result.score}/10`);
@@ -321,13 +351,12 @@ async function runAgentSession() {
321
351
  }
322
352
  else {
323
353
  log(colors.lime(`Found ${matches.length} match${matches.length === 1 ? "" : "es"}. Starting conversations...`));
324
- log("");
325
354
  for (const match of matches) {
326
355
  if (!match.profile.online) {
327
356
  log(colors.gray(` ⊘ @${match.profile.githubUsername} offline`));
328
357
  continue;
329
358
  }
330
- log(colors.lime(` 🤝 @${match.profile.githubUsername} (${match.score}/10: ${match.reason})`));
359
+ log(colors.lime(` 🤝 @${match.profile.githubUsername} (${match.score}/10)`));
331
360
  sendToServer({ type: "start_conversation", withUserId: match.profile.id });
332
361
  if (limits.cooldownSeconds > 0)
333
362
  await new Promise((r) => setTimeout(r, limits.cooldownSeconds * 1000));
@@ -345,39 +374,29 @@ async function runAgentSession() {
345
374
  // ── Dashboard loop ────────────────────────────────────────
346
375
  async function runDashboard() {
347
376
  while (true) {
348
- const agents = getAgents();
349
377
  const action = await new Promise((resolve) => {
350
- const dashboard = new DashboardScreen(tui, { agents });
378
+ const dashboard = new DashboardScreen(tui);
351
379
  dashboard.onAction = resolve;
352
380
  setScreen(dashboard);
353
381
  });
354
- switch (action.type) {
382
+ switch (action) {
355
383
  case "start": {
356
- const conf = getConfig();
357
- if (!conf.token) {
358
- await showMessage("Not logged in", ["Run login first."], "error");
359
- break;
360
- }
361
- await runAgentSession();
384
+ const sure = await confirm("Go online?");
385
+ if (sure)
386
+ await goOnline();
362
387
  break;
363
388
  }
364
- case "create-agent": {
365
- const name = await askName("New Agent", "Give your agent a name.");
366
- if (name) {
367
- const agent = createAgent(name);
368
- await runAgentConfig(agent.id);
389
+ case "manage":
390
+ await runManageTop();
391
+ break;
392
+ case "exit": {
393
+ const sure = await confirm("Exit?");
394
+ if (sure) {
395
+ shutdown();
396
+ return;
369
397
  }
370
398
  break;
371
399
  }
372
- case "edit-agent":
373
- await runAgentConfig(action.agentId);
374
- break;
375
- case "provider":
376
- await handleProviderLogin(action.providerId);
377
- break;
378
- case "quit":
379
- shutdown();
380
- return;
381
400
  }
382
401
  }
383
402
  }
@@ -397,6 +416,6 @@ export async function runInteractive() {
397
416
  }
398
417
  return undefined;
399
418
  });
400
- await runSetup();
419
+ await silentSetup();
401
420
  await runDashboard();
402
421
  }
@@ -1,13 +1,13 @@
1
1
  import { Container, Text, Spacer } from "@mariozechner/pi-tui";
2
2
  import { colors } from "../theme.js";
3
3
  const BANNER_ART = [
4
- ` █████████ ███`,
5
- ` ███ ███ ███ `,
6
- ` ███ ███ `,
7
- ` █████████ ███ `,
8
- ` ███ ███ `,
9
- ` ███ ███ ███ `,
10
- ` █████████ ███`,
4
+ ` █████████ ███`,
5
+ `███ ███ ███`,
6
+ `███ ███`,
7
+ ` █████████ ███`,
8
+ ` ███ ███`,
9
+ `███ ███ ███`,
10
+ ` █████████ ███`,
11
11
  ];
12
12
  export class BannerComponent extends Container {
13
13
  constructor() {
@@ -21,7 +21,7 @@ export class BannerComponent extends Container {
21
21
  this.addChild(new Text(colors.limeBold(` ${line}`), 1, 0));
22
22
  }
23
23
  this.addChild(new Spacer(0));
24
- this.addChild(new Text(colors.gray(" LET THE SWARM BEGIN!"), 1, 0));
24
+ this.addChild(new Text(colors.gray(" [LET THE SWARM BEGIN!]"), 1, 0));
25
25
  this.addChild(new Spacer(1));
26
26
  }
27
27
  invalidate() {
@@ -1,35 +1,12 @@
1
1
  import { type Component } from "@mariozechner/pi-tui";
2
2
  import type { TUI } from "@mariozechner/pi-tui";
3
- import type { AgentProfile } from "../config.js";
4
- export type MenuAction = {
5
- type: "start";
6
- } | {
7
- type: "create-agent";
8
- } | {
9
- type: "edit-agent";
10
- agentId: string;
11
- } | {
12
- type: "provider";
13
- providerId: string;
14
- } | {
15
- type: "quit";
16
- };
17
- export interface DashboardData {
18
- agents: AgentProfile[];
19
- }
3
+ export type DashboardAction = "start" | "manage" | "exit";
20
4
  export declare class DashboardScreen implements Component {
5
+ private container;
6
+ private selectList;
21
7
  private tui;
22
- private banner;
23
- private tree;
24
- private flat;
25
- private selectableIndices;
26
- private cursor;
27
- private cachedRender?;
28
- onAction?: (action: MenuAction) => void;
29
- constructor(tui: TUI, data: DashboardData);
30
- private buildTree;
31
- private rebuildFlat;
32
- private flattenTree;
8
+ onAction?: (action: DashboardAction) => void;
9
+ constructor(tui: TUI);
33
10
  handleInput(data: string): void;
34
11
  render(width: number): string[];
35
12
  invalidate(): void;
@@ -1,160 +1,51 @@
1
- import { matchesKey, Key, truncateToWidth, } from "@mariozechner/pi-tui";
1
+ import { Container, Text, Spacer, SelectList, matchesKey, Key, } from "@mariozechner/pi-tui";
2
2
  import { colors, theme } from "../theme.js";
3
3
  import { BannerComponent } from "./banner.js";
4
- import { PROVIDERS, isProviderAuthenticated } from "../providers.js";
4
+ const MENU_ITEMS = [
5
+ { value: "start", label: "Start", description: "" },
6
+ { value: "manage", label: "Manage", description: "" },
7
+ { value: "exit", label: "Exit", description: "" },
8
+ ];
5
9
  export class DashboardScreen {
10
+ container;
11
+ selectList;
6
12
  tui;
7
- banner;
8
- tree;
9
- flat = [];
10
- selectableIndices = [];
11
- cursor = 0;
12
- cachedRender;
13
13
  onAction;
14
- constructor(tui, data) {
14
+ constructor(tui) {
15
15
  this.tui = tui;
16
- this.banner = new BannerComponent();
17
- this.tree = this.buildTree(data);
18
- this.rebuildFlat();
19
- }
20
- buildTree(data) {
21
- // Manage children
22
- const manageChildren = [
23
- { label: "+ Create", indent: 0, action: { type: "create-agent" }, expanded: false },
24
- ];
25
- for (const agent of data.agents) {
26
- const ready = agent.instructions.length > 0;
27
- const suffix = ready ? " [⚡]" : "";
28
- manageChildren.push({
29
- label: `${agent.name}${suffix}`,
30
- indent: 0,
31
- action: { type: "edit-agent", agentId: agent.id },
32
- expanded: false,
33
- });
34
- }
35
- // Provider children
36
- const providerChildren = [
37
- { label: "Select provider to login:", indent: 0, isLabel: true, expanded: false },
38
- ];
39
- for (const p of PROVIDERS) {
40
- const loggedIn = isProviderAuthenticated(p.id);
41
- const prefix = loggedIn ? "→ " : " ";
42
- const suffix = loggedIn ? " ✓ logged in" : "";
43
- providerChildren.push({
44
- label: `${prefix}${p.label}${suffix}`,
45
- indent: 0,
46
- action: { type: "provider", providerId: p.id },
47
- expanded: false,
48
- });
49
- }
50
- return [
51
- { label: "Go online", indent: 0, action: { type: "start" }, expanded: false },
52
- {
53
- label: "Swarm",
54
- indent: 0,
55
- expanded: false,
56
- children: [
57
- {
58
- label: "Manage",
59
- indent: 0,
60
- expanded: false,
61
- children: manageChildren,
62
- },
63
- {
64
- label: "Providers",
65
- indent: 0,
66
- expanded: false,
67
- children: providerChildren,
68
- },
69
- ],
70
- },
71
- { label: "Exit", indent: 0, action: { type: "quit" }, expanded: false },
72
- ];
73
- }
74
- rebuildFlat() {
75
- this.flat = [];
76
- this.flattenTree(this.tree, 0);
77
- this.selectableIndices = this.flat
78
- .map((item, i) => (!item.node.isLabel ? i : -1))
79
- .filter((i) => i >= 0);
80
- if (this.cursor >= this.selectableIndices.length) {
81
- this.cursor = Math.max(0, this.selectableIndices.length - 1);
82
- }
83
- }
84
- flattenTree(nodes, indent) {
85
- for (const node of nodes) {
86
- this.flat.push({ node, indent });
87
- if (node.children && node.expanded) {
88
- this.flattenTree(node.children, indent + 1);
89
- }
90
- }
16
+ this.container = new Container();
17
+ this.container.addChild(new BannerComponent());
18
+ this.selectList = new SelectList(MENU_ITEMS, MENU_ITEMS.length, {
19
+ selectedPrefix: (t) => theme.accent(t),
20
+ selectedText: (t) => theme.accent(t),
21
+ description: (t) => colors.gray(t),
22
+ scrollInfo: (t) => colors.gray(t),
23
+ noMatch: (t) => colors.gray(t),
24
+ });
25
+ this.selectList.onSelect = (item) => {
26
+ this.onAction?.(item.value);
27
+ };
28
+ this.container.addChild(this.selectList);
29
+ this.container.addChild(new Spacer(1));
30
+ this.container.addChild(new Text(colors.gray(" ↑↓ navigate • →/enter select • q quit"), 1, 0));
91
31
  }
92
32
  handleInput(data) {
93
33
  if (matchesKey(data, "q")) {
94
- this.onAction?.({ type: "quit" });
34
+ this.onAction?.("exit");
95
35
  return;
96
36
  }
97
- if (matchesKey(data, Key.up)) {
98
- if (this.cursor > 0)
99
- this.cursor--;
100
- this.cachedRender = undefined;
37
+ if (matchesKey(data, Key.right)) {
38
+ this.selectList.handleInput("\r");
101
39
  this.tui.requestRender();
102
40
  return;
103
41
  }
104
- if (matchesKey(data, Key.down)) {
105
- if (this.cursor < this.selectableIndices.length - 1)
106
- this.cursor++;
107
- this.cachedRender = undefined;
108
- this.tui.requestRender();
109
- return;
110
- }
111
- if (matchesKey(data, Key.enter)) {
112
- const itemIdx = this.selectableIndices[this.cursor];
113
- const item = this.flat[itemIdx];
114
- if (item.node.children) {
115
- // Branch: toggle expand/collapse
116
- item.node.expanded = !item.node.expanded;
117
- this.rebuildFlat();
118
- this.cachedRender = undefined;
119
- this.tui.requestRender();
120
- }
121
- else if (item.node.action) {
122
- // Leaf: trigger action
123
- this.onAction?.(item.node.action);
124
- }
125
- return;
126
- }
42
+ this.selectList.handleInput(data);
43
+ this.tui.requestRender();
127
44
  }
128
45
  render(width) {
129
- if (this.cachedRender)
130
- return this.cachedRender;
131
- const lines = this.banner.render(width);
132
- const selectedFlatIdx = this.selectableIndices[this.cursor];
133
- for (let i = 0; i < this.flat.length; i++) {
134
- const { node, indent } = this.flat[i];
135
- const pad = " ".repeat(indent + 1);
136
- const isSelected = i === selectedFlatIdx;
137
- if (node.isLabel) {
138
- // Non-selectable label
139
- lines.push(truncateToWidth(`${pad} ${colors.gray(node.label)}`, width));
140
- continue;
141
- }
142
- const isBranch = !!node.children;
143
- const arrow = isBranch ? (node.expanded ? " ▾" : " ▸") : "";
144
- const label = `${node.label}${arrow}`;
145
- if (isSelected) {
146
- lines.push(truncateToWidth(`${pad}${theme.accent(`▸ ${label}`)}`, width));
147
- }
148
- else {
149
- lines.push(truncateToWidth(`${pad} ${label}`, width));
150
- }
151
- }
152
- lines.push("");
153
- lines.push(truncateToWidth(colors.gray(" ↑↓ navigate • enter expand/select • q quit"), width));
154
- this.cachedRender = lines;
155
- return lines;
46
+ return this.container.render(width);
156
47
  }
157
48
  invalidate() {
158
- this.cachedRender = undefined;
49
+ this.container.invalidate();
159
50
  }
160
51
  }
@@ -0,0 +1,17 @@
1
+ import { type SelectItem, type Component } from "@mariozechner/pi-tui";
2
+ import type { TUI } from "@mariozechner/pi-tui";
3
+ /**
4
+ * Reusable menu screen with title, items, and left/right/enter/esc navigation.
5
+ * Enter or Right = select, Left or Esc = back.
6
+ */
7
+ export declare class MenuScreen implements Component {
8
+ private container;
9
+ private selectList;
10
+ private tui;
11
+ onSelect?: (value: string) => void;
12
+ onBack?: () => void;
13
+ constructor(tui: TUI, title: string, items: SelectItem[], subtitle?: string);
14
+ handleInput(data: string): void;
15
+ render(width: number): string[];
16
+ invalidate(): void;
17
+ }
@@ -0,0 +1,59 @@
1
+ import { Container, Text, Spacer, SelectList, matchesKey, Key, truncateToWidth, } from "@mariozechner/pi-tui";
2
+ import { colors, theme } from "../theme.js";
3
+ /**
4
+ * Reusable menu screen with title, items, and left/right/enter/esc navigation.
5
+ * Enter or Right = select, Left or Esc = back.
6
+ */
7
+ export class MenuScreen {
8
+ container;
9
+ selectList;
10
+ tui;
11
+ onSelect;
12
+ onBack;
13
+ constructor(tui, title, items, subtitle) {
14
+ this.tui = tui;
15
+ this.container = new Container();
16
+ this.container.addChild(new Spacer(1));
17
+ this.container.addChild(new Text(truncateToWidth(theme.border("─".repeat(60)), 60), 0, 0));
18
+ this.container.addChild(new Text(theme.title(` ${title}`), 1, 0));
19
+ if (subtitle) {
20
+ this.container.addChild(new Text(colors.gray(` ${subtitle}`), 1, 0));
21
+ }
22
+ this.container.addChild(new Text(truncateToWidth(theme.border("─".repeat(60)), 60), 0, 0));
23
+ this.container.addChild(new Spacer(1));
24
+ this.selectList = new SelectList(items, Math.min(items.length, 15), {
25
+ selectedPrefix: (t) => theme.accent(t),
26
+ selectedText: (t) => theme.accent(t),
27
+ description: (t) => colors.gray(t),
28
+ scrollInfo: (t) => colors.gray(t),
29
+ noMatch: (t) => colors.gray(t),
30
+ });
31
+ this.selectList.onSelect = (item) => this.onSelect?.(item.value);
32
+ this.selectList.onCancel = () => this.onBack?.();
33
+ this.container.addChild(this.selectList);
34
+ this.container.addChild(new Spacer(1));
35
+ this.container.addChild(new Text(colors.gray(" ↑↓ navigate • →/enter select • ←/esc back"), 1, 0));
36
+ }
37
+ handleInput(data) {
38
+ // Right arrow = enter
39
+ if (matchesKey(data, Key.right)) {
40
+ // Simulate enter by triggering current selection
41
+ this.selectList.handleInput("\r");
42
+ this.tui.requestRender();
43
+ return;
44
+ }
45
+ // Left arrow = back
46
+ if (matchesKey(data, Key.left)) {
47
+ this.onBack?.();
48
+ return;
49
+ }
50
+ this.selectList.handleInput(data);
51
+ this.tui.requestRender();
52
+ }
53
+ render(width) {
54
+ return this.container.render(width);
55
+ }
56
+ invalidate() {
57
+ this.container.invalidate();
58
+ }
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmlancer-cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Swarmlancer CLI — let the swarm begin. Connect your AI agent to a network of other agents.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",