casabot 1.1.8 → 1.1.11

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/Logo.svg ADDED
@@ -0,0 +1,24 @@
1
+ <svg width="3701" height="1227" viewBox="0 0 3701 1227" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="3701" height="1227" fill="white"/>
3
+ <path d="M660.525 238.407C676.289 208.559 719.039 208.559 734.803 238.407L775.914 316.25C785.995 335.338 808.903 343.676 828.895 335.534L910.424 302.328C941.686 289.596 974.435 317.076 967.324 350.073L948.781 436.13C944.233 457.232 956.423 478.345 976.971 484.958L1060.77 511.927C1092.9 522.268 1100.33 564.369 1073.67 585.076L1004.15 639.08C987.1 652.322 982.866 676.33 994.357 694.604L1041.22 769.129C1059.18 797.705 1037.81 834.728 1004.08 833.455L916.107 830.137C894.536 829.323 875.861 844.994 872.916 866.378L860.908 953.588C856.304 987.027 816.132 1001.65 791.11 978.992L725.855 919.904C709.853 905.416 685.475 905.416 669.473 919.904L604.218 978.992C579.196 1001.65 539.024 987.027 534.42 953.588L522.412 866.378C519.467 844.994 500.792 829.323 479.221 830.137L391.252 833.455C357.521 834.728 336.145 797.705 354.113 769.129L400.971 694.604C412.461 676.33 408.228 652.322 391.181 639.08L321.659 585.076C295.002 564.369 302.426 522.268 334.557 511.927L418.357 484.958C438.905 478.345 451.094 457.232 446.547 436.13L428.004 350.073C420.893 317.076 453.642 289.596 484.904 302.328L566.433 335.534C586.425 343.676 609.333 335.338 619.414 316.25L660.525 238.407Z" fill="#EA3A23"/>
4
+ <path d="M797.472 250.022C822.885 227.806 862.796 243.126 866.816 276.641L877.3 364.047C879.871 385.479 898.27 401.473 919.851 401.036L1007.87 399.254C1041.61 398.57 1062.34 435.961 1043.88 464.218L995.724 537.914C983.917 555.985 987.73 580.063 1004.54 593.601L1073.11 648.81C1099.4 669.979 1091.25 711.944 1058.94 721.723L974.682 747.225C954.022 753.479 941.466 774.375 945.644 795.553L962.683 881.921C969.216 915.037 935.993 941.941 904.958 928.665L824.021 894.042C804.174 885.552 781.124 893.489 770.711 912.398L728.248 989.511C711.966 1019.08 669.222 1018.33 653.982 988.215L614.235 909.666C604.489 890.406 581.73 881.669 561.599 889.461L479.503 921.239C448.024 933.423 415.759 905.377 423.445 872.508L443.487 786.788C448.402 765.769 436.583 744.447 416.153 737.476L332.837 709.048C300.891 698.148 294.203 655.924 321.218 635.685L391.671 582.903C408.947 569.96 413.599 546.03 402.429 527.558L356.878 452.227C339.413 423.342 361.431 386.697 395.134 388.558L483.032 393.411C504.586 394.601 523.532 379.259 526.849 357.929L540.377 270.943C545.564 237.589 585.986 223.671 610.608 246.761L674.822 306.978C690.568 321.744 714.943 322.17 731.195 307.962L797.472 250.022Z" fill="#2BADEE" fill-opacity="0.5"/>
5
+ <circle cx="487.27" cy="557.093" r="116.956" fill="white"/>
6
+ <circle cx="871.191" cy="531.667" r="116.956" fill="white"/>
7
+ <circle cx="430.063" cy="545.651" r="34.3241" fill="black"/>
8
+ <circle cx="923.313" cy="497.343" r="34.3241" fill="black"/>
9
+ <path d="M822.883 682.312C822.883 698.756 819.645 715.039 813.352 730.232C807.059 745.424 797.835 759.228 786.208 770.856C774.58 782.484 760.776 791.707 745.583 798C730.391 804.293 714.108 807.532 697.664 807.532C681.22 807.532 664.937 804.293 649.744 798C634.552 791.707 620.748 782.484 609.12 770.856C597.493 759.228 588.269 745.424 581.976 730.232C575.683 715.039 572.444 698.756 572.444 682.312L629.702 682.312C629.702 691.237 631.46 700.075 634.875 708.32C638.291 716.566 643.297 724.058 649.607 730.369C655.918 736.68 663.41 741.686 671.656 745.101C679.902 748.516 688.739 750.274 697.664 750.274C706.589 750.274 715.426 748.516 723.672 745.101C731.917 741.686 739.41 736.68 745.72 730.369C752.031 724.058 757.037 716.566 760.453 708.32C763.868 700.075 765.626 691.237 765.626 682.312H822.883Z" fill="black"/>
10
+ <path d="M1685.16 695.25C1696.64 670.106 1732.36 670.106 1743.84 695.25V695.25C1749.56 707.757 1762.57 715.269 1776.25 713.962V713.962C1803.77 711.335 1821.63 742.271 1805.6 764.788V764.788C1797.62 775.988 1797.62 791.012 1805.6 802.212V802.212C1821.63 824.729 1803.77 855.665 1776.25 853.038V853.038C1762.57 851.731 1749.56 859.243 1743.84 871.75V871.75C1732.36 896.894 1696.64 896.894 1685.16 871.75V871.75C1679.44 859.243 1666.43 851.731 1652.75 853.038V853.038C1625.23 855.665 1607.37 824.729 1623.4 802.212V802.212C1631.38 791.012 1631.38 775.988 1623.4 764.788V764.788C1607.37 742.271 1625.23 711.335 1652.75 713.962V713.962C1666.43 715.269 1679.44 707.757 1685.16 695.25V695.25Z" fill="#EA3A23"/>
11
+ <path d="M2697.58 657.708C2710.48 633.24 2745.52 633.24 2758.42 657.708V657.708C2765.46 671.079 2780.42 678.279 2795.26 675.453V675.453C2822.44 670.28 2844.28 697.676 2833.19 723.015V723.015C2827.14 736.862 2830.83 753.042 2842.3 762.888V762.888C2863.28 780.906 2855.48 815.068 2828.76 822.197V822.197C2814.15 826.093 2803.81 839.068 2803.26 854.172V854.172C2802.26 881.813 2770.69 897.017 2748.45 880.567V880.567C2736.3 871.578 2719.7 871.578 2707.55 880.567V880.567C2685.31 897.017 2653.74 881.813 2652.74 854.172V854.172C2652.19 839.068 2641.85 826.093 2627.24 822.197V822.197C2600.52 815.068 2592.72 780.906 2613.7 762.888V762.888C2625.17 753.042 2628.86 736.862 2622.81 723.015V723.015C2611.72 697.676 2633.56 670.28 2660.74 675.453V675.453C2675.58 678.279 2690.54 671.079 2697.58 657.708V657.708Z" fill="black"/>
12
+ <path d="M3078.87 866.916C3058.12 899.232 3010.88 899.232 2990.13 866.916L2976.57 845.808C2969.43 834.689 2958.37 826.657 2945.59 823.3L2921.33 816.929C2884.18 807.174 2869.58 762.253 2893.9 732.527L2909.79 713.11C2918.15 702.882 2922.38 689.885 2921.62 676.692L2920.18 651.646C2917.98 613.302 2956.19 585.539 2991.98 599.482L3015.35 608.59C3027.67 613.388 3041.33 613.388 3053.65 608.59L3077.02 599.482C3112.81 585.539 3151.02 613.302 3148.82 651.646L3147.38 676.692C3146.62 689.885 3150.85 702.882 3159.21 713.11L3175.1 732.527C3199.42 762.253 3184.82 807.174 3147.67 816.929L3123.41 823.3C3110.63 826.657 3099.57 834.689 3092.43 845.808L3078.87 866.916Z" fill="#2BADEE"/>
13
+ <path d="M1545.44 819.589C1515.81 849.148 1478.24 869.518 1437.26 878.238C1396.29 886.957 1353.65 883.654 1314.52 868.728C1275.38 853.801 1241.41 827.888 1216.71 794.12C1192.01 760.351 1177.64 720.17 1175.33 678.43C1173.02 636.69 1182.87 595.176 1203.7 558.901C1224.52 522.625 1255.43 493.141 1292.68 474.008C1329.93 454.874 1371.94 446.911 1413.63 451.079C1455.33 455.247 1494.92 471.367 1527.62 497.495L1481.9 554.471C1460.22 537.151 1433.98 526.465 1406.34 523.702C1378.71 520.94 1350.86 526.219 1326.16 538.902C1301.47 551.584 1280.98 571.129 1267.18 595.175C1253.38 619.221 1246.84 646.74 1248.37 674.408C1249.91 702.077 1259.43 728.712 1275.8 751.096C1292.18 773.48 1314.69 790.657 1340.64 800.552C1366.58 810.446 1394.84 812.636 1422 806.856C1449.17 801.076 1474.07 787.573 1493.71 767.979L1545.44 819.589Z" fill="black"/>
14
+ <path d="M1962.5 777C1937.17 777 1912.88 766.886 1894.97 748.882C1877.06 730.879 1867 706.461 1867 681C1867 655.539 1877.06 631.121 1894.97 613.118C1912.88 595.114 1937.17 585 1962.5 585L1962.5 639.079C1951.44 639.079 1940.83 643.495 1933.01 651.357C1925.19 659.219 1920.8 669.882 1920.8 681C1920.8 692.118 1925.19 702.781 1933.01 710.643C1940.83 718.505 1951.44 722.921 1962.5 722.921L1962.5 777Z" fill="black"/>
15
+ <path d="M1962.5 701C1987.83 701 2012.12 711.062 2030.03 728.971C2047.94 746.881 2058 771.172 2058 796.5C2058 821.828 2047.94 846.119 2030.03 864.029C2012.12 881.938 1987.83 892 1962.5 892L1962.5 838.203C1973.56 838.203 1984.17 833.809 1991.99 825.989C1999.81 818.168 2004.2 807.56 2004.2 796.5C2004.2 785.44 1999.81 774.832 1991.99 767.011C1984.17 759.191 1973.56 754.797 1962.5 754.797L1962.5 701Z" fill="black"/>
16
+ <path d="M1624.31 640.094C1631.91 618.283 1647.15 599.963 1667.22 588.527C1687.28 577.092 1710.81 573.316 1733.45 577.899C1756.09 582.482 1776.3 595.113 1790.33 613.452C1804.37 631.792 1811.29 654.598 1809.8 677.646L1753.63 674.024C1754.24 664.56 1751.4 655.197 1745.64 647.667C1739.87 640.137 1731.57 634.951 1722.28 633.069C1712.99 631.188 1703.32 632.738 1695.09 637.433C1686.85 642.128 1680.59 649.65 1677.47 658.605L1624.31 640.094Z" fill="black"/>
17
+ <rect x="2148.83" y="455.492" width="75" height="420.804" transform="rotate(9 2148.83 455.492)" fill="black"/>
18
+ <rect x="2522" y="450" width="75" height="434" fill="black"/>
19
+ <rect x="3262" y="455" width="75" height="434" fill="black"/>
20
+ <rect x="3417" y="539" width="75" height="234" transform="rotate(90 3417 539)" fill="black"/>
21
+ <rect x="3417" y="817" width="75" height="155" transform="rotate(90 3417 817)" fill="black"/>
22
+ <rect x="2223" y="466.871" width="75" height="420.804" transform="rotate(-13 2223 466.871)" fill="black"/>
23
+ <rect x="2413" y="657" width="75" height="280" transform="rotate(90 2413 657)" fill="black"/>
24
+ </svg>
package/Logo2.png ADDED
Binary file
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="Logo.svg" alt="CasAbot" width="480" />
3
+ </p>
4
+
1
5
  # CasAbot
2
6
 
3
7
  > **Cassiopeia A** — Create anything freely, like a supernova explosion.
@@ -1,5 +1,5 @@
1
1
  import type { ChatProvider } from "../providers/base.js";
2
2
  import type { Message, Skill, ConversationHistory } from "../config/types.js";
3
3
  export declare function buildSystemPrompt(skills: Skill[]): string;
4
- export declare function runAgent(provider: ChatProvider, userMessage: string, conversation: ConversationHistory, skills: Skill[]): AsyncGenerator<Message>;
4
+ export declare function runAgent(provider: ChatProvider, userMessage: string, conversation: ConversationHistory, skills: Skill[], signal: AbortSignal): AsyncGenerator<Message>;
5
5
  //# sourceMappingURL=base.d.ts.map
@@ -3,6 +3,18 @@ import { appendMessage } from "../history/store.js";
3
3
  import { formatSkillsForPrompt } from "../skills/loader.js";
4
4
  import { CASABOT_HOME } from "../config/manager.js";
5
5
  const MAX_ITERATIONS = 20;
6
+ const MAX_RETRIES = 3;
7
+ const RETRY_BASE_DELAY_MS = 2000;
8
+ function raceAbort(promise, signal) {
9
+ if (signal.aborted)
10
+ return Promise.reject(new Error("AbortError"));
11
+ return Promise.race([
12
+ promise,
13
+ new Promise((_, reject) => {
14
+ signal.addEventListener("abort", () => reject(new Error("AbortError")), { once: true });
15
+ }),
16
+ ]);
17
+ }
6
18
  export function buildSystemPrompt(skills) {
7
19
  const skillList = formatSkillsForPrompt(skills);
8
20
  return `You are the base agent of CasAbot. Cassiopeia A — Freely creates everything, like a supernova explosion.
@@ -33,23 +45,47 @@ export function buildSystemPrompt(skills) {
33
45
  ## ${skillList}
34
46
  `;
35
47
  }
36
- export async function* runAgent(provider, userMessage, conversation, skills) {
48
+ export async function* runAgent(provider, userMessage, conversation, skills, signal) {
37
49
  const systemPrompt = buildSystemPrompt(skills);
38
50
  const userMsg = { role: "user", content: userMessage };
39
51
  await appendMessage(conversation, userMsg);
40
52
  const tools = [TERMINAL_TOOL];
41
53
  for (let i = 0; i < MAX_ITERATIONS; i++) {
54
+ if (signal.aborted)
55
+ return;
42
56
  const messagesWithSystem = [
43
57
  { role: "system", content: systemPrompt },
44
58
  ...conversation.messages,
45
59
  ];
46
- const assistantMsg = await provider.chat(messagesWithSystem, tools);
60
+ let assistantMsg;
61
+ for (let attempt = 0;; attempt++) {
62
+ try {
63
+ assistantMsg = await raceAbort(provider.chat(messagesWithSystem, tools), signal);
64
+ break;
65
+ }
66
+ catch (err) {
67
+ if (signal.aborted)
68
+ return;
69
+ if (attempt >= MAX_RETRIES)
70
+ throw err;
71
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
72
+ const errorMsg = err instanceof Error ? err.message : String(err);
73
+ const retryMsg = {
74
+ role: "assistant",
75
+ content: `⏳ API request failed: ${errorMsg}. Retrying in ${delay}ms... (${attempt + 1}/${MAX_RETRIES})`,
76
+ };
77
+ yield retryMsg;
78
+ await raceAbort(new Promise((resolve) => setTimeout(resolve, delay)), signal);
79
+ }
80
+ }
47
81
  await appendMessage(conversation, assistantMsg);
48
82
  yield assistantMsg;
49
83
  if (!assistantMsg.toolCalls?.length) {
50
84
  return;
51
85
  }
52
86
  for (const toolCall of assistantMsg.toolCalls) {
87
+ if (signal.aborted)
88
+ return;
53
89
  let result;
54
90
  if (toolCall.name === "run_command") {
55
91
  try {
package/dist/cli/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "module";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
2
5
  import { Command } from "commander";
3
6
  import { loadConfig, saveConfig, getDefaultConfig, ensureDirectories } from "../config/manager.js";
4
7
  import { createProvider } from "../providers/index.js";
@@ -6,6 +9,24 @@ import { loadSkills } from "../skills/loader.js";
6
9
  import { createConversation } from "../history/store.js";
7
10
  import { startTUI } from "../tui/app.js";
8
11
  import { setupWizard } from "./setup.js";
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ async function displayLogo() {
14
+ try {
15
+ const require = createRequire(import.meta.url);
16
+ const asciify = require("asciify-image");
17
+ const logoPath = join(__dirname, "..", "..", "Logo2.png");
18
+ const ascii = await asciify(logoPath, {
19
+ fit: "box",
20
+ width: "50%",
21
+ color: true,
22
+ });
23
+ console.log(ascii);
24
+ console.log("");
25
+ }
26
+ catch {
27
+ // Logo display is non-critical; silently skip on failure
28
+ }
29
+ }
9
30
  const program = new Command();
10
31
  program
11
32
  .name("casabot")
@@ -58,6 +79,7 @@ program
58
79
  const provider = createProvider(providerConfig);
59
80
  const skills = await loadSkills();
60
81
  const conversation = createConversation();
82
+ await displayLogo();
61
83
  startTUI(provider, conversation, skills);
62
84
  }
63
85
  catch (err) {
package/dist/tui/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useMemo } from "react";
2
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
3
3
  import { render, Box, Text, Static, useInput, useApp, useStdout } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
@@ -7,6 +7,9 @@ import Gradient from "ink-gradient";
7
7
  import { Marked } from "marked";
8
8
  import { markedTerminal } from "marked-terminal";
9
9
  import { runAgent } from "../agent/base.js";
10
+ import { createConversation, listConversations, saveConversation, appendMessage, } from "../history/store.js";
11
+ const BRAND_BLUE = "#2BADEE";
12
+ const BRAND_RED = "#EA3A23";
10
13
  function renderMarkdown(content) {
11
14
  const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
12
15
  const md = new Marked({ gfm: true });
@@ -20,21 +23,33 @@ function truncateOutput(content, maxLines = 8) {
20
23
  return (lines.slice(0, maxLines).join("\n") +
21
24
  `\n … ${lines.length - maxLines} more lines`);
22
25
  }
26
+ function formatDate(iso) {
27
+ const d = new Date(iso);
28
+ const pad = (n) => String(n).padStart(2, "0");
29
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
30
+ }
31
+ function getPreview(conv, maxLen = 50) {
32
+ const firstUser = conv.messages.find((m) => m.role === "user");
33
+ if (!firstUser)
34
+ return "(empty session)";
35
+ const text = firstUser.content.replace(/\n/g, " ").trim();
36
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
37
+ }
23
38
  function HRule({ columns }) {
24
39
  return (_jsx(Box, { paddingX: 1, width: columns, children: _jsx(Text, { dimColor: true, children: "─".repeat(Math.max(columns - 4, 1)) }) }));
25
40
  }
26
41
  function HeaderBlock({ columns }) {
27
- return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, width: columns, children: [_jsx(Box, { paddingX: 2, children: _jsx(Gradient, { name: "vice", children: _jsx(Text, { bold: true, children: "✦ CasAbot" }) }) }), _jsx(Box, { paddingX: 2, children: _jsx(Text, { wrap: "wrap", dimColor: true, children: "Cassiopeia A — Freely creates everything, like a supernova explosion." }) }), _jsx(HRule, { columns: columns })] }));
42
+ return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, width: columns, children: [_jsx(Box, { paddingX: 2, children: _jsx(Gradient, { colors: [BRAND_RED, BRAND_BLUE], children: _jsx(Text, { bold: true, children: "✦ CasAbot" }) }) }), _jsx(Box, { paddingX: 2, children: _jsx(Text, { wrap: "wrap", dimColor: true, children: "Cassiopeia A — Freely creates everything, like a supernova explosion." }) }), _jsx(HRule, { columns: columns })] }));
28
43
  }
29
44
  function UserMessageView({ content, columns }) {
30
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: "green", bold: true, children: "▶ You" }), _jsx(Box, { marginLeft: 2, width: Math.max(columns - 6, 10), children: _jsx(Text, { wrap: "wrap", children: content }) })] }));
45
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: BRAND_RED, bold: true, children: "▶ You" }), _jsx(Box, { marginLeft: 2, width: Math.max(columns - 6, 10), children: _jsx(Text, { wrap: "wrap", children: content }) })] }));
31
46
  }
32
47
  function AssistantMessageView({ content, columns, }) {
33
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: "cyan", bold: true, children: "✦ CasAbot" }), _jsx(Box, { marginLeft: 2, width: Math.max(columns - 6, 10), children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(content) }) })] }));
48
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: BRAND_BLUE, bold: true, children: "✦ CasAbot" }), _jsx(Box, { marginLeft: 2, width: Math.max(columns - 6, 10), children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(content) }) })] }));
34
49
  }
35
50
  function ToolCallsView({ message, columns, }) {
36
51
  const boxWidth = Math.max(columns - 6, 10);
37
- return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: "cyan", bold: true, children: "✦ CasAbot" }), message.content ? (_jsx(Box, { marginLeft: 2, width: boxWidth, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(message.content) }) })) : null, _jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, width: boxWidth, overflow: "hidden", children: [_jsx(Text, { color: "yellow", bold: true, children: "⚡ Tool Calls" }), message.toolCalls?.map((tc, i) => {
52
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, width: columns, children: [_jsx(Text, { color: BRAND_BLUE, bold: true, children: "✦ CasAbot" }), message.content ? (_jsx(Box, { marginLeft: 2, width: boxWidth, children: _jsx(Text, { wrap: "wrap", children: renderMarkdown(message.content) }) })) : null, _jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, borderStyle: "round", borderColor: BRAND_RED, paddingX: 1, width: boxWidth, overflow: "hidden", children: [_jsx(Text, { color: BRAND_RED, bold: true, children: "⚡ Tool Calls" }), message.toolCalls?.map((tc, i) => {
38
53
  let display = tc.arguments;
39
54
  try {
40
55
  const args = JSON.parse(tc.arguments);
@@ -74,60 +89,166 @@ function WelcomeHint({ columns }) {
74
89
  return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, marginBottom: 1, width: columns, children: [_jsx(Text, { dimColor: true, children: "Type a message below to get started." }), _jsx(Text, { dimColor: true, children: "CasAbot will orchestrate agents to help you." })] }));
75
90
  }
76
91
  function ProcessingIndicator({ columns }) {
77
- return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, width: columns, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: "Thinking…" })] }));
92
+ return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, width: columns, children: [_jsx(Text, { color: BRAND_BLUE, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: BRAND_BLUE, children: "Thinking…" })] }));
78
93
  }
79
- function App({ provider, conversation, skills, }) {
94
+ function HistoryBrowser({ columns, currentId, onSelect, onBack, }) {
95
+ const [conversations, setConversations] = useState([]);
96
+ const [selectedIndex, setSelectedIndex] = useState(0);
97
+ const [isLoading, setIsLoading] = useState(true);
98
+ useEffect(() => {
99
+ listConversations()
100
+ .then((convs) => {
101
+ setConversations(convs);
102
+ setIsLoading(false);
103
+ })
104
+ .catch(() => {
105
+ setIsLoading(false);
106
+ });
107
+ }, []);
108
+ useInput((ch, key) => {
109
+ if (key.escape) {
110
+ onBack();
111
+ return;
112
+ }
113
+ if (key.return && conversations.length > 0) {
114
+ onSelect(conversations[selectedIndex]);
115
+ return;
116
+ }
117
+ if (key.upArrow) {
118
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
119
+ }
120
+ if (key.downArrow && conversations.length > 0) {
121
+ setSelectedIndex((prev) => Math.min(conversations.length - 1, prev + 1));
122
+ }
123
+ });
124
+ const boxWidth = Math.max(columns - 4, 10);
125
+ return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, width: columns, children: [_jsx(Box, { paddingX: 2, children: _jsx(Gradient, { colors: [BRAND_RED, BRAND_BLUE], children: _jsx(Text, { bold: true, children: "📋 Session History" }) }) }), _jsx(HRule, { columns: columns }), isLoading ? (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: BRAND_BLUE, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: BRAND_BLUE, children: "Loading sessions…" })] })) : conversations.length === 0 ? (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No previous sessions found." }) })) : (_jsx(Box, { flexDirection: "column", marginX: 2, marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, paddingY: 1, width: boxWidth, overflow: "hidden", children: conversations.map((conv, i) => {
126
+ const isSelected = i === selectedIndex;
127
+ const isCurrent = conv.id === currentId;
128
+ const dateStr = formatDate(conv.startedAt);
129
+ const preview = getPreview(conv, Math.max(boxWidth - dateStr.length - 12, 20));
130
+ const msgCount = conv.messages.filter((m) => m.role === "user").length;
131
+ return (_jsxs(Box, { width: boxWidth - 4, children: [_jsx(Text, { color: isSelected ? BRAND_BLUE : undefined, bold: isSelected, dimColor: !isSelected, children: isSelected ? " ▶ " : " " }), _jsx(Text, { color: isSelected ? BRAND_BLUE : undefined, bold: isSelected, dimColor: !isSelected, children: dateStr }), _jsx(Text, { dimColor: !isSelected, children: " │ " }), _jsx(Text, { color: isSelected ? "white" : undefined, dimColor: !isSelected, wrap: "truncate", children: preview }), isCurrent ? (_jsx(Text, { color: BRAND_RED, bold: true, children: " (current)" })) : null, _jsx(Text, { dimColor: true, children: ` [${msgCount}]` })] }, conv.id));
132
+ }) })), _jsx(HRule, { columns: columns }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: "↑↓ navigate Enter select ESC back" }), _jsx(Text, { dimColor: true, children: `${conversations.length} sessions` })] })] }));
133
+ }
134
+ function App({ provider, conversation: initialConversation, skills, }) {
80
135
  const [messages, setMessages] = useState([]);
81
136
  const [input, setInput] = useState("");
82
137
  const [isProcessing, setIsProcessing] = useState(false);
138
+ const [mode, setMode] = useState("chat");
139
+ const conversationRef = useRef(initialConversation);
140
+ const abortControllerRef = useRef(null);
83
141
  const { exit } = useApp();
84
142
  const { stdout } = useStdout();
85
143
  const columns = stdout.columns ?? 80;
144
+ const handleCancel = useCallback(() => {
145
+ if (abortControllerRef.current) {
146
+ abortControllerRef.current.abort();
147
+ }
148
+ }, []);
86
149
  const handleSubmit = useCallback(async (text) => {
87
150
  const trimmed = text.trim();
88
151
  if (!trimmed || isProcessing)
89
152
  return;
153
+ if (trimmed === "/new") {
154
+ setInput("");
155
+ if (conversationRef.current.messages.length > 0) {
156
+ await saveConversation(conversationRef.current);
157
+ }
158
+ process.stdout.write("\x1Bc");
159
+ const newConv = createConversation();
160
+ conversationRef.current = newConv;
161
+ setMessages([]);
162
+ return;
163
+ }
164
+ if (trimmed === "/history") {
165
+ setInput("");
166
+ if (conversationRef.current.messages.length > 0) {
167
+ await saveConversation(conversationRef.current);
168
+ }
169
+ setMode("history");
170
+ return;
171
+ }
90
172
  setInput("");
91
173
  setIsProcessing(true);
92
174
  const userMsg = { role: "user", content: trimmed };
93
175
  setMessages((prev) => [...prev, userMsg]);
176
+ const controller = new AbortController();
177
+ abortControllerRef.current = controller;
94
178
  try {
95
- const generator = runAgent(provider, trimmed, conversation, skills);
179
+ const generator = runAgent(provider, trimmed, conversationRef.current, skills, controller.signal);
96
180
  for await (const msg of generator) {
181
+ if (controller.signal.aborted)
182
+ break;
97
183
  setMessages((prev) => [...prev, msg]);
98
184
  }
99
185
  }
100
186
  catch (err) {
101
- const errorMsg = err instanceof Error ? err.message : String(err);
102
- setMessages((prev) => [
103
- ...prev,
104
- { role: "assistant", content: `❌ Error: ${errorMsg}` },
105
- ]);
187
+ if (!controller.signal.aborted) {
188
+ const errorMsg = err instanceof Error ? err.message : String(err);
189
+ setMessages((prev) => [
190
+ ...prev,
191
+ { role: "assistant", content: `❌ Error: ${errorMsg}` },
192
+ ]);
193
+ }
194
+ }
195
+ if (controller.signal.aborted) {
196
+ const cancelMsg = {
197
+ role: "assistant",
198
+ content: "⏹ Cancelled.",
199
+ };
200
+ setMessages((prev) => [...prev, cancelMsg]);
201
+ await appendMessage(conversationRef.current, cancelMsg);
106
202
  }
203
+ abortControllerRef.current = null;
107
204
  setIsProcessing(false);
108
- }, [isProcessing, provider, conversation, skills]);
205
+ }, [isProcessing, provider, skills]);
206
+ const handleHistorySelect = useCallback(async (conv) => {
207
+ if (conversationRef.current.messages.length > 0) {
208
+ await saveConversation(conversationRef.current);
209
+ }
210
+ process.stdout.write("\x1Bc");
211
+ conversationRef.current = conv;
212
+ setMessages([...conv.messages]);
213
+ setMode("chat");
214
+ }, []);
109
215
  useInput((ch, key) => {
110
- if (key.ctrl && ch === "c") {
111
- exit();
216
+ if (mode !== "chat")
217
+ return;
218
+ if (isProcessing) {
219
+ if (key.escape || (key.ctrl && ch === "c")) {
220
+ handleCancel();
221
+ }
222
+ }
223
+ else {
224
+ if (key.ctrl && ch === "c") {
225
+ exit();
226
+ }
112
227
  }
113
228
  });
114
229
  const userCount = messages.filter((m) => m.role === "user").length;
230
+ const convId = conversationRef.current.id;
115
231
  const items = useMemo(() => [
116
- { key: "header", type: "header" },
232
+ { key: `header-${convId}`, type: "header" },
117
233
  ...messages.map((msg, i) => ({
118
- key: `msg-${i}`,
234
+ key: `${convId}-msg-${i}`,
119
235
  type: "message",
120
236
  message: msg,
121
237
  })),
122
- ], [messages]);
238
+ ], [messages, convId]);
239
+ if (mode === "history") {
240
+ return (_jsx(HistoryBrowser, { columns: columns, currentId: conversationRef.current.id, onSelect: handleHistorySelect, onBack: () => setMode("chat") }));
241
+ }
123
242
  return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: items, children: (item) => {
124
243
  if (item.type === "header") {
125
244
  return (_jsx(Box, { flexDirection: "column", width: columns, children: _jsx(HeaderBlock, { columns: columns }) }, item.key));
126
245
  }
127
246
  return (_jsx(Box, { flexDirection: "column", width: columns, children: _jsx(MessageView, { message: item.message, columns: columns }) }, item.key));
128
- } }), messages.length === 0 && !isProcessing && _jsx(WelcomeHint, { columns: columns }), isProcessing && _jsx(ProcessingIndicator, { columns: columns }), _jsx(HRule, { columns: columns }), _jsx(Box, { paddingX: 1, width: columns, children: _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : "cyan", paddingX: 1, width: Math.max(columns - 2, 10), overflow: "hidden", children: [_jsx(Text, { color: "cyan", bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: (val) => {
247
+ } }), messages.length === 0 && !isProcessing && _jsx(WelcomeHint, { columns: columns }), isProcessing && _jsx(ProcessingIndicator, { columns: columns }), _jsx(HRule, { columns: columns }), _jsx(Box, { paddingX: 1, width: columns, children: _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : BRAND_BLUE, paddingX: 1, width: Math.max(columns - 2, 10), overflow: "hidden", children: [_jsx(Text, { color: BRAND_BLUE, bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: (val) => {
129
248
  handleSubmit(val).catch(() => { });
130
- }, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: "Ctrl+C exit" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
249
+ }, placeholder: "Type your message\u2026", focus: !isProcessing, showCursor: true })] }) }), _jsxs(Box, { paddingX: 2, width: columns, justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: isProcessing
250
+ ? "ESC / Ctrl+C cancel"
251
+ : "/new /history Ctrl+C exit" }), _jsxs(Text, { dimColor: true, children: [userCount, " ", userCount === 1 ? "message" : "messages"] })] })] }));
131
252
  }
132
253
  export function startTUI(provider, conversation, skills) {
133
254
  render(_jsx(App, { provider: provider, conversation: conversation, skills: skills }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "casabot",
3
- "version": "1.1.8",
3
+ "version": "1.1.11",
4
4
  "description": "CasAbot — Skill-driven multi-agent orchestrator system",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,6 +28,7 @@
28
28
  "license": "Apache-2.0",
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sdk": "^0.52.0",
31
+ "asciify-image": "^0.1.10",
31
32
  "chalk": "^5.4.1",
32
33
  "commander": "^13.1.0",
33
34
  "gray-matter": "^4.0.3",
@@ -17,15 +17,62 @@ This manual explains how the base agent creates and manages podman-based sub-age
17
17
 
18
18
  podman must be installed before creating sub-agents.
19
19
 
20
+ ### Step 1: Check if podman is already installed
21
+
20
22
  ```bash
21
- # Check installation
22
- which podman
23
+ which podman && podman --version
24
+ ```
25
+
26
+ If podman is found, skip to Section 2.
27
+
28
+ ### Step 2: Gather requirements from the user
29
+
30
+ > **Important:** Before installing podman, you **must** ask the user the following questions. Do not assume or proceed without their answers.
31
+
32
+ 1. **Detect or ask the distro:**
33
+ ```bash
34
+ cat /etc/os-release
35
+ ```
36
+ If the distro cannot be determined, ask the user: *"Which Linux distribution are you using? (e.g. Ubuntu, Fedora, Arch, Debian, RHEL, etc.)"*
23
37
 
24
- # If not installed (Debian/Ubuntu)
38
+ 2. **Ask about sudo privileges:**
39
+ *"Do you have sudo (root) privileges on this system?"*
40
+ — If the user does not have sudo, guide them to request it from an administrator, or suggest rootless podman setup if possible.
41
+
42
+ 3. **Ask about rootless mode:**
43
+ *"Would you like to run podman in rootless mode (recommended for security)?"*
44
+
45
+ 4. **Ask about special requirements:**
46
+ *"Do you have any specific requirements? (e.g. a particular podman version, a custom storage location, proxy settings, etc.)"*
47
+
48
+ ### Step 3: Install podman based on user's answers
49
+
50
+ ```bash
51
+ # Debian / Ubuntu
25
52
  sudo apt update && sudo apt install -y podman
26
53
 
27
- # If not installed (Fedora/RHEL)
54
+ # Fedora / RHEL / CentOS
28
55
  sudo dnf install -y podman
56
+
57
+ # Arch Linux
58
+ sudo pacman -S podman
59
+
60
+ # openSUSE
61
+ sudo zypper install -y podman
62
+ ```
63
+
64
+ If the user requested rootless mode, also configure subuid/subgid:
65
+
66
+ ```bash
67
+ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)
68
+ podman system migrate
69
+ ```
70
+
71
+ ### Step 4: Verify installation
72
+
73
+ ```bash
74
+ podman --version
75
+ podman info
29
76
  ```
30
77
 
31
78
  ## 2. Configure podman storage
package/src/agent/base.ts CHANGED
@@ -6,6 +6,22 @@ import { formatSkillsForPrompt } from "../skills/loader.js";
6
6
  import { CASABOT_HOME } from "../config/manager.js";
7
7
 
8
8
  const MAX_ITERATIONS = 20;
9
+ const MAX_RETRIES = 3;
10
+ const RETRY_BASE_DELAY_MS = 2000;
11
+
12
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
13
+ if (signal.aborted) return Promise.reject(new Error("AbortError"));
14
+ return Promise.race([
15
+ promise,
16
+ new Promise<never>((_, reject) => {
17
+ signal.addEventListener(
18
+ "abort",
19
+ () => reject(new Error("AbortError")),
20
+ { once: true },
21
+ );
22
+ }),
23
+ ]);
24
+ }
9
25
 
10
26
  export function buildSystemPrompt(skills: Skill[]): string {
11
27
  const skillList = formatSkillsForPrompt(skills);
@@ -44,6 +60,7 @@ export async function* runAgent(
44
60
  userMessage: string,
45
61
  conversation: ConversationHistory,
46
62
  skills: Skill[],
63
+ signal: AbortSignal,
47
64
  ): AsyncGenerator<Message> {
48
65
  const systemPrompt = buildSystemPrompt(skills);
49
66
 
@@ -53,11 +70,34 @@ export async function* runAgent(
53
70
  const tools = [TERMINAL_TOOL];
54
71
 
55
72
  for (let i = 0; i < MAX_ITERATIONS; i++) {
73
+ if (signal.aborted) return;
74
+
56
75
  const messagesWithSystem: Message[] = [
57
76
  { role: "system", content: systemPrompt },
58
77
  ...conversation.messages,
59
78
  ];
60
- const assistantMsg = await provider.chat(messagesWithSystem, tools);
79
+
80
+ let assistantMsg: Message;
81
+ for (let attempt = 0; ; attempt++) {
82
+ try {
83
+ assistantMsg = await raceAbort(provider.chat(messagesWithSystem, tools), signal);
84
+ break;
85
+ } catch (err: unknown) {
86
+ if (signal.aborted) return;
87
+ if (attempt >= MAX_RETRIES) throw err;
88
+
89
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
90
+ const errorMsg = err instanceof Error ? err.message : String(err);
91
+ const retryMsg: Message = {
92
+ role: "assistant",
93
+ content: `⏳ API request failed: ${errorMsg}. Retrying in ${delay}ms... (${attempt + 1}/${MAX_RETRIES})`,
94
+ };
95
+ yield retryMsg;
96
+
97
+ await raceAbort(new Promise((resolve) => setTimeout(resolve, delay)), signal);
98
+ }
99
+ }
100
+
61
101
  await appendMessage(conversation, assistantMsg);
62
102
  yield assistantMsg;
63
103
 
@@ -66,6 +106,8 @@ export async function* runAgent(
66
106
  }
67
107
 
68
108
  for (const toolCall of assistantMsg.toolCalls) {
109
+ if (signal.aborted) return;
110
+
69
111
  let result: string;
70
112
 
71
113
  if (toolCall.name === "run_command") {
package/src/cli/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from "module";
4
+ import { fileURLToPath } from "url";
5
+ import { dirname, join } from "path";
3
6
  import { Command } from "commander";
4
7
  import { loadConfig, saveConfig, getDefaultConfig, ensureDirectories } from "../config/manager.js";
5
8
  import { createProvider } from "../providers/index.js";
@@ -8,6 +11,28 @@ import { createConversation } from "../history/store.js";
8
11
  import { startTUI } from "../tui/app.js";
9
12
  import { setupWizard } from "./setup.js";
10
13
 
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+
16
+ async function displayLogo(): Promise<void> {
17
+ try {
18
+ const require = createRequire(import.meta.url);
19
+ const asciify = require("asciify-image") as (
20
+ path: string,
21
+ opts: Record<string, unknown>,
22
+ ) => Promise<string>;
23
+ const logoPath = join(__dirname, "..", "..", "Logo2.png");
24
+ const ascii = await asciify(logoPath, {
25
+ fit: "box",
26
+ width: "50%",
27
+ color: true,
28
+ });
29
+ console.log(ascii);
30
+ console.log("");
31
+ } catch {
32
+ // Logo display is non-critical; silently skip on failure
33
+ }
34
+ }
35
+
11
36
  const program = new Command();
12
37
 
13
38
  program
@@ -70,6 +95,7 @@ program
70
95
  const skills = await loadSkills();
71
96
  const conversation = createConversation();
72
97
 
98
+ await displayLogo();
73
99
  startTUI(provider, conversation, skills);
74
100
  } catch (err: unknown) {
75
101
  const msg = err instanceof Error ? err.message : String(err);
package/src/tui/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback, useMemo } from "react";
1
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
2
2
  import { render, Box, Text, Static, useInput, useApp, useStdout } from "ink";
3
3
  import TextInput from "ink-text-input";
4
4
  import Spinner from "ink-spinner";
@@ -9,6 +9,16 @@ import type { ChatProvider } from "../providers/base.js";
9
9
  import type { ConversationHistory, Message, Skill } from "../config/types.js";
10
10
  import { runAgent } from "../agent/base.js";
11
11
 
12
+ import {
13
+ createConversation,
14
+ listConversations,
15
+ saveConversation,
16
+ appendMessage,
17
+ } from "../history/store.js";
18
+
19
+ const BRAND_BLUE = "#2BADEE";
20
+ const BRAND_RED = "#EA3A23";
21
+
12
22
  function renderMarkdown(content: string): string {
13
23
  const width = Math.max((process.stdout.columns ?? 80) - 8, 40);
14
24
  const md = new Marked({ gfm: true });
@@ -25,6 +35,19 @@ function truncateOutput(content: string, maxLines = 8): string {
25
35
  );
26
36
  }
27
37
 
38
+ function formatDate(iso: string): string {
39
+ const d = new Date(iso);
40
+ const pad = (n: number) => String(n).padStart(2, "0");
41
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
42
+ }
43
+
44
+ function getPreview(conv: ConversationHistory, maxLen = 50): string {
45
+ const firstUser = conv.messages.find((m) => m.role === "user");
46
+ if (!firstUser) return "(empty session)";
47
+ const text = firstUser.content.replace(/\n/g, " ").trim();
48
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text;
49
+ }
50
+
28
51
  function HRule({ columns }: { columns: number }): React.ReactElement {
29
52
  return (
30
53
  <Box paddingX={1} width={columns}>
@@ -37,7 +60,7 @@ function HeaderBlock({ columns }: { columns: number }): React.ReactElement {
37
60
  return (
38
61
  <Box flexDirection="column" paddingTop={1} width={columns}>
39
62
  <Box paddingX={2}>
40
- <Gradient name="vice">
63
+ <Gradient colors={[BRAND_RED, BRAND_BLUE]}>
41
64
  <Text bold>{"✦ CasAbot"}</Text>
42
65
  </Gradient>
43
66
  </Box>
@@ -54,7 +77,7 @@ function HeaderBlock({ columns }: { columns: number }): React.ReactElement {
54
77
  function UserMessageView({ content, columns }: { content: string; columns: number }): React.ReactElement {
55
78
  return (
56
79
  <Box flexDirection="column" paddingX={2} marginTop={1} width={columns}>
57
- <Text color="green" bold>
80
+ <Text color={BRAND_RED} bold>
58
81
  {"▶ You"}
59
82
  </Text>
60
83
  <Box marginLeft={2} width={Math.max(columns - 6, 10)}>
@@ -73,7 +96,7 @@ function AssistantMessageView({
73
96
  }): React.ReactElement {
74
97
  return (
75
98
  <Box flexDirection="column" paddingX={2} marginTop={1} width={columns}>
76
- <Text color="cyan" bold>
99
+ <Text color={BRAND_BLUE} bold>
77
100
  {"✦ CasAbot"}
78
101
  </Text>
79
102
  <Box marginLeft={2} width={Math.max(columns - 6, 10)}>
@@ -93,7 +116,7 @@ function ToolCallsView({
93
116
  const boxWidth = Math.max(columns - 6, 10);
94
117
  return (
95
118
  <Box flexDirection="column" paddingX={2} marginTop={1} width={columns}>
96
- <Text color="cyan" bold>
119
+ <Text color={BRAND_BLUE} bold>
97
120
  {"✦ CasAbot"}
98
121
  </Text>
99
122
  {message.content ? (
@@ -106,12 +129,12 @@ function ToolCallsView({
106
129
  marginLeft={2}
107
130
  marginTop={1}
108
131
  borderStyle="round"
109
- borderColor="yellow"
132
+ borderColor={BRAND_RED}
110
133
  paddingX={1}
111
134
  width={boxWidth}
112
135
  overflow="hidden"
113
136
  >
114
- <Text color="yellow" bold>
137
+ <Text color={BRAND_RED} bold>
115
138
  {"⚡ Tool Calls"}
116
139
  </Text>
117
140
  {message.toolCalls?.map((tc, i) => {
@@ -200,10 +223,143 @@ function WelcomeHint({ columns }: { columns: number }): React.ReactElement {
200
223
  function ProcessingIndicator({ columns }: { columns: number }): React.ReactElement {
201
224
  return (
202
225
  <Box paddingX={2} marginTop={1} gap={1} width={columns}>
203
- <Text color="yellow">
226
+ <Text color={BRAND_BLUE}>
204
227
  <Spinner type="dots" />
205
228
  </Text>
206
- <Text color="yellow">{"Thinking…"}</Text>
229
+ <Text color={BRAND_BLUE}>{"Thinking…"}</Text>
230
+ </Box>
231
+ );
232
+ }
233
+
234
+ interface HistoryBrowserProps {
235
+ columns: number;
236
+ currentId: string;
237
+ onSelect: (conversation: ConversationHistory) => void;
238
+ onBack: () => void;
239
+ }
240
+
241
+ function HistoryBrowser({
242
+ columns,
243
+ currentId,
244
+ onSelect,
245
+ onBack,
246
+ }: HistoryBrowserProps): React.ReactElement {
247
+ const [conversations, setConversations] = useState<ConversationHistory[]>([]);
248
+ const [selectedIndex, setSelectedIndex] = useState(0);
249
+ const [isLoading, setIsLoading] = useState(true);
250
+
251
+ useEffect(() => {
252
+ listConversations()
253
+ .then((convs) => {
254
+ setConversations(convs);
255
+ setIsLoading(false);
256
+ })
257
+ .catch(() => {
258
+ setIsLoading(false);
259
+ });
260
+ }, []);
261
+
262
+ useInput((ch, key) => {
263
+ if (key.escape) {
264
+ onBack();
265
+ return;
266
+ }
267
+ if (key.return && conversations.length > 0) {
268
+ onSelect(conversations[selectedIndex]);
269
+ return;
270
+ }
271
+ if (key.upArrow) {
272
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
273
+ }
274
+ if (key.downArrow && conversations.length > 0) {
275
+ setSelectedIndex((prev) => Math.min(conversations.length - 1, prev + 1));
276
+ }
277
+ });
278
+
279
+ const boxWidth = Math.max(columns - 4, 10);
280
+
281
+ return (
282
+ <Box flexDirection="column" paddingTop={1} width={columns}>
283
+ <Box paddingX={2}>
284
+ <Gradient colors={[BRAND_RED, BRAND_BLUE]}>
285
+ <Text bold>{"📋 Session History"}</Text>
286
+ </Gradient>
287
+ </Box>
288
+
289
+ <HRule columns={columns} />
290
+
291
+ {isLoading ? (
292
+ <Box paddingX={2} marginTop={1} gap={1}>
293
+ <Text color={BRAND_BLUE}>
294
+ <Spinner type="dots" />
295
+ </Text>
296
+ <Text color={BRAND_BLUE}>{"Loading sessions…"}</Text>
297
+ </Box>
298
+ ) : conversations.length === 0 ? (
299
+ <Box paddingX={2} marginTop={1}>
300
+ <Text dimColor>{"No previous sessions found."}</Text>
301
+ </Box>
302
+ ) : (
303
+ <Box
304
+ flexDirection="column"
305
+ marginX={2}
306
+ marginTop={1}
307
+ borderStyle="round"
308
+ borderColor="gray"
309
+ paddingX={1}
310
+ paddingY={1}
311
+ width={boxWidth}
312
+ overflow="hidden"
313
+ >
314
+ {conversations.map((conv, i) => {
315
+ const isSelected = i === selectedIndex;
316
+ const isCurrent = conv.id === currentId;
317
+ const dateStr = formatDate(conv.startedAt);
318
+ const preview = getPreview(conv, Math.max(boxWidth - dateStr.length - 12, 20));
319
+ const msgCount = conv.messages.filter((m) => m.role === "user").length;
320
+
321
+ return (
322
+ <Box key={conv.id} width={boxWidth - 4}>
323
+ <Text
324
+ color={isSelected ? BRAND_BLUE : undefined}
325
+ bold={isSelected}
326
+ dimColor={!isSelected}
327
+ >
328
+ {isSelected ? " ▶ " : " "}
329
+ </Text>
330
+ <Text
331
+ color={isSelected ? BRAND_BLUE : undefined}
332
+ bold={isSelected}
333
+ dimColor={!isSelected}
334
+ >
335
+ {dateStr}
336
+ </Text>
337
+ <Text dimColor={!isSelected}>{" │ "}</Text>
338
+ <Text
339
+ color={isSelected ? "white" : undefined}
340
+ dimColor={!isSelected}
341
+ wrap="truncate"
342
+ >
343
+ {preview}
344
+ </Text>
345
+ {isCurrent ? (
346
+ <Text color={BRAND_RED} bold>{" (current)"}</Text>
347
+ ) : null}
348
+ <Text dimColor>
349
+ {` [${msgCount}]`}
350
+ </Text>
351
+ </Box>
352
+ );
353
+ })}
354
+ </Box>
355
+ )}
356
+
357
+ <HRule columns={columns} />
358
+
359
+ <Box paddingX={2} width={columns} justifyContent="space-between">
360
+ <Text dimColor>{"↑↓ navigate Enter select ESC back"}</Text>
361
+ <Text dimColor>{`${conversations.length} sessions`}</Text>
362
+ </Box>
207
363
  </Box>
208
364
  );
209
365
  }
@@ -212,6 +368,8 @@ type DisplayItem =
212
368
  | { key: string; type: "header" }
213
369
  | { key: string; type: "message"; message: Message };
214
370
 
371
+ type AppMode = "chat" | "history";
372
+
215
373
  interface AppProps {
216
374
  provider: ChatProvider;
217
375
  conversation: ConversationHistory;
@@ -220,61 +378,149 @@ interface AppProps {
220
378
 
221
379
  function App({
222
380
  provider,
223
- conversation,
381
+ conversation: initialConversation,
224
382
  skills,
225
383
  }: AppProps): React.ReactElement {
226
384
  const [messages, setMessages] = useState<Message[]>([]);
227
385
  const [input, setInput] = useState("");
228
386
  const [isProcessing, setIsProcessing] = useState(false);
387
+ const [mode, setMode] = useState<AppMode>("chat");
388
+ const conversationRef = useRef<ConversationHistory>(initialConversation);
389
+ const abortControllerRef = useRef<AbortController | null>(null);
229
390
  const { exit } = useApp();
230
391
  const { stdout } = useStdout();
231
392
  const columns = stdout.columns ?? 80;
232
393
 
394
+ const handleCancel = useCallback(() => {
395
+ if (abortControllerRef.current) {
396
+ abortControllerRef.current.abort();
397
+ }
398
+ }, []);
399
+
233
400
  const handleSubmit = useCallback(
234
401
  async (text: string) => {
235
402
  const trimmed = text.trim();
236
403
  if (!trimmed || isProcessing) return;
237
404
 
405
+ if (trimmed === "/new") {
406
+ setInput("");
407
+ if (conversationRef.current.messages.length > 0) {
408
+ await saveConversation(conversationRef.current);
409
+ }
410
+ process.stdout.write("\x1Bc");
411
+ const newConv = createConversation();
412
+ conversationRef.current = newConv;
413
+ setMessages([]);
414
+ return;
415
+ }
416
+
417
+ if (trimmed === "/history") {
418
+ setInput("");
419
+ if (conversationRef.current.messages.length > 0) {
420
+ await saveConversation(conversationRef.current);
421
+ }
422
+ setMode("history");
423
+ return;
424
+ }
425
+
238
426
  setInput("");
239
427
  setIsProcessing(true);
240
428
 
241
429
  const userMsg: Message = { role: "user", content: trimmed };
242
430
  setMessages((prev) => [...prev, userMsg]);
243
431
 
432
+ const controller = new AbortController();
433
+ abortControllerRef.current = controller;
434
+
244
435
  try {
245
- const generator = runAgent(provider, trimmed, conversation, skills);
436
+ const generator = runAgent(
437
+ provider,
438
+ trimmed,
439
+ conversationRef.current,
440
+ skills,
441
+ controller.signal,
442
+ );
246
443
  for await (const msg of generator) {
444
+ if (controller.signal.aborted) break;
247
445
  setMessages((prev) => [...prev, msg]);
248
446
  }
249
447
  } catch (err: unknown) {
250
- const errorMsg = err instanceof Error ? err.message : String(err);
251
- setMessages((prev) => [
252
- ...prev,
253
- { role: "assistant", content: `❌ Error: ${errorMsg}` },
254
- ]);
448
+ if (!controller.signal.aborted) {
449
+ const errorMsg = err instanceof Error ? err.message : String(err);
450
+ setMessages((prev) => [
451
+ ...prev,
452
+ { role: "assistant", content: `❌ Error: ${errorMsg}` },
453
+ ]);
454
+ }
255
455
  }
256
456
 
457
+ if (controller.signal.aborted) {
458
+ const cancelMsg: Message = {
459
+ role: "assistant",
460
+ content: "⏹ Cancelled.",
461
+ };
462
+ setMessages((prev) => [...prev, cancelMsg]);
463
+ await appendMessage(conversationRef.current, cancelMsg);
464
+ }
465
+
466
+ abortControllerRef.current = null;
257
467
  setIsProcessing(false);
258
468
  },
259
- [isProcessing, provider, conversation, skills],
469
+ [isProcessing, provider, skills],
260
470
  );
261
471
 
472
+ const handleHistorySelect = useCallback(async (conv: ConversationHistory) => {
473
+ if (conversationRef.current.messages.length > 0) {
474
+ await saveConversation(conversationRef.current);
475
+ }
476
+ process.stdout.write("\x1Bc");
477
+ conversationRef.current = conv;
478
+ setMessages([...conv.messages]);
479
+ setMode("chat");
480
+ }, []);
481
+
262
482
  useInput((ch, key) => {
263
- if (key.ctrl && ch === "c") {
264
- exit();
483
+ if (mode !== "chat") return;
484
+
485
+ if (isProcessing) {
486
+ if (key.escape || (key.ctrl && ch === "c")) {
487
+ handleCancel();
488
+ }
489
+ } else {
490
+ if (key.ctrl && ch === "c") {
491
+ exit();
492
+ }
265
493
  }
266
494
  });
267
495
 
268
496
  const userCount = messages.filter((m) => m.role === "user").length;
269
497
 
270
- const items = useMemo((): DisplayItem[] => [
271
- { key: "header", type: "header" },
272
- ...messages.map((msg, i): DisplayItem => ({
273
- key: `msg-${i}`,
274
- type: "message",
275
- message: msg,
276
- })),
277
- ], [messages]);
498
+ const convId = conversationRef.current.id;
499
+
500
+ const items = useMemo(
501
+ (): DisplayItem[] => [
502
+ { key: `header-${convId}`, type: "header" },
503
+ ...messages.map(
504
+ (msg, i): DisplayItem => ({
505
+ key: `${convId}-msg-${i}`,
506
+ type: "message",
507
+ message: msg,
508
+ }),
509
+ ),
510
+ ],
511
+ [messages, convId],
512
+ );
513
+
514
+ if (mode === "history") {
515
+ return (
516
+ <HistoryBrowser
517
+ columns={columns}
518
+ currentId={conversationRef.current.id}
519
+ onSelect={handleHistorySelect}
520
+ onBack={() => setMode("chat")}
521
+ />
522
+ );
523
+ }
278
524
 
279
525
  return (
280
526
  <Box flexDirection="column" width={columns}>
@@ -303,12 +549,12 @@ function App({
303
549
  <Box paddingX={1} width={columns}>
304
550
  <Box
305
551
  borderStyle="round"
306
- borderColor={isProcessing ? "gray" : "cyan"}
552
+ borderColor={isProcessing ? "gray" : BRAND_BLUE}
307
553
  paddingX={1}
308
554
  width={Math.max(columns - 2, 10)}
309
555
  overflow="hidden"
310
556
  >
311
- <Text color="cyan" bold>
557
+ <Text color={BRAND_BLUE} bold>
312
558
  {"❯ "}
313
559
  </Text>
314
560
  <TextInput
@@ -325,7 +571,11 @@ function App({
325
571
  </Box>
326
572
 
327
573
  <Box paddingX={2} width={columns} justifyContent="space-between">
328
- <Text dimColor>{"Ctrl+C exit"}</Text>
574
+ <Text dimColor>
575
+ {isProcessing
576
+ ? "ESC / Ctrl+C cancel"
577
+ : "/new /history Ctrl+C exit"}
578
+ </Text>
329
579
  <Text dimColor>
330
580
  {userCount} {userCount === 1 ? "message" : "messages"}
331
581
  </Text>