pudu-ai 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,8 @@ npx pudu-ai hardware
43
43
  npx pudu-ai models
44
44
  npx pudu-ai models add-path ~/Models
45
45
  npx pudu-ai recommend
46
+ npx pudu-ai recommend --install --yes
47
+ npx pudu-ai recommend --link opencode --yes
46
48
  npx pudu-ai tasks
47
49
  npx pudu-ai tasks --for code,image,transcription --lang es
48
50
  npx pudu-ai benchmark
package/dist/cli/index.js CHANGED
@@ -29,6 +29,7 @@ function parseArgs(argv) {
29
29
  const forIndex = args2.findIndex((a) => a === "--for");
30
30
  const scopeIndex = args2.findIndex((a) => a === "--scope");
31
31
  const priorityIndex = args2.findIndex((a) => a === "--priority");
32
+ const linkIndex = args2.findIndex((a) => a === "--link");
32
33
  return {
33
34
  command: isKnown ? command : "dashboard",
34
35
  positional: isKnown ? rest.slice(1) : rest,
@@ -45,7 +46,9 @@ function parseArgs(argv) {
45
46
  forKinds: forIndex >= 0 ? args2[forIndex + 1] : void 0,
46
47
  scope: scopeIndex >= 0 ? args2[scopeIndex + 1] : void 0,
47
48
  priority: priorityIndex >= 0 ? args2[priorityIndex + 1] : void 0,
48
- yes: flags.has("--yes")
49
+ yes: flags.has("--yes"),
50
+ install: flags.has("--install"),
51
+ link: linkIndex >= 0 ? args2[linkIndex + 1] : void 0
49
52
  };
50
53
  }
51
54
 
@@ -843,6 +846,14 @@ var en = {
843
846
  compatible: "COMPATIBLE (catalog, estimated)",
844
847
  recommended: "RECOMMENDED FOR THIS MACHINE",
845
848
  recommendedHint: "Estimated values come from CanIRun.ai by midudev (canirun.ai \xB7 midu.dev) or a local fallback \u2014 not measured",
849
+ recommendKeys: "[\u2191\u2193] model [I] ollama pull if grade S\u2013B [O] OpenCode [E] Hermes [W] OpenClaw [Esc] back",
850
+ recommendCliHint: "Install: npx pudu-ai recommend --install --yes Link: npx pudu-ai recommend --link opencode --yes",
851
+ agentsTitle: "CODING AGENTS",
852
+ agentMissing: "not installed \u2014 select to install via ollama launch if the model is eligible",
853
+ installGradeBlock: "Grade {grade} is too low to pull. Need S, A, or B.",
854
+ linkingTool: "Linking {tool} with {model}\u2026",
855
+ modelPulled: "Pulled {model} with Ollama.",
856
+ installNone: "No recommended model has grade S\u2013B to install.",
846
857
  credits: "Measured: llama-bench + OS telemetry. Estimated: CanIRun.ai by midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Labelled separately; never mixed.",
847
858
  noModels: "No local models detected",
848
859
  notTested: "Not tested",
@@ -950,6 +961,8 @@ Usage:
950
961
  npx pudu-ai models
951
962
  npx pudu-ai models add-path ~/Models
952
963
  npx pudu-ai recommend
964
+ npx pudu-ai recommend --install --yes
965
+ npx pudu-ai recommend --link opencode --yes
953
966
  npx pudu-ai tasks
954
967
  npx pudu-ai tasks --for code,image --scope all --priority speed
955
968
  npx pudu-ai benchmark [model]
@@ -1005,6 +1018,14 @@ var es = {
1005
1018
  compatible: "COMPATIBLES (cat\xE1logo, estimado)",
1006
1019
  recommended: "RECOMENDADOS PARA ESTE EQUIPO",
1007
1020
  recommendedHint: "Los valores estimados vienen de CanIRun.ai de midudev (canirun.ai \xB7 midu.dev) o de un fallback local \u2014 no son mediciones",
1021
+ recommendKeys: "[\u2191\u2193] modelo [I] ollama pull si nota S\u2013B [O] OpenCode [E] Hermes [W] OpenClaw [Esc] atr\xE1s",
1022
+ recommendCliHint: "Instalar: npx pudu-ai recommend --install --yes Vincular: npx pudu-ai recommend --link opencode --yes",
1023
+ agentsTitle: "AGENTES DE C\xD3DIGO",
1024
+ agentMissing: "no instalado \u2014 selecci\xF3nalo para instalar con ollama launch si el modelo es elegible",
1025
+ installGradeBlock: "La nota {grade} es baja para hacer pull. Hace falta S, A o B.",
1026
+ linkingTool: "Vinculando {tool} con {model}\u2026",
1027
+ modelPulled: "Descargado {model} con Ollama.",
1028
+ installNone: "Ning\xFAn modelo recomendado tiene nota S\u2013B para instalar.",
1008
1029
  credits: "Medido: llama-bench + telemetr\xEDa del SO. Estimado: CanIRun.ai de midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Se etiquetan por separado; nunca se mezclan.",
1009
1030
  noModels: "No se detectaron modelos locales",
1010
1031
  notTested: "Sin probar",
@@ -1112,6 +1133,8 @@ Uso:
1112
1133
  npx pudu-ai models
1113
1134
  npx pudu-ai models add-path ~/Models
1114
1135
  npx pudu-ai recommend
1136
+ npx pudu-ai recommend --install --yes
1137
+ npx pudu-ai recommend --link opencode --yes
1115
1138
  npx pudu-ai tasks
1116
1139
  npx pudu-ai tasks --for code,image --scope all --priority speed
1117
1140
  npx pudu-ai benchmark [model]
@@ -1358,15 +1381,33 @@ async function listBenchmarks() {
1358
1381
  return records;
1359
1382
  }
1360
1383
 
1384
+ // src/integrations/agents.ts
1385
+ var AGENTS = [
1386
+ { id: "opencode", label: "OpenCode", bin: "opencode" },
1387
+ { id: "hermes", label: "Hermes", bin: "hermes" },
1388
+ { id: "openclaw", label: "OpenClaw", bin: "openclaw" }
1389
+ ];
1390
+ async function detectAgents() {
1391
+ const out = [];
1392
+ for (const agent of AGENTS) {
1393
+ out.push({
1394
+ ...agent,
1395
+ detected: Boolean(await commandExists(agent.bin))
1396
+ });
1397
+ }
1398
+ return out;
1399
+ }
1400
+
1361
1401
  // src/session/load.ts
1362
1402
  async function loadSession(options) {
1363
1403
  const hardware = await detectHardware();
1364
- const [runtimes2, models, catalogState, history, llamaBench] = await Promise.all([
1404
+ const [runtimes2, models, catalogState, history, llamaBench, agents] = await Promise.all([
1365
1405
  detectRuntimes(),
1366
1406
  discoverLocalModels(),
1367
1407
  loadCatalog(options.network),
1368
1408
  listBenchmarks(),
1369
- commandExists("llama-bench").then(Boolean)
1409
+ commandExists("llama-bench").then(Boolean),
1410
+ detectAgents()
1370
1411
  ]);
1371
1412
  const rows = [];
1372
1413
  for (const local of models) {
@@ -1385,7 +1426,8 @@ async function loadSession(options) {
1385
1426
  recommendations,
1386
1427
  history,
1387
1428
  llamaBench,
1388
- networkUsed: catalogState.networkUsed
1429
+ networkUsed: catalogState.networkUsed,
1430
+ agents
1389
1431
  };
1390
1432
  }
1391
1433
 
@@ -1885,8 +1927,8 @@ function setLogLevel(next) {
1885
1927
  import { render } from "ink";
1886
1928
 
1887
1929
  // src/tui/App.tsx
1888
- import { Box as Box9, Text as Text9, useApp, useInput as useInput3 } from "ink";
1889
- import { useMemo as useMemo2, useState as useState3 } from "react";
1930
+ import { Box as Box9, Text as Text9, useApp as useApp2, useInput as useInput4 } from "ink";
1931
+ import { useMemo as useMemo2, useState as useState4 } from "react";
1890
1932
 
1891
1933
  // src/tui/views/Dashboard.tsx
1892
1934
  import { Box, Text } from "ink";
@@ -1924,6 +1966,27 @@ var GRADE_MEANING = {
1924
1966
  F: "Not recommended"
1925
1967
  };
1926
1968
 
1969
+ // src/tui/theme.ts
1970
+ function gradeColor(grade) {
1971
+ if (grade === "S" || grade === "A") return "green";
1972
+ if (grade === "B") return "yellow";
1973
+ if (grade === "C") return "magenta";
1974
+ if (grade === "D" || grade === "F") return "red";
1975
+ return "gray";
1976
+ }
1977
+ function bar(percent, width = 10) {
1978
+ if (percent === void 0 || Number.isNaN(percent)) return `${"\u2591".repeat(width)} N/A`;
1979
+ const filled = Math.max(0, Math.min(width, Math.round(percent / 100 * width)));
1980
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
1981
+ }
1982
+ function sparkline(values, width = 24) {
1983
+ const glyphs = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
1984
+ if (!values.length) return glyphs[0].repeat(width);
1985
+ const slice = values.slice(-width);
1986
+ const max = Math.max(...slice, 1);
1987
+ return slice.map((v) => glyphs[Math.min(glyphs.length - 1, Math.floor(v / max * (glyphs.length - 1)))]).join("").padStart(width, glyphs[0]);
1988
+ }
1989
+
1927
1990
  // src/tui/views/Dashboard.tsx
1928
1991
  import { jsx, jsxs } from "react/jsx-runtime";
1929
1992
  function Dashboard({ session }) {
@@ -1963,7 +2026,7 @@ function Dashboard({ session }) {
1963
2026
  ] }),
1964
2027
  /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1965
2028
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("runtimes") }),
1966
- session.runtimes.map((r) => /* @__PURE__ */ jsxs(Text, { children: [
2029
+ session.runtimes.map((r) => /* @__PURE__ */ jsxs(Text, { color: r.detected ? "green" : "gray", children: [
1967
2030
  r.detected ? "\u2713" : "\u25CB",
1968
2031
  " ",
1969
2032
  r.label.padEnd(14),
@@ -1975,12 +2038,12 @@ function Dashboard({ session }) {
1975
2038
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("installedModels") }),
1976
2039
  session.rows.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: t("noModels") }),
1977
2040
  session.rows.map((row) => /* @__PURE__ */ jsxs(Text, { children: [
1978
- row.local.name.padEnd(20),
2041
+ /* @__PURE__ */ jsx(Text, { color: "green", children: row.local.name.padEnd(20) }),
1979
2042
  " ",
1980
2043
  row.local.sizeBytes ? formatBytes(row.local.sizeBytes) : "N/A",
1981
- " ",
1982
- row.compatibility?.grade ?? "\u2014",
1983
- " ",
2044
+ " ",
2045
+ /* @__PURE__ */ jsx(Text, { color: gradeColor(row.compatibility?.grade), bold: true, children: row.compatibility?.grade ?? "\u2014" }),
2046
+ " ",
1984
2047
  row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested")
1985
2048
  ] }, row.local.id))
1986
2049
  ] }),
@@ -1988,11 +2051,11 @@ function Dashboard({ session }) {
1988
2051
  /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("recommended") }),
1989
2052
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: t("recommendedHint") }),
1990
2053
  session.recommendations.map((rec) => /* @__PURE__ */ jsxs(Text, { children: [
1991
- rec.useCase.padEnd(12),
2054
+ /* @__PURE__ */ jsx(Text, { color: "magenta", children: rec.useCase.padEnd(12) }),
1992
2055
  " ",
1993
2056
  rec.model.name.padEnd(22),
1994
2057
  " ",
1995
- rec.grade,
2058
+ /* @__PURE__ */ jsx(Text, { color: gradeColor(rec.grade), bold: true, children: rec.grade }),
1996
2059
  " ",
1997
2060
  GRADE_MEANING[rec.grade]
1998
2061
  ] }, `${rec.useCase}-${rec.model.id}`)),
@@ -2004,13 +2067,31 @@ function Dashboard({ session }) {
2004
2067
  // src/tui/views/Models.tsx
2005
2068
  import { Box as Box2, Text as Text2 } from "ink";
2006
2069
 
2070
+ // src/cli/color.ts
2071
+ var enabled = () => !process.env.NO_COLOR && process.stdout.isTTY;
2072
+ var wrap = (open, close = 39) => (text) => enabled() ? `\x1B[${open}m${text}\x1B[${close}m` : text;
2073
+ var cyan = wrap(36);
2074
+ var green = wrap(32);
2075
+ var yellow = wrap(33);
2076
+ var red = wrap(31);
2077
+ var magenta = wrap(35);
2078
+ var bold = wrap(1, 22);
2079
+ var dim = wrap(2, 22);
2080
+ function gradeAnsi(grade) {
2081
+ if (grade === "S" || grade === "A") return green(grade);
2082
+ if (grade === "B") return yellow(grade);
2083
+ if (grade === "C") return magenta(grade);
2084
+ if (grade === "D" || grade === "F") return red(grade);
2085
+ return grade;
2086
+ }
2087
+
2007
2088
  // src/cli/text.ts
2008
2089
  function hardwareText(session) {
2009
2090
  const h = session.hardware;
2010
2091
  const ram = formatBytes(h.memory.totalBytes, 0);
2011
2092
  const avail = h.memory.availableBytes ? formatBytes(h.memory.availableBytes) : "N/A";
2012
2093
  return [
2013
- t("machine"),
2094
+ bold(cyan(t("machine"))),
2014
2095
  ` ${h.machineModel ?? t("unknownMachine")}`,
2015
2096
  ` ${h.cpu.name ?? t("cpuNa")}`,
2016
2097
  ` CPU ${h.cpu.physicalCores ?? "N/A"} cores (${h.cpu.performanceCores ?? "?"}P / ${h.cpu.efficiencyCores ?? "?"}E)`,
@@ -2022,18 +2103,22 @@ function hardwareText(session) {
2022
2103
  }
2023
2104
  function runtimesText(session) {
2024
2105
  return [
2025
- t("runtimes"),
2026
- ...session.runtimes.map((r) => ` ${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.detected ? r.version ?? t("detected") : t("notDetected")}`)
2106
+ bold(cyan(t("runtimes"))),
2107
+ ...session.runtimes.map((r) => {
2108
+ const mark = r.detected ? green("\u2713") : dim("\u25CB");
2109
+ const state = r.detected ? green(r.version ?? t("detected")) : dim(t("notDetected"));
2110
+ return ` ${mark} ${r.label.padEnd(14)} ${state}`;
2111
+ })
2027
2112
  ].join("\n");
2028
2113
  }
2029
2114
  function modelsText(session) {
2030
- const lines = [t("installedModels"), "MODEL INSTALLED FIT EST. SPEED MEASURED"];
2115
+ const lines = [bold(cyan(t("installedModels"))), dim("MODEL INSTALLED FIT EST. SPEED MEASURED")];
2031
2116
  for (const row of session.rows) {
2032
2117
  const fit = row.compatibility?.grade ?? "\u2014";
2033
2118
  const est = row.compatibility?.estimatedTokensPerSecond ? `~${formatNumber(row.compatibility.estimatedTokensPerSecond)} t/s` : "\u2014";
2034
2119
  const measured = row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested");
2035
2120
  lines.push(
2036
- `${row.local.name.padEnd(22)} \u2713 ${fit.padEnd(9)} ${est.padEnd(14)} ${measured}`
2121
+ `${green(row.local.name.padEnd(22))} \u2713 ${gradeAnsi(fit).padEnd(9)} ${est.padEnd(14)} ${measured}`
2037
2122
  );
2038
2123
  }
2039
2124
  lines.push("", t("compatible"));
@@ -2045,12 +2130,19 @@ function modelsText(session) {
2045
2130
  return lines.join("\n");
2046
2131
  }
2047
2132
  function recommendText(session) {
2048
- const lines = [t("recommended"), t("recommendedHint"), "", t("credits"), ""];
2133
+ const lines = [bold(cyan(t("recommended"))), dim(t("recommendedHint")), "", dim(t("credits")), ""];
2049
2134
  for (const rec of session.recommendations) {
2050
2135
  lines.push(
2051
- `${rec.useCase.padEnd(12)} ${rec.model.name.padEnd(22)} ${rec.grade} ${GRADE_MEANING[rec.grade]} ~${na(rec.estimatedTokensPerSecond)} t/s est.`
2136
+ `${magenta(rec.useCase.padEnd(12))} ${rec.model.name.padEnd(22)} ${gradeAnsi(rec.grade)} ${GRADE_MEANING[rec.grade]} ~${na(rec.estimatedTokensPerSecond)} t/s est.`
2137
+ );
2138
+ }
2139
+ lines.push("", bold(yellow(t("agentsTitle"))));
2140
+ for (const agent of session.agents) {
2141
+ lines.push(
2142
+ ` ${agent.detected ? green("\u2713") : dim("\u25CB")} ${agent.label.padEnd(12)} ${agent.detected ? green(t("detected")) : dim(t("agentMissing"))}`
2052
2143
  );
2053
2144
  }
2145
+ lines.push("", dim(t("recommendCliHint")));
2054
2146
  return lines.join("\n");
2055
2147
  }
2056
2148
  function historyText(records) {
@@ -2086,6 +2178,7 @@ function doctorText(session) {
2086
2178
  `${session.hardware.gpu.metal ? "\u2713" : "\u25CB"} Metal`,
2087
2179
  ...session.runtimes.map((r) => `${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.version ?? ""}`.trimEnd()),
2088
2180
  `${session.networkUsed ? "\u2713" : "\u25CB"} CanIRun.ai (midudev)`,
2181
+ ...session.agents.map((a) => `${a.detected ? "\u2713" : "\u25CB"} ${a.label.padEnd(14)} ${a.detected ? t("detected") : t("agentMissing")}`),
2089
2182
  "",
2090
2183
  session.llamaBench ? t("doctorReady", { count: session.models.filter((m) => m.artifactPath).length }) : t("doctorNoBench")
2091
2184
  ];
@@ -2186,10 +2279,264 @@ function HardwareView({ session }) {
2186
2279
  }
2187
2280
 
2188
2281
  // src/tui/views/Recommend.tsx
2189
- import { Box as Box4, Text as Text4 } from "ink";
2190
- import { jsx as jsx4 } from "react/jsx-runtime";
2282
+ import { Box as Box4, Text as Text4, useApp, useInput } from "ink";
2283
+ import { useState } from "react";
2284
+
2285
+ // src/integrations/catalog.ts
2286
+ var INTEGRATIONS = {
2287
+ opencode: {
2288
+ id: "opencode",
2289
+ docsUrl: "https://docs.ollama.com/integrations/opencode",
2290
+ ollamaLaunch: "opencode",
2291
+ minGenerationTps: 12,
2292
+ allowedGrades: ["S", "A", "B"],
2293
+ useCases: ["code", "chat", "reasoning"]
2294
+ },
2295
+ openclaw: {
2296
+ id: "openclaw",
2297
+ docsUrl: "https://docs.ollama.com/integrations/openclaw",
2298
+ ollamaLaunch: "openclaw",
2299
+ minGenerationTps: 12,
2300
+ allowedGrades: ["S", "A", "B"],
2301
+ useCases: ["code", "chat", "reasoning"]
2302
+ },
2303
+ hermes: {
2304
+ id: "hermes",
2305
+ docsUrl: "https://docs.ollama.com/integrations/hermes",
2306
+ ollamaLaunch: "hermes",
2307
+ minGenerationTps: 12,
2308
+ allowedGrades: ["S", "A"],
2309
+ useCases: ["code", "chat", "reasoning"]
2310
+ },
2311
+ claude: {
2312
+ id: "claude",
2313
+ docsUrl: "https://docs.ollama.com/integrations/claude-code",
2314
+ ollamaLaunch: "claude",
2315
+ minGenerationTps: 12,
2316
+ allowedGrades: ["S", "A", "B"],
2317
+ useCases: ["code", "chat", "reasoning"]
2318
+ }
2319
+ };
2320
+
2321
+ // src/integrations/types.ts
2322
+ var INTEGRATION_IDS = ["opencode", "openclaw", "hermes", "claude"];
2323
+
2324
+ // src/integrations/decide.ts
2325
+ var GRADE_RANK = { S: 5, A: 4, B: 3, C: 2, D: 1, F: 0 };
2326
+ function toOllamaTag(id) {
2327
+ const compact = id.toLowerCase().replace(/_/g, "-");
2328
+ const match = compact.match(/^([a-z0-9.]+(?:-[a-z0-9.]+)*)-(\d+(?:\.\d+)?b)$/i);
2329
+ if (match) return `${match[1]}:${match[2]}`;
2330
+ if (compact.includes(":")) return compact;
2331
+ return compact;
2332
+ }
2333
+ function useCaseOk(useCases, needed) {
2334
+ if (!useCases?.length) return needed.includes("chat");
2335
+ return useCases.some((u) => needed.some((n) => u.includes(n)));
2336
+ }
2337
+ function decideLaunch(session, id) {
2338
+ const def = INTEGRATIONS[id];
2339
+ const reasons = [];
2340
+ const ollama = session.runtimes.find((r) => r.id === "ollama")?.detected;
2341
+ if (!ollama) reasons.push(t("launchNeedOllama"));
2342
+ const candidates = [];
2343
+ for (const row of session.rows) {
2344
+ const useCases = row.catalog?.useCase ?? ["chat", "code"];
2345
+ if (!useCaseOk(useCases, def.useCases)) continue;
2346
+ const tps = row.lastBenchmark?.benchmark.generationTokensPerSecond;
2347
+ candidates.push({
2348
+ modelId: row.local.id,
2349
+ ollamaTag: row.local.id.includes(":") ? row.local.id : toOllamaTag(row.local.id),
2350
+ installed: true,
2351
+ origin: row.lastBenchmark ? "measured" : "estimated",
2352
+ grade: row.compatibility?.grade,
2353
+ tps
2354
+ });
2355
+ }
2356
+ if (!candidates.length) {
2357
+ for (const model of session.catalog) {
2358
+ if (!useCaseOk(model.useCase, def.useCases)) continue;
2359
+ const fit = localCompatibility(session.hardware, model);
2360
+ if (!def.allowedGrades.includes(fit.grade)) continue;
2361
+ candidates.push({
2362
+ modelId: model.id,
2363
+ ollamaTag: toOllamaTag(model.id),
2364
+ installed: false,
2365
+ origin: "estimated",
2366
+ grade: fit.grade
2367
+ });
2368
+ }
2369
+ }
2370
+ candidates.sort((a, b) => {
2371
+ if (a.installed !== b.installed) return a.installed ? -1 : 1;
2372
+ return (GRADE_RANK[b.grade ?? "F"] ?? 0) - (GRADE_RANK[a.grade ?? "F"] ?? 0);
2373
+ });
2374
+ const pick = candidates[0];
2375
+ if (!pick) reasons.push(t("launchNoModel"));
2376
+ if (pick?.grade && !def.allowedGrades.includes(pick.grade)) {
2377
+ reasons.push(t("launchGradeFail", { grade: pick.grade, allowed: def.allowedGrades.join(",") }));
2378
+ }
2379
+ if (pick?.origin === "measured" && pick.tps !== void 0 && pick.tps < def.minGenerationTps) {
2380
+ reasons.push(t("launchSlowFail", { tps: pick.tps.toFixed(1), min: def.minGenerationTps }));
2381
+ }
2382
+ if (pick && pick.origin === "estimated" && (!pick.grade || GRADE_RANK[pick.grade] < GRADE_RANK.B)) {
2383
+ reasons.push(t("launchEstimateWeak"));
2384
+ }
2385
+ const eligible = Boolean(ollama && pick && reasons.length === 0);
2386
+ const command = pick ? `ollama launch ${def.ollamaLaunch} --model ${pick.ollamaTag}` : `ollama launch ${def.ollamaLaunch}`;
2387
+ return {
2388
+ integration: id,
2389
+ docsUrl: def.docsUrl,
2390
+ eligible,
2391
+ reasons: eligible ? [t("launchOk")] : reasons,
2392
+ modelId: pick?.modelId,
2393
+ ollamaTag: pick?.ollamaTag,
2394
+ installed: pick?.installed ?? false,
2395
+ origin: pick?.origin,
2396
+ grade: pick?.grade,
2397
+ command
2398
+ };
2399
+ }
2400
+ function decideAll(session) {
2401
+ return INTEGRATION_IDS.map((id) => decideLaunch(session, id));
2402
+ }
2403
+ function launchText(decisions) {
2404
+ const lines = [t("launchTitle"), t("launchHint"), ""];
2405
+ for (const d of decisions) {
2406
+ lines.push(`${d.integration} ${d.docsUrl}`);
2407
+ lines.push(` ${d.eligible ? "\u2713" : "\u25CB"} ${d.reasons.join(" ")}`);
2408
+ if (d.modelId) {
2409
+ lines.push(
2410
+ ` ${t("launchModel")}: ${d.modelId} ${d.grade ?? "\u2014"} ${d.origin === "measured" ? t("measured") : t("estimated")} ${d.installed ? t("tasksInstalled") : t("tasksNotInstalled")}`
2411
+ );
2412
+ }
2413
+ lines.push(` ${d.command}`);
2414
+ if (d.eligible) lines.push(` ${t("launchRunHint", { tool: d.integration })}`);
2415
+ else lines.push(` ${t("launchBlocked")}`);
2416
+ lines.push("");
2417
+ }
2418
+ return lines.join("\n");
2419
+ }
2420
+ function parseIntegration(raw) {
2421
+ if (!raw) return void 0;
2422
+ const id = raw.toLowerCase();
2423
+ if (id === "claude-code") return "claude";
2424
+ return INTEGRATION_IDS.find((item) => item === id);
2425
+ }
2426
+
2427
+ // src/integrations/execute.ts
2428
+ async function executeLaunch(decision) {
2429
+ if (!decision.eligible || !decision.ollamaTag) {
2430
+ return { ok: false, log: t("launchBlocked") };
2431
+ }
2432
+ const def = INTEGRATIONS[decision.integration];
2433
+ if (!def) return { ok: false, log: t("launchUnknown") };
2434
+ const lines = [];
2435
+ if (!decision.installed) {
2436
+ lines.push(t("launchPulling", { model: decision.ollamaTag }));
2437
+ const pull = await runCommand("ollama", ["pull", decision.ollamaTag], { timeout: 30 * 6e4 });
2438
+ if (pull.exitCode !== 0) {
2439
+ return { ok: false, log: `${lines.join("\n")}
2440
+ ${pull.stderr || t("launchPullFail")}` };
2441
+ }
2442
+ }
2443
+ const args2 = ["launch", def.ollamaLaunch, "--model", decision.ollamaTag];
2444
+ if (def.id === "openclaw" || def.id === "claude") args2.push("--yes");
2445
+ lines.push(`ollama ${args2.join(" ")}`);
2446
+ const launched = await runCommand("ollama", args2, { timeout: 12e4 });
2447
+ if (launched.exitCode !== 0) {
2448
+ return {
2449
+ ok: false,
2450
+ log: `${lines.join("\n")}
2451
+ ${launched.stderr || launched.stdout || t("launchExecFail")}`
2452
+ };
2453
+ }
2454
+ return { ok: true, log: `${lines.join("\n")}
2455
+ ${launched.stdout}`.trim() };
2456
+ }
2457
+
2458
+ // src/integrations/pull.ts
2459
+ var INSTALLABLE = ["S", "A", "B"];
2460
+ function canInstallGrade(grade) {
2461
+ return Boolean(grade && INSTALLABLE.includes(grade));
2462
+ }
2463
+ async function pullOllamaModel(modelId) {
2464
+ const tag = toOllamaTag(modelId);
2465
+ const result = await runCommand("ollama", ["pull", tag], { timeout: 30 * 6e4 });
2466
+ if (result.exitCode !== 0) {
2467
+ return { ok: false, log: result.stderr || t("launchPullFail"), tag };
2468
+ }
2469
+ return { ok: true, log: t("modelPulled", { model: tag }), tag };
2470
+ }
2471
+
2472
+ // src/tui/views/Recommend.tsx
2473
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2191
2474
  function RecommendView({ session }) {
2192
- return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: /* @__PURE__ */ jsx4(Text4, { children: recommendText(session) }) });
2475
+ const { exit } = useApp();
2476
+ const [cursor, setCursor] = useState(0);
2477
+ const [log, setLog] = useState("");
2478
+ const [busy, setBusy] = useState(false);
2479
+ const recs = session.recommendations;
2480
+ useInput((input, key) => {
2481
+ if (busy) return;
2482
+ if (key.escape) return;
2483
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
2484
+ if (key.downArrow) setCursor((c) => Math.min(Math.max(recs.length - 1, 0), c + 1));
2485
+ const rec = recs[cursor];
2486
+ if (input.toLowerCase() === "i" && rec) {
2487
+ if (!canInstallGrade(rec.grade)) {
2488
+ setLog(t("installGradeBlock", { grade: rec.grade }));
2489
+ return;
2490
+ }
2491
+ setBusy(true);
2492
+ setLog(t("launchPulling", { model: rec.model.id }));
2493
+ void pullOllamaModel(rec.model.id).then((result) => {
2494
+ setLog(result.log);
2495
+ setBusy(false);
2496
+ });
2497
+ }
2498
+ const map = { o: "opencode", e: "hermes", w: "openclaw" };
2499
+ const tool = map[input.toLowerCase()];
2500
+ if (tool && rec) {
2501
+ const decision = decideLaunch(session, tool);
2502
+ if (!decision.eligible) {
2503
+ setLog(decision.reasons.join(" "));
2504
+ return;
2505
+ }
2506
+ setBusy(true);
2507
+ setLog(t("linkingTool", { tool, model: rec.model.id }));
2508
+ void executeLaunch({ ...decision, ollamaTag: decision.ollamaTag, modelId: rec.model.id }).then((result) => {
2509
+ setLog(result.log);
2510
+ setBusy(false);
2511
+ if (result.ok) exit();
2512
+ });
2513
+ }
2514
+ });
2515
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
2516
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: t("recommended") }),
2517
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("aboutRecommend") }),
2518
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("recommendKeys") }),
2519
+ recs.map((rec, i) => /* @__PURE__ */ jsxs4(Text4, { color: i === cursor ? "cyan" : void 0, children: [
2520
+ i === cursor ? "\u276F " : " ",
2521
+ /* @__PURE__ */ jsx4(Text4, { color: "magenta", children: rec.useCase.padEnd(12) }),
2522
+ rec.model.name.padEnd(22),
2523
+ " ",
2524
+ /* @__PURE__ */ jsx4(Text4, { color: gradeColor(rec.grade), bold: true, children: rec.grade }),
2525
+ " ",
2526
+ GRADE_MEANING[rec.grade]
2527
+ ] }, `${rec.useCase}-${rec.model.id}`)),
2528
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", children: [
2529
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "yellow", children: t("agentsTitle") }),
2530
+ session.agents.map((agent) => /* @__PURE__ */ jsxs4(Text4, { color: agent.detected ? "green" : "gray", children: [
2531
+ agent.detected ? "\u2713" : "\u25CB",
2532
+ " ",
2533
+ agent.label.padEnd(12),
2534
+ " ",
2535
+ agent.detected ? t("detected") : t("agentMissing")
2536
+ ] }, agent.id))
2537
+ ] }),
2538
+ log ? /* @__PURE__ */ jsx4(Text4, { color: "green", children: log }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: t("credits") })
2539
+ ] });
2193
2540
  }
2194
2541
 
2195
2542
  // src/tui/views/Compare.tsx
@@ -2200,35 +2547,19 @@ function CompareView({ session }) {
2200
2547
  }
2201
2548
 
2202
2549
  // src/tui/views/Benchmark.tsx
2203
- import { Box as Box6, Text as Text6, useInput } from "ink";
2204
- import { useEffect, useRef, useState } from "react";
2205
-
2206
- // src/tui/theme.ts
2207
- function bar(percent, width = 10) {
2208
- if (percent === void 0 || Number.isNaN(percent)) return `${"\u2591".repeat(width)} N/A`;
2209
- const filled = Math.max(0, Math.min(width, Math.round(percent / 100 * width)));
2210
- return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
2211
- }
2212
- function sparkline(values, width = 24) {
2213
- const glyphs = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
2214
- if (!values.length) return glyphs[0].repeat(width);
2215
- const slice = values.slice(-width);
2216
- const max = Math.max(...slice, 1);
2217
- return slice.map((v) => glyphs[Math.min(glyphs.length - 1, Math.floor(v / max * (glyphs.length - 1)))]).join("").padStart(width, glyphs[0]);
2218
- }
2219
-
2220
- // src/tui/views/Benchmark.tsx
2221
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
2550
+ import { Box as Box6, Text as Text6, useInput as useInput2 } from "ink";
2551
+ import { useEffect, useRef, useState as useState2 } from "react";
2552
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
2222
2553
  function BenchmarkView(props) {
2223
- const [phase, setPhase] = useState(props.models.length ? "select" : "error");
2224
- const [sample, setSample] = useState();
2225
- const [elapsed, setElapsed] = useState(0);
2226
- const [result, setResult] = useState();
2227
- const [error, setError] = useState(props.models.length ? "" : t("noGguf"));
2554
+ const [phase, setPhase] = useState2(props.models.length ? "select" : "error");
2555
+ const [sample, setSample] = useState2();
2556
+ const [elapsed, setElapsed] = useState2(0);
2557
+ const [result, setResult] = useState2();
2558
+ const [error, setError] = useState2(props.models.length ? "" : t("noGguf"));
2228
2559
  const cpuHist = useRef([]);
2229
2560
  const memHist = useRef([]);
2230
2561
  const abortRef = useRef(void 0);
2231
- useInput((input, key) => {
2562
+ useInput2((input, key) => {
2232
2563
  if (phase === "select") {
2233
2564
  if (key.upArrow) props.setSelected(Math.max(0, props.selected - 1));
2234
2565
  if (key.downArrow) props.setSelected(Math.min(props.models.length - 1, props.selected + 1));
@@ -2278,9 +2609,9 @@ function BenchmarkView(props) {
2278
2609
  const h = props.session.hardware;
2279
2610
  const model = props.models[props.selected];
2280
2611
  if (phase === "select") {
2281
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2612
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2282
2613
  /* @__PURE__ */ jsx6(Text6, { bold: true, children: t("selectBenchmark") }),
2283
- props.models.map((m, i) => /* @__PURE__ */ jsxs4(Text6, { color: i === props.selected ? "cyan" : void 0, children: [
2614
+ props.models.map((m, i) => /* @__PURE__ */ jsxs5(Text6, { color: i === props.selected ? "cyan" : void 0, children: [
2284
2615
  i === props.selected ? "\u276F " : " ",
2285
2616
  m.name
2286
2617
  ] }, m.id)),
@@ -2288,9 +2619,9 @@ function BenchmarkView(props) {
2288
2619
  ] });
2289
2620
  }
2290
2621
  if (phase === "done" && result) {
2291
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2622
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2292
2623
  /* @__PURE__ */ jsx6(Text6, { children: resultText(result.record, result.assessment) }),
2293
- /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
2624
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
2294
2625
  t("saved"),
2295
2626
  " ",
2296
2627
  result.path
@@ -2304,11 +2635,11 @@ function BenchmarkView(props) {
2304
2635
  const gpu = sample?.gpu?.utilizationPercent;
2305
2636
  const memUsed = sample?.memory.usedBytes;
2306
2637
  const memTotal = h.memory.totalBytes;
2307
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2308
- /* @__PURE__ */ jsxs4(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [
2638
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
2639
+ /* @__PURE__ */ jsxs5(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [
2309
2640
  /* @__PURE__ */ jsx6(Text6, { bold: true, children: t("benchmarkTitle") }),
2310
2641
  /* @__PURE__ */ jsx6(Text6, { children: model?.name }),
2311
- /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
2642
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
2312
2643
  h.cpu.name,
2313
2644
  " \xB7 ",
2314
2645
  formatBytes(h.memory.totalBytes, 0),
@@ -2316,9 +2647,9 @@ function BenchmarkView(props) {
2316
2647
  memoryLabel(h)
2317
2648
  ] })
2318
2649
  ] }),
2319
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2650
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2320
2651
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("performance") }),
2321
- /* @__PURE__ */ jsxs4(Text6, { children: [
2652
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2322
2653
  t("elapsed"),
2323
2654
  " ",
2324
2655
  elapsed.toFixed(1),
@@ -2326,21 +2657,21 @@ function BenchmarkView(props) {
2326
2657
  ] }),
2327
2658
  /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("measuredAfter") })
2328
2659
  ] }),
2329
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2660
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2330
2661
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("system") }),
2331
- /* @__PURE__ */ jsxs4(Text6, { children: [
2662
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2332
2663
  "CPU ",
2333
2664
  bar(cpu),
2334
2665
  " ",
2335
2666
  formatPercent(cpu)
2336
2667
  ] }),
2337
- /* @__PURE__ */ jsxs4(Text6, { children: [
2668
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2338
2669
  "GPU ",
2339
2670
  bar(gpu),
2340
2671
  " ",
2341
2672
  formatPercent(gpu)
2342
2673
  ] }),
2343
- /* @__PURE__ */ jsxs4(Text6, { children: [
2674
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2344
2675
  "Memory ",
2345
2676
  bar(memUsed && memTotal ? memUsed / memTotal * 100 : void 0),
2346
2677
  " ",
@@ -2348,36 +2679,36 @@ function BenchmarkView(props) {
2348
2679
  " / ",
2349
2680
  formatBytes(memTotal, 0)
2350
2681
  ] }),
2351
- /* @__PURE__ */ jsxs4(Text6, { children: [
2682
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2352
2683
  "Swap ",
2353
2684
  bar(0),
2354
2685
  " ",
2355
2686
  sample ? formatBytes(sample.memory.swapUsedBytes) : "N/A"
2356
2687
  ] })
2357
2688
  ] }),
2358
- /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
2689
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
2359
2690
  /* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("powerThermals") }),
2360
- /* @__PURE__ */ jsxs4(Text6, { children: [
2691
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2361
2692
  "Package ",
2362
2693
  sample?.packagePowerWatts ?? "N/A",
2363
2694
  " W"
2364
2695
  ] }),
2365
- /* @__PURE__ */ jsxs4(Text6, { children: [
2696
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2366
2697
  "Temperature ",
2367
2698
  sample?.thermal?.temperatureC ?? "N/A",
2368
2699
  " \xB0C"
2369
2700
  ] }),
2370
- /* @__PURE__ */ jsxs4(Text6, { children: [
2701
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2371
2702
  "Thermal Pressure ",
2372
2703
  sample?.thermal?.pressure ?? "N/A"
2373
2704
  ] }),
2374
2705
  /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("permissionsHint") })
2375
2706
  ] }),
2376
- /* @__PURE__ */ jsxs4(Text6, { children: [
2707
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2377
2708
  sparkline(cpuHist.current),
2378
2709
  " CPU"
2379
2710
  ] }),
2380
- /* @__PURE__ */ jsxs4(Text6, { children: [
2711
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2381
2712
  sparkline(memHist.current.map((v) => v / 1024 ** 3)),
2382
2713
  " Memory"
2383
2714
  ] }),
@@ -2387,17 +2718,17 @@ function BenchmarkView(props) {
2387
2718
 
2388
2719
  // src/tui/views/History.tsx
2389
2720
  import { Box as Box7, Text as Text7 } from "ink";
2390
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
2721
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2391
2722
  function HistoryView({ session }) {
2392
- return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
2723
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
2393
2724
  /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: t("measured") }),
2394
2725
  /* @__PURE__ */ jsx7(Text7, { children: historyText(session.history) })
2395
2726
  ] });
2396
2727
  }
2397
2728
 
2398
2729
  // src/tui/views/Tasks.tsx
2399
- import { Box as Box8, Text as Text8, useInput as useInput2 } from "ink";
2400
- import { useMemo, useState as useState2 } from "react";
2730
+ import { Box as Box8, Text as Text8, useInput as useInput3 } from "ink";
2731
+ import { useMemo, useState as useState3 } from "react";
2401
2732
 
2402
2733
  // src/tasks/catalog.ts
2403
2734
  var TASK_CATALOG = [
@@ -2592,7 +2923,7 @@ function tasksText(plans, answers) {
2592
2923
  }
2593
2924
 
2594
2925
  // src/tui/views/Tasks.tsx
2595
- import { Fragment, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2926
+ import { Fragment, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2596
2927
  var KIND_LABEL = {
2597
2928
  code: "tasksOptCode",
2598
2929
  video: "tasksOptVideo",
@@ -2601,16 +2932,16 @@ var KIND_LABEL = {
2601
2932
  chat: "tasksOptChat"
2602
2933
  };
2603
2934
  function TasksView({ session }) {
2604
- const [step, setStep] = useState2(0);
2605
- const [cursor, setCursor] = useState2(0);
2606
- const [kinds, setKinds] = useState2(["code", "chat"]);
2607
- const [scope, setScope] = useState2("installed");
2608
- const [priority, setPriority] = useState2("balanced");
2935
+ const [step, setStep] = useState3(0);
2936
+ const [cursor, setCursor] = useState3(0);
2937
+ const [kinds, setKinds] = useState3(["code", "chat"]);
2938
+ const [scope, setScope] = useState3("installed");
2939
+ const [priority, setPriority] = useState3("balanced");
2609
2940
  const plans = useMemo(
2610
2941
  () => planTasks(session, { kinds, scope, priority }),
2611
2942
  [session, kinds, scope, priority]
2612
2943
  );
2613
- useInput2((input, key) => {
2944
+ useInput3((input, key) => {
2614
2945
  if (step === 0) {
2615
2946
  if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
2616
2947
  if (key.downArrow) setCursor((c) => Math.min(WORK_KINDS.length - 1, c + 1));
@@ -2635,11 +2966,11 @@ function TasksView({ session }) {
2635
2966
  if (step === 3) {
2636
2967
  return /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsx8(Text8, { children: tasksText(plans, { kinds, scope, priority }) }) });
2637
2968
  }
2638
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
2969
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", children: [
2639
2970
  /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: t("tasksTitle") }),
2640
- step === 0 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2971
+ step === 0 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2641
2972
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ1") }),
2642
- WORK_KINDS.map((kind, i) => /* @__PURE__ */ jsxs6(Text8, { color: i === cursor ? "cyan" : void 0, children: [
2973
+ WORK_KINDS.map((kind, i) => /* @__PURE__ */ jsxs7(Text8, { color: i === cursor ? "cyan" : void 0, children: [
2643
2974
  i === cursor ? "\u276F " : " ",
2644
2975
  "[",
2645
2976
  kinds.includes(kind) ? "x" : " ",
@@ -2647,20 +2978,20 @@ function TasksView({ session }) {
2647
2978
  t(KIND_LABEL[kind])
2648
2979
  ] }, kind))
2649
2980
  ] }),
2650
- step === 1 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2981
+ step === 1 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2651
2982
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ2") }),
2652
- /* @__PURE__ */ jsxs6(Text8, { color: scope === "installed" ? "cyan" : void 0, children: [
2983
+ /* @__PURE__ */ jsxs7(Text8, { color: scope === "installed" ? "cyan" : void 0, children: [
2653
2984
  scope === "installed" ? "\u276F " : " ",
2654
2985
  t("tasksOptInstalled")
2655
2986
  ] }),
2656
- /* @__PURE__ */ jsxs6(Text8, { color: scope === "all" ? "cyan" : void 0, children: [
2987
+ /* @__PURE__ */ jsxs7(Text8, { color: scope === "all" ? "cyan" : void 0, children: [
2657
2988
  scope === "all" ? "\u276F " : " ",
2658
2989
  t("tasksOptAll")
2659
2990
  ] })
2660
2991
  ] }),
2661
- step === 2 && /* @__PURE__ */ jsxs6(Fragment, { children: [
2992
+ step === 2 && /* @__PURE__ */ jsxs7(Fragment, { children: [
2662
2993
  /* @__PURE__ */ jsx8(Text8, { children: t("tasksQ3") }),
2663
- ["speed", "balanced", "quality"].map((item) => /* @__PURE__ */ jsxs6(Text8, { color: priority === item ? "cyan" : void 0, children: [
2994
+ ["speed", "balanced", "quality"].map((item) => /* @__PURE__ */ jsxs7(Text8, { color: priority === item ? "cyan" : void 0, children: [
2664
2995
  priority === item ? "\u276F " : " ",
2665
2996
  item === "speed" ? t("tasksOptSpeed") : item === "quality" ? t("tasksOptQuality") : t("tasksOptBalanced")
2666
2997
  ] }, item))
@@ -2669,17 +3000,22 @@ function TasksView({ session }) {
2669
3000
  }
2670
3001
 
2671
3002
  // src/tui/App.tsx
2672
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3003
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2673
3004
  function App(props) {
2674
- const { exit } = useApp();
2675
- const [screen, setScreen] = useState3("home");
2676
- const [selected, setSelected] = useState3(0);
3005
+ const { exit } = useApp2();
3006
+ const [screen, setScreen] = useState4("home");
3007
+ const [selected, setSelected] = useState4(0);
2677
3008
  const benchable = useMemo2(
2678
3009
  () => props.session.models.filter((m) => Boolean(m.artifactPath)),
2679
3010
  [props.session.models]
2680
3011
  );
2681
- useInput3((input, key) => {
3012
+ useInput4((input, key) => {
2682
3013
  if (screen === "benchmark") return;
3014
+ if (screen === "recommend" || screen === "tasks") {
3015
+ if (letter === "q") exit();
3016
+ if (key.escape) setScreen("home");
3017
+ return;
3018
+ }
2683
3019
  const letter = input.toLowerCase();
2684
3020
  if (letter === "q" || key.escape) exit();
2685
3021
  if (letter === "b") setScreen("benchmark");
@@ -2691,7 +3027,7 @@ function App(props) {
2691
3027
  if (letter === "t") setScreen("tasks");
2692
3028
  if (key.return && screen === "home") setScreen("benchmark");
2693
3029
  });
2694
- return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", padding: 1, children: [
3030
+ return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", padding: 1, children: [
2695
3031
  screen === "home" && /* @__PURE__ */ jsx9(Dashboard, { session: props.session }),
2696
3032
  screen === "models" && /* @__PURE__ */ jsx9(ModelsView, { session: props.session }),
2697
3033
  screen === "hardware" && /* @__PURE__ */ jsx9(HardwareView, { session: props.session }),
@@ -2721,179 +3057,6 @@ async function renderDashboard(session, preset) {
2721
3057
  await instance.waitUntilExit();
2722
3058
  }
2723
3059
 
2724
- // src/integrations/catalog.ts
2725
- var INTEGRATIONS = {
2726
- opencode: {
2727
- id: "opencode",
2728
- docsUrl: "https://docs.ollama.com/integrations/opencode",
2729
- ollamaLaunch: "opencode",
2730
- minGenerationTps: 12,
2731
- allowedGrades: ["S", "A", "B"],
2732
- useCases: ["code", "chat", "reasoning"]
2733
- },
2734
- openclaw: {
2735
- id: "openclaw",
2736
- docsUrl: "https://docs.ollama.com/integrations/openclaw",
2737
- ollamaLaunch: "openclaw",
2738
- minGenerationTps: 12,
2739
- allowedGrades: ["S", "A", "B"],
2740
- useCases: ["code", "chat", "reasoning"]
2741
- },
2742
- hermes: {
2743
- id: "hermes",
2744
- docsUrl: "https://docs.ollama.com/integrations/hermes",
2745
- ollamaLaunch: "hermes",
2746
- minGenerationTps: 12,
2747
- allowedGrades: ["S", "A"],
2748
- useCases: ["code", "chat", "reasoning"]
2749
- },
2750
- claude: {
2751
- id: "claude",
2752
- docsUrl: "https://docs.ollama.com/integrations/claude-code",
2753
- ollamaLaunch: "claude",
2754
- minGenerationTps: 12,
2755
- allowedGrades: ["S", "A", "B"],
2756
- useCases: ["code", "chat", "reasoning"]
2757
- }
2758
- };
2759
-
2760
- // src/integrations/types.ts
2761
- var INTEGRATION_IDS = ["opencode", "openclaw", "hermes", "claude"];
2762
-
2763
- // src/integrations/decide.ts
2764
- var GRADE_RANK = { S: 5, A: 4, B: 3, C: 2, D: 1, F: 0 };
2765
- function toOllamaTag(id) {
2766
- const compact = id.toLowerCase().replace(/_/g, "-");
2767
- const match = compact.match(/^([a-z0-9.]+(?:-[a-z0-9.]+)*)-(\d+(?:\.\d+)?b)$/i);
2768
- if (match) return `${match[1]}:${match[2]}`;
2769
- if (compact.includes(":")) return compact;
2770
- return compact;
2771
- }
2772
- function useCaseOk(useCases, needed) {
2773
- if (!useCases?.length) return needed.includes("chat");
2774
- return useCases.some((u) => needed.some((n) => u.includes(n)));
2775
- }
2776
- function decideLaunch(session, id) {
2777
- const def = INTEGRATIONS[id];
2778
- const reasons = [];
2779
- const ollama = session.runtimes.find((r) => r.id === "ollama")?.detected;
2780
- if (!ollama) reasons.push(t("launchNeedOllama"));
2781
- const candidates = [];
2782
- for (const row of session.rows) {
2783
- const useCases = row.catalog?.useCase ?? ["chat", "code"];
2784
- if (!useCaseOk(useCases, def.useCases)) continue;
2785
- const tps = row.lastBenchmark?.benchmark.generationTokensPerSecond;
2786
- candidates.push({
2787
- modelId: row.local.id,
2788
- ollamaTag: row.local.id.includes(":") ? row.local.id : toOllamaTag(row.local.id),
2789
- installed: true,
2790
- origin: row.lastBenchmark ? "measured" : "estimated",
2791
- grade: row.compatibility?.grade,
2792
- tps
2793
- });
2794
- }
2795
- if (!candidates.length) {
2796
- for (const model of session.catalog) {
2797
- if (!useCaseOk(model.useCase, def.useCases)) continue;
2798
- const fit = localCompatibility(session.hardware, model);
2799
- if (!def.allowedGrades.includes(fit.grade)) continue;
2800
- candidates.push({
2801
- modelId: model.id,
2802
- ollamaTag: toOllamaTag(model.id),
2803
- installed: false,
2804
- origin: "estimated",
2805
- grade: fit.grade
2806
- });
2807
- }
2808
- }
2809
- candidates.sort((a, b) => {
2810
- if (a.installed !== b.installed) return a.installed ? -1 : 1;
2811
- return (GRADE_RANK[b.grade ?? "F"] ?? 0) - (GRADE_RANK[a.grade ?? "F"] ?? 0);
2812
- });
2813
- const pick = candidates[0];
2814
- if (!pick) reasons.push(t("launchNoModel"));
2815
- if (pick?.grade && !def.allowedGrades.includes(pick.grade)) {
2816
- reasons.push(t("launchGradeFail", { grade: pick.grade, allowed: def.allowedGrades.join(",") }));
2817
- }
2818
- if (pick?.origin === "measured" && pick.tps !== void 0 && pick.tps < def.minGenerationTps) {
2819
- reasons.push(t("launchSlowFail", { tps: pick.tps.toFixed(1), min: def.minGenerationTps }));
2820
- }
2821
- if (pick && pick.origin === "estimated" && (!pick.grade || GRADE_RANK[pick.grade] < GRADE_RANK.B)) {
2822
- reasons.push(t("launchEstimateWeak"));
2823
- }
2824
- const eligible = Boolean(ollama && pick && reasons.length === 0);
2825
- const command = pick ? `ollama launch ${def.ollamaLaunch} --model ${pick.ollamaTag}` : `ollama launch ${def.ollamaLaunch}`;
2826
- return {
2827
- integration: id,
2828
- docsUrl: def.docsUrl,
2829
- eligible,
2830
- reasons: eligible ? [t("launchOk")] : reasons,
2831
- modelId: pick?.modelId,
2832
- ollamaTag: pick?.ollamaTag,
2833
- installed: pick?.installed ?? false,
2834
- origin: pick?.origin,
2835
- grade: pick?.grade,
2836
- command
2837
- };
2838
- }
2839
- function decideAll(session) {
2840
- return INTEGRATION_IDS.map((id) => decideLaunch(session, id));
2841
- }
2842
- function launchText(decisions) {
2843
- const lines = [t("launchTitle"), t("launchHint"), ""];
2844
- for (const d of decisions) {
2845
- lines.push(`${d.integration} ${d.docsUrl}`);
2846
- lines.push(` ${d.eligible ? "\u2713" : "\u25CB"} ${d.reasons.join(" ")}`);
2847
- if (d.modelId) {
2848
- lines.push(
2849
- ` ${t("launchModel")}: ${d.modelId} ${d.grade ?? "\u2014"} ${d.origin === "measured" ? t("measured") : t("estimated")} ${d.installed ? t("tasksInstalled") : t("tasksNotInstalled")}`
2850
- );
2851
- }
2852
- lines.push(` ${d.command}`);
2853
- if (d.eligible) lines.push(` ${t("launchRunHint", { tool: d.integration })}`);
2854
- else lines.push(` ${t("launchBlocked")}`);
2855
- lines.push("");
2856
- }
2857
- return lines.join("\n");
2858
- }
2859
- function parseIntegration(raw) {
2860
- if (!raw) return void 0;
2861
- const id = raw.toLowerCase();
2862
- if (id === "claude-code") return "claude";
2863
- return INTEGRATION_IDS.find((item) => item === id);
2864
- }
2865
-
2866
- // src/integrations/execute.ts
2867
- async function executeLaunch(decision) {
2868
- if (!decision.eligible || !decision.ollamaTag) {
2869
- return { ok: false, log: t("launchBlocked") };
2870
- }
2871
- const def = INTEGRATIONS[decision.integration];
2872
- if (!def) return { ok: false, log: t("launchUnknown") };
2873
- const lines = [];
2874
- if (!decision.installed) {
2875
- lines.push(t("launchPulling", { model: decision.ollamaTag }));
2876
- const pull = await runCommand("ollama", ["pull", decision.ollamaTag], { timeout: 30 * 6e4 });
2877
- if (pull.exitCode !== 0) {
2878
- return { ok: false, log: `${lines.join("\n")}
2879
- ${pull.stderr || t("launchPullFail")}` };
2880
- }
2881
- }
2882
- const args2 = ["launch", def.ollamaLaunch, "--model", decision.ollamaTag];
2883
- if (def.id === "openclaw" || def.id === "claude") args2.push("--yes");
2884
- lines.push(`ollama ${args2.join(" ")}`);
2885
- const launched = await runCommand("ollama", args2, { timeout: 12e4 });
2886
- if (launched.exitCode !== 0) {
2887
- return {
2888
- ok: false,
2889
- log: `${lines.join("\n")}
2890
- ${launched.stderr || launched.stdout || t("launchExecFail")}`
2891
- };
2892
- }
2893
- return { ok: true, log: `${lines.join("\n")}
2894
- ${launched.stdout}`.trim() };
2895
- }
2896
-
2897
3060
  // src/cli/help.ts
2898
3061
  function helpText() {
2899
3062
  return t("help");
@@ -2952,9 +3115,36 @@ ${runtimesText(session)}`),
2952
3115
  case "models":
2953
3116
  print(args2.json ? session.rows : explained("aboutModels", modelsText(session)), args2.json);
2954
3117
  return 0;
2955
- case "recommend":
2956
- print(args2.json ? session.recommendations : explained("aboutRecommend", recommendText(session)), args2.json);
3118
+ case "recommend": {
3119
+ if (args2.install && args2.yes) {
3120
+ const rec = session.recommendations.find((r) => canInstallGrade(r.grade));
3121
+ if (!rec) {
3122
+ print(t("installNone"), args2.json);
3123
+ return 1;
3124
+ }
3125
+ const pulled = await pullOllamaModel(rec.model.id);
3126
+ print(args2.json ? pulled : explained("aboutRecommend", pulled.log), args2.json);
3127
+ return pulled.ok ? 0 : 1;
3128
+ }
3129
+ if (args2.link && args2.yes) {
3130
+ const tool = parseIntegration(args2.link);
3131
+ if (!tool || tool === "claude") {
3132
+ process.stderr.write(`${t("launchNeedTool")}
3133
+ `);
3134
+ return 1;
3135
+ }
3136
+ const decision = decideLaunch(session, tool);
3137
+ if (!decision.eligible) {
3138
+ print(explained("aboutLaunch", launchText([decision])), args2.json);
3139
+ return 1;
3140
+ }
3141
+ const result = await executeLaunch(decision);
3142
+ print(args2.json ? result : explained("aboutLaunch", result.log), args2.json);
3143
+ return result.ok ? 0 : 1;
3144
+ }
3145
+ print(args2.json ? { recommendations: session.recommendations, agents: session.agents } : explained("aboutRecommend", recommendText(session)), args2.json);
2957
3146
  return 0;
3147
+ }
2958
3148
  case "launch": {
2959
3149
  const tool = parseIntegration(args2.positional[0]);
2960
3150
  const decisions = tool ? [decideLaunch(session, tool)] : decideAll(session);
@@ -3058,10 +3248,10 @@ ${runtimesText(session)}`),
3058
3248
  // src/cli/index.ts
3059
3249
  var args = parseArgs(process.argv);
3060
3250
  run(args).then((code) => {
3061
- process.exitCode = code;
3251
+ process.exit(code);
3062
3252
  }).catch((error) => {
3063
3253
  const message = error instanceof Error ? error.message : String(error);
3064
3254
  process.stderr.write(`${message}
3065
3255
  `);
3066
- process.exitCode = 1;
3256
+ process.exit(1);
3067
3257
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pudu-ai",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Pudu-AI — local AI hardware & benchmark lab for the terminal",
5
5
  "keywords": [
6
6
  "ai",