From 8addb74a1a20c861daaa5a2ceaf38a68b8fcd947 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sun, 26 Jul 2026 13:35:59 -0400 Subject: [PATCH] ui: brand palette, rendered markdown, and a vertical session nav Repaint the whole console in the Ellipsis dark palette, render agent markdown as styled ANSI, and turn the horizontal session bar into a vertical list. Ink 7 / React 19 and SDK 0.4.0 come along for the ride. --- bun.lock | 145 +++++++-- package.json | 19 +- src/commands/connect.ts | 5 + src/lib/facts.ts | 112 +++++++ src/lib/markdown.ts | Bin 0 -> 9273 bytes src/lib/sessions.ts | 134 ++++++-- src/lib/theme.ts | 53 ++++ src/marked-terminal.d.ts | 6 + src/ui/ConnectApp.tsx | 392 +++++++++++++---------- src/ui/SessionsApp.tsx | 660 +++++++++++++++++++++++---------------- src/ui/launch.tsx | 10 +- test/connect-app.test.ts | 89 +++--- test/facts.test.ts | 23 ++ test/markdown.test.ts | 108 +++++++ test/sessions.test.ts | 122 +++++++- tsup.config.ts | 2 +- 16 files changed, 1324 insertions(+), 556 deletions(-) create mode 100644 src/lib/facts.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/theme.ts create mode 100644 src/marked-terminal.d.ts create mode 100644 test/facts.test.ts create mode 100644 test/markdown.test.ts diff --git a/bun.lock b/bun.lock index 9db0533..87f0e0b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,19 +5,24 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.3.0", + "@ellipsis-dev/sdk": "^0.4.0", + "chalk": "^5.6.2", + "cli-table3": "^0.6.5", "commander": "^12.1.0", - "ink": "^5.0.1", - "ink-spinner": "^5.0.0", - "react": "^18.3.1", + "ink": "^7.1.1", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0", + "react": "^19.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yaml": "^2.9.0", }, "devDependencies": { "@types/node": "^22.5.0", - "@types/react": "^18.3.0", + "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "react-devtools-core": "^4.28.5", + "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", "typescript": "^5.6.0", @@ -26,9 +31,11 @@ }, }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.3.0", "", {}, "sha512-b7Q5Vzkny/bHYtmwVrLyNt8LxwOVHCg2riawYT6l+FmH+IywMPmnQciuF3uTELlSZ71WfE2nmTgkk8Sex+em5A=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.4.0", "", {}, "sha512-9BdMBn7bDOsffJ9i8EyPxchBMnxOqxWwCzTJgIZOQHu6PE6VgIQ4MrBs1FzFx4nrVmLYmpR9hHEmwnsRoX4X4g=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -140,13 +147,13 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], - - "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -186,20 +193,30 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="], - "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], @@ -214,7 +231,9 @@ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -224,6 +243,8 @@ "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -236,21 +257,23 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" } }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "ink": ["ink@7.1.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w=="], - "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": "cli.js" }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -258,12 +281,14 @@ "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -274,10 +299,16 @@ "nanoid": ["nanoid@3.3.15", "", { "bin": "bin/nanoid.cjs" }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], @@ -296,21 +327,23 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-devtools-core": ["react-devtools-core@4.28.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA=="], + "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], @@ -318,7 +351,9 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + + "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -330,12 +365,20 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -360,7 +403,7 @@ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], - "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -368,6 +411,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": "vite-node.mjs" }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], @@ -376,32 +421,64 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], + + "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + "@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cli-truncate/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ink/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": "bin/esbuild" }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "widest-line/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "wrap-ansi/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], diff --git a/package.json b/package.json index 7baaeee..17bb34e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "//": "Not an npm package. The CLI ships only as a Bun-compiled single binary via the Homebrew tap (see .github/workflows/release.yml). `private` blocks accidental `npm publish`; there is deliberately no `bin` so `npm i -g .` can't install a stale global that shadows the brew binary.", "private": true, "engines": { - "node": ">=18" + "node": ">=22" }, "scripts": { "dev": "tsx src/cli.tsx", @@ -20,19 +20,24 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.3.0", + "@ellipsis-dev/sdk": "^0.4.0", + "chalk": "^5.6.2", + "cli-table3": "^0.6.5", "commander": "^12.1.0", - "ink": "^5.0.1", - "ink-spinner": "^5.0.0", - "react": "^18.3.1", + "ink": "^7.1.1", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0", + "react": "^19.2.0", + "strip-ansi": "^7.2.0", + "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^22.5.0", - "@types/react": "^18.3.0", + "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "react-devtools-core": "^4.28.5", + "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", "typescript": "^5.6.0", diff --git a/src/commands/connect.ts b/src/commands/connect.ts index bd09a03..c4bb8c7 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -159,6 +159,11 @@ export async function runConnect( configName: config, exitState, }), + // Render every frame as it happens even without a TTY. Ink's default is to + // buffer and emit only the final frame off-TTY, which would silence the + // documented headless path (`--no-input` piped into a script or an agent) + // for the entire life of the session. + { interactive: true }, ) // Guard against the revoked-TTY spin. When the controlling terminal is torn // down abruptly (terminal app force-quit/crash, SSH drop, login-session diff --git a/src/lib/facts.ts b/src/lib/facts.ts new file mode 100644 index 0000000..9dd085d --- /dev/null +++ b/src/lib/facts.ts @@ -0,0 +1,112 @@ +// The trivia shown under "What are we shipping today?" on the new-session +// pane — one fact, picked at random each time the pane mounts. Entertaining +// over educational; software engineering and CS lore, light on math and +// physics. One line per fact. +export const FACTS: readonly string[] = [ + 'The first computer "bug" was literal: a moth taped into the Harvard Mark II logbook in 1947, with the note "first actual case of bug being found."', + 'Python is named after Monty Python, not the snake. Guido van Rossum was reading the scripts while building it.', + 'A 1978 spam email sent to 393 ARPANET users is considered the first spam ever. People were furious. It reportedly sold $13 million in DEC computers anyway.', + 'The Apollo 11 guidance computer had about 4KB of RAM. Your terminal prompt is using more memory than the moon landing did.', + 'In 1996, a $370 million Ariane 5 rocket exploded because of a 64-bit float being stuffed into a 16-bit integer.', + '"Ada", the language, is named after Ada Lovelace, who wrote the first published algorithm in 1843 for a computer that was never built.', + 'Linus Torvalds also wrote Git in about two weeks, mostly because he was annoyed at every existing version control system.', + "The QWERTY layout was designed partly to slow typists down so mechanical typewriter arms wouldn't jam. We just never stopped using it.", + 'NASA still flies code written in the 1970s: the Voyager probes get patches from billions of miles away, at 160 bits per second.', + 'The "404" error is just HTTP\'s fourth class of status code, fourth entry. The legend about a "room 404 at CERN" is itself a 404: not found.', + "Windows 95 had a timer bug that crashed the OS after exactly 49.7 days of uptime. Nobody noticed for years because nobody's Windows 95 stayed up that long.", + "The first webcam watched a coffee pot. Cambridge researchers pointed a camera at it so they wouldn't walk downstairs to an empty pot.", + 'JavaScript was built in 10 days. It now runs most of the visible internet. Sleep well.', + 'A single bit flipped by a cosmic ray once gave a Belgian election candidate 4,096 extra votes. The universe has write access to your RAM.', + 'There\'s a number so illegal that publishing it was a crime: the AACS encryption key. People printed it on t-shirts anyway.', + 'The average pencil can draw a line 35 miles long. The average programmer can debug a line for 35 miles before noticing the typo.', + 'Grace Hopper handed out foot-long wires to explain a nanosecond: that\'s how far light travels in one. She kept a "microsecond" coil of wire that was 984 feet.', + 'In binary, 42 is 101010. Douglas Adams claimed the choice was a joke with no meaning. Fans refuse to believe him to this day.', + 'The Y2K bug cost an estimated $300+ billion to fix, worked, and is now remembered as a hoax because it worked.', + 'TCP\'s three-way handshake is basically: "Can you hear me?" "I can hear you, can you hear me?" "I can hear you." Every website you open starts with this tiny trust exercise.', + 'Ctrl-C sends a signal called SIGINT. It sounds like a spy agency, and it does terminate people\'s jobs.', + '"Bluetooth" is named after a 10th-century Viking king, Harald Bluetooth, who united Denmark and Norway. The logo is his initials in runes.', + 'The first hard drive shipped in 1956. It stored 5MB, weighed about a ton, and was moved by forklift. IBM leased it for $3,200 a month.', + 'Vim\'s license asks you to consider donating to orphans in Uganda. Its author, Bram Moolenaar, called it "charityware."', + 'The save icon is a floppy disk. Most people who click it have never touched one.', + 'Email is older than the web by about 20 years. People had inbox anxiety before they had browsers.', + 'The word "robot" comes from the Czech word for forced labor. It first appeared in a 1920 play where the robots revolt. We used the name anyway.', + 'A group of flamingos is called a flamboyance. A group of programmers is called a merge conflict.', + 'A race condition in the Therac-25 radiation machine hurt real patients in the 1980s. It is why safety code reviewers are so picky. They have a good reason.', + 'Dennis Ritchie co-created C and Unix. He died the same week as Steve Jobs and got a fraction of the news coverage. Every tribute to Jobs was typed on Ritchie\'s work.', + 'Ada Lovelace was asked to translate a paper about the Analytical Engine. Her notes ended up three times longer than the paper. The first case of scope creep.', + '"Debugging is twice as hard as writing the code in the first place," wrote Brian Kernighan. So if you write the cleverest possible code, you cannot debug it.', + 'The Unix epoch began January 1, 1970. On January 19, 2038, 32-bit timestamps overflow. Y2K fans call it "the sequel."', + 'The @ symbol was a dying accounting character until 1971. Ray Tomlinson picked it to separate user from machine in the first email address.', + 'Google was misspelled. The founders meant "googol," the number 1 followed by 100 zeros, and registered the typo.', + 'The first banner ad ran in 1994. It said "Have you ever clicked your mouse right HERE?" and 44% of people clicked. It has been downhill ever since.', + 'Amazon found that every extra 100ms of page load time cost them about 1% in sales. Milliseconds have a salary.', + 'Space Invaders sped up as you killed aliens because the hardware had less to draw. The bug became the difficulty curve.', + 'Pac-Man breaks on level 256 because the level counter is a single byte. The kill screen is an integer overflow you can visit.', + 'Walk far enough in old Minecraft and the terrain turned to glitchy chaos, the "Far Lands." That was floating point math running out of precision.', + 'The PlayStation startup sound was partly there to cover the slow boot. Loading screens have always been theater.', + 'The Konami Code exists because a developer found his own game too hard to test. He added a cheat, then forgot to remove it.', + 'Mario has a mustache because 1981 pixels could not draw a mouth. He wears a hat because they could not animate hair.', + 'The dial-up modem scream was a negotiation. Two machines shouted what they could do at each other until they agreed on a speed.', + 'Wi-Fi does not stand for anything. A branding agency made it up to sound like "hi-fi." People invented "wireless fidelity" after the fact.', + 'CAPTCHA stands for "Completely Automated Public Turing test to tell Computers and Humans Apart." You have failed one before. Think about that.', + 'The first 1GB hard drive shipped in 1980. It cost $40,000 and weighed 550 pounds. Today a gigabyte falls out of your pocket.', + 'The Morris Worm of 1988 was meant to measure the size of the internet. A bug made it crash machines instead. Its author now teaches at MIT.', + 'Junk mail is called "spam" because of a Monty Python sketch. Vikings chant "spam" until nobody can talk. That is also how your inbox works.', + 'A "jiffy" is a real unit of time. The Linux kernel uses it for its clock tick. "Back in a jiffy" is a promise you can measure.', + 'The Boeing 787 had a counter that overflowed after 248 days and cut electrical power. The fix: reboot the plane on a schedule. Turn it off and on again, but with paperwork.', + 'Half of all "Stack Overflow copy-paste" jokes are copied and pasted.', + 'The first domain name ever registered was symbolics.com, in 1985. It is still up, as a museum of itself.', + 'IPv4 has about 4.3 billion addresses, and we ran out. IPv6 has enough to give every grain of sand on Earth trillions of them. We should be fine for a bit.', + 'In 1997, Deep Blue made a chess move that spooked Kasparov. It may have been a bug: the machine could not decide, so it played a random move.', + 'Rubber duck debugging is real and it works. Explaining your code out loud to a duck forces you to actually read what you wrote.', + 'The cloud icon in network diagrams is decades older than cloud computing. Engineers drew a cloud to mean "stuff we don\'t care about here."', + 'Every software safety course uses the same two disasters: Therac-25 and Ariane 5. The good news is that the list of famous ones is short.', + 'Excel thinks 1900 was a leap year. It was not. Lotus 1-2-3 had the bug first, and Excel copied it on purpose to stay compatible. The bug is older than most of its users.', + 'Scientists renamed human genes like SEPT1 and MARCH1 because Excel kept turning them into dates. The spreadsheet won.', + 'Early Unix had a command called "biff" that announced new mail. It was named after a real dog that barked at the mailman.', + 'Background processes are called "daemons" after Maxwell\'s demon in physics: an invisible worker that never stops. Pronounce it however you want. Everyone else does.', + '"Booting" a computer comes from "pulling yourself up by your bootstraps." The machine loads the loader that loads the loader.', + 'Gangnam Style got so many views it overflowed YouTube\'s 32-bit counter in 2014. Google had to upgrade to 64 bits.', + 'Arrays start at 0, so the 13th element is at index 12. Someone on your team got bitten by this in the last month.', + 'The first computer mouse was made of wood. Douglas Engelbart showed it off in 1968, in a demo that also introduced video calls and hypertext.', + 'The first six programmers of the ENIAC were all women. They programmed it by rewiring it. The job title "programmer" did not exist yet.', + 'Margaret Hamilton printed out her Apollo flight code, and the stack stood as tall as she did. She also coined the term "software engineering."', + 'Floppy disks had a write-protect notch. Cover the hole with tape and the drive physically could not write. File permissions, but with tape.', + 'Ken Thompson showed how to hide a backdoor in a compiler so well that recompiling cannot remove it. His conclusion: you cannot fully trust code you did not write yourself.', + 'The ASCII delete character is 127: all seven bits set to 1. On paper tape, you deleted something by punching out every hole. Deletion by total destruction.', + 'In The Hitchhiker\'s Guide, the answer to everything is 42. In ASCII, 42 is the asterisk: the wildcard. So the answer to everything is whatever you want it to be.', + 'The traveling salesman problem and the seating chart at your wedding are the same math problem. Only one of them has good software.', + 'There are more possible chess games than atoms in the observable universe. Your test suite still won\'t cover every input.', + 'A googol is 1 followed by 100 zeros. A googolplex is 1 followed by a googol zeros. You cannot write it out. The universe does not have enough paper.', + 'Put 23 people in a room and odds are two of them share a birthday. Hash collisions work the same way. That is why cryptographers use very big numbers.', + 'Fermat claimed he had a proof of his Last Theorem, but the margin of his book was too small to write it down. The real proof took 358 years and 129 pages.', + 'A day on Venus is longer than a year on Venus. It spins that slowly. Somewhere, a calendar library maintainer is sweating.', + 'GPS satellites correct their clocks for relativity. Without the fix, your map position would drift about 10 kilometers per day.', + 'Sunlight takes 8 minutes to reach Earth. Light from the next nearest star takes over 4 years. The night sky is a very stale cache.', + 'A small part of old TV static came from the afterglow of the Big Bang. The snow between channels was partly the birth of the universe.', + 'Honey never spoils, sharks predate trees, and there is code in production older than the person debugging it.', + '"Foo" and "bar" come from the old military slang FUBAR. Your placeholder variables have a service record.', + 'Linux still has an error code that means "not a typewriter" (ENOTTY). Terminals were once real teletypewriters. That is what TTY stands for.', + 'A software "patch" was once a real patch. You fixed a program by taping over holes in the punched tape and punching new ones.', + 'The Mars Climate Orbiter burned up because one team used pounds and another used newtons. A missing unit conversion cost $327 million.', + 'The first Apple logo was a drawing of Isaac Newton under an apple tree. It lasted a year. It was impossible to print small.', + '"Hello, World!" comes from a 1978 C tutorial by Brian Kernighan. The first version, in 1972, had no exclamation point. The excitement was added later.', + 'The Commodore 64 is still the best-selling single computer model ever. Its prompt said "READY." Modern shells are not so upbeat.', + 'The ancient Greeks built a gear computer to predict eclipses over 2,000 years ago. It is legacy hardware with no docs and no maintainers.', + 'IBM put "THINK" signs on office walls decades before Google wrote "Don\'t be evil." Company slogans are the oldest form of prompt engineering.', + 'Alan Turing proposed his famous test in 1950 and called it "the imitation game." He guessed machines would pass it by the year 2000. He was close.', + 'Some servers have run for over 16 years without a reboot. At that point, nobody dares to try.', + 'SQL is older than the compact disc. NoSQL databases are a rebellion against something older than Star Wars.', + 'Microsoft\'s official name for the blue screen of death is a "bug check." That is like calling a house fire a "warmth event."', + 'During the Moon landing, the Apollo computer raised a 1202 alarm. It was overloaded, so it dropped low-priority work and kept flying. Graceful degradation, live on TV.', + 'The most common bug report of all time is "works on my machine." Docker was invented so you could ship your machine.', + 'Radia Perlman invented the spanning tree protocol and explained it in a poem. It rhymes, and it routed the world.', + 'CSS came out in 1994. Centering a div was not truly easy until around 2015. Twenty-one years. Still faster than the Fermat proof.', + 'Undo is Ctrl-Z, in the bottom-left corner of the keyboard. Fixing mistakes was designed to be within easy reach.', +] + +// One at random. The caller reads it once per mount, so the fact changes each +// time the pane opens rather than flickering on re-render. +export function randomFact(): string { + return FACTS[Math.floor(Math.random() * FACTS.length)] ?? FACTS[0]! +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f3f5e03d5de498289085f7a4f538f1e76a9863c GIT binary patch literal 9273 zcmb7K%W~Vu70p^-(aE@mpdOHHC#jTXWS9JyjH+yp%U0rrs4+pXNn!{B96U@hvZ|)C z&LRsfvdAixWtLgyYxE^K=iY7rl*XBfHzI)U+xL0yqn#|4c~O~o7Nv7DDe{Hs$?x7) zqF+vXtaTeFbI&UbxrYKujZMd__|dzE|NY$&TDSsrI) z;^@iqUW6CTl2s9PSMYIj!KJBYc43>d!hLz}ZEaQSr8OebQ=6vd$Y5zcw^{l4(A3#H z%U9Xixw+DHCs~?g_H|U0wveSu`FV&<@=L!5N}#j9e`v^cHU$G$TU-14=6Gte$}Ee# zv?eJHRWim==H`cMGs@HPu9?|rj2-2!8P#cOEBp{mS7eKOi)ckk5k3}4IWJ9=jm&)|H|0Oq3Q7er+aLO}bG9=_<%vnvsp88U~>@J|uP}fv`+s=mLuyYGsE@G>;C5?`2L+d{La6sdWAa0`tU@QU@(Rx@%-0%^&n&pTd1X7h3Yh!VwfqTKb z^Ew^FA=Hn}Y*#{Zut%A_a^dv9{%luXjG4C=bt>T_HIvzf>iI3BkliLEo+O0bX|2X7yrZi(QvgxRyL=3Tg33;H@MG zM^gmT3=*bLBf{1$@OxH>x&=QWW;G*?p`^7CQrT*_YgV%a`gYJlbJA0i%nUB>kIoJj z62g)B>JKkoP!YBY%?V-;ftw~);*UOnfGwk`1tXbFq4-f0&!+`o0p?o^@gnplCe=E@8P&|;9$Wqc`&fl9P zTbi+*L^NxM)lPySH4z4$m{k(5r!$CeMrlr5B#1LAT^?`@Bn4egWSJ%(1Ys7ifB|G= zRZ^|NS0vGD7i0?iReInPvQ-NIR`;&q%VlVCjg19N;kWE5&HsnId)ylxF}-7~C*XWO-5|)|GRX4sWgK z{52R)E)LBkO3Q|6(2bkw(3HuaK*V9UOWp7iSfL^+Nh+IWIh>fVl~*`;D7qPb(ejyR zDPZve5f`JX0kL;X`>dYkNJEvxmzD||>E0CpoycCmJQu7g0OJBhAp}m4Vz{4~43f(+ z%QV7EJ}T4rgXGR7QWJVD$U)yo<}6tTuJU614hVF}1cZ9CW3*eC1$1zsr^@~H-UuxC zz~repN|IoWs#HpgaaXg`!rIK63;?$P=wua)cpv3rii3dwbJQIQYHN^k5UT8hf>$rWEhs@HL)at$^vU_qA9hYg3~ zJ|k8O0aY5sF|E#GcZ`r^-N-tk4#q^&^5TYUJ#Gm>@!u*vm1i7U^B`y40^3+fObDnTY4Mg2LVk*;cW;xq(hfj=o_WAX6SRk&_p8?p5Dw&s%M*}S;7-$ z=4ax+`(;+#eJ2ro`}I|ypqMcvNc9$=D|mZ!LD+X3S29dnn6PoXje`zRqy~j|wlL_D ze*;edKzO^f+jV8rus3ZABiK0YD*g<=6 zP!vGEKOt$LlfW-J#BR}!y{)VYjOZ;HgJO4tY+ju2?xhl)kNu)ssv49+PR<}nVJ#rFoC(r?ijx!yH{Ml{t z`=Rvph5m8vJxPa(aOUx2b9PSm(0^K0sCKp96%m?r&s78wqz8aggop?MszDI<4JsL5 zkd6bD1F!o{jBzV8t|!c32clQL^MmR zZ*MbMY;Q{-51EaI<^&^}XU|auH4TiCW0@~dK=*_B(HCN@K@ohVwmG?BJzsHlfl*4^ zV=67r1mt0_iJ}W!FBrP20x4TRg>sboX9PA&XASAkKR2AR(a=uOMB0$g`9C&61%#houc6oK z!LPcbcGXzVJa)D?Z1;aUG<>EjL-f6V{Gp!0$Z?11i!GgRzVrjly+<;11SI((SMDo& zhhb7$w(YHsRRG99!)$8D>|bG2;xJq1NtC20EF*Yo3M5%@fWZ-wL42UYv==#?9+cro zMUBDPqJ~FFBFD>8nocG_l(f<|Bl)_XJ={WP!O8cnPSQ&fC}ZBb4r4pbrE^AxjiMVM znv;x|n1un$E`kRK9An@CvoznF3bN_)ImT^AW_a(C`>uX*?@|xEB6R7B85)EmZ9t_> z#SZG9F3(TDL|n1TnxM!UH~lKUU=8XJ!iDD^Zz_zKG{lv-B)T!^duy|SCM(W|o&39S z{Rl;1?Akq#?8GAEaCJCOi-F#H96jJgo=snXzcN4txpDCfyi z212mj+*%H`u}V~d2?~Zge4L@ZU(#*vI4D~4#ybfI-nxLGQI1g};(aCY|X;QV}N za6Sn7`wPHPS?Q#MoM_RkQ1BWTA1-tG(QU$3Uj|{f=Dc@h{E(Chia;rJZqI;puG5u~ z&L%fOvb66|-dR+od|Th&OHpO z3!NFkv(Tz^2cWn$s?4izPTMkqQ(uhCO^OhtL+Z5>eG>X$wZK|}EqXiNr>;W7c?L9u z`999z1VksmRi3lXL_Y^&xW)jYe%Kgpg1gS(S+@ki-hL zrPaU02`eMl-JoNUyNw89pgo4k(z@#7wFRdV{P-EWrz@>Zv{{>=n8csW%*2SA2Qo3igj5q24#iG#S6Z)r|NwqydyxKL1m_JCQ<~=~w)o6>~k>wZzW`~g9g$Ls>q0{$--{ncx@i5m=bzdvo zKFKfk#<t0h`iJv< zO)V|5Yg9Nul=j+*q(Q`3mpffIDSY}usvh+|U%)>?%4UFL)5i=+PQVB%k z9Xaq&6(&~`dLJh@W=s}Dq7ZS_8FXW@M8C_NCX2*)|UwMASZy)(9@aGe;kOFj8XpU;>H0 zhoq**j#zlPid>8E<^8Y@@CB*(j*9M`=y}kGQm%B^4?>CP}nhmgfHPA zJm9gp4KY-1xSVOovraIe$0*KAdb$qabzfU$0&SpRR@5|{;lB)KD5 Q28_D!lPuT4q^qs}0?vc>T>t<8 literal 0 HcmV?d00001 diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index d44b69f..5500d6e 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,15 +1,16 @@ import { sessionStatusWord } from '@ellipsis-dev/sdk/stream' import type { AgentSessionWire } from '@ellipsis-dev/sdk' +import { theme } from './theme' import type { AgentSession, SupportedModel } from './types' // Pure session-model helpers shared by the connect command and the // multi-session UI (SessionsApp). No I/O here — everything is testable. -// THE selection marker, everywhere: the one cyan character that says "you -// are here" — it replaces a sidebar row's status dot, a transcript line's -// gutter icon, and the focused composer's prompt. One char, always cyan, so -// the eye finds the cursor instantly anywhere in the console. The thick -// right-arrow is reserved for selection alone; statuses are colored dots. +// THE selection marker, everywhere: the one bone-bright character that says +// "you are here" — it replaces a sidebar row's status dot, a transcript +// line's gutter icon, and the focused composer's prompt. One char, always the +// accent, so the eye finds the cursor instantly anywhere in the console. The +// thick right-arrow is reserved for selection alone; statuses are colored dots. export const SELECTION_GLYPH = '▶' // Whether the composer can send to this session, and — when it can't — why. @@ -71,16 +72,17 @@ export function isActiveStatusWord(word: string): boolean { // The sidebar row's status marker: one dot, status told by color alone (the // arrow shape belongs to the selection cursor): -// ● yellow: in flight · ● cyan: your move (waiting) · ● dim: sleeping -// ● green: done/closed · ● red: failed · ● dim red: stopped/cancelled +// ● amber: in flight · ● bone: your move (waiting) · ● dim: sleeping +// ● green: done/closed · ● red: failed · ● dim red: stopped/cancelled export function rowGlyph(word: string): { glyph: string; color?: string; dim: boolean } { - if (isActiveStatusWord(word)) return { glyph: '●', color: 'yellow', dim: false } - if (word === 'waiting') return { glyph: '●', color: 'cyan', dim: false } + if (isActiveStatusWord(word)) return { glyph: '●', color: theme.active, dim: false } + if (word === 'waiting') return { glyph: '●', color: theme.foreground, dim: false } if (word === 'sleeping' || word === 'idle') return { glyph: '●', dim: true } - if (word === 'error' || word === 'failed') return { glyph: '●', color: 'red', dim: false } - if (word === 'stopped' || word === 'cancelled') return { glyph: '●', color: 'red', dim: true } + if (word === 'error' || word === 'failed') return { glyph: '●', color: theme.error, dim: false } + if (word === 'stopped' || word === 'cancelled') + return { glyph: '●', color: theme.error, dim: true } // closed / completed and anything unrecognized settles as done. - return { glyph: '●', color: 'green', dim: true } + return { glyph: '●', color: theme.success, dim: true } } // The row's one-line description: what the session is doing right now @@ -121,6 +123,60 @@ export function shortAge(iso: string, now: Date = new Date()): string { return `${Math.floor(hours / 24)}d ago` } +// A token count in the tightest readable form: 840 -> "840", 84_200 -> "84.2k", +// 512_000 -> "512k", 1_240_000 -> "1.24M". One decimal only while it buys +// precision, so the column stays narrow. +export function compactTokens(n: number): string { + if (!isFinite(n) || n < 0) return '0' + if (n < 1000) return String(Math.round(n)) + if (n < 1_000_000) { + const k = n / 1000 + return k < 100 ? `${trimZero(k.toFixed(1))}k` : `${Math.round(k)}k` + } + return `${trimZero((n / 1_000_000).toFixed(2))}M` +} + +function trimZero(s: string): string { + return s.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1') +} + +// The nav row's right-hand metadata: how much work the agent did (turns, +// tokens, spend) and when it last moved. Turns come from tokens_info.num_turns +// — the list row's own counter — and spend is the sum of the four millicent +// cost columns, the same total the chat footer shows. A just-started session +// drops the empty bits rather than showing "0 turns · 0 · $0.00". No source tag: +// the nav lists cloud sessions only, so it would read the same on every row. +export function rowMeta(session: AgentSession, now: Date = new Date()): string { + const bits: string[] = [] + const turns = numberField(session.tokens_info, 'num_turns') + if (turns > 0) bits.push(`${turns} ${turns === 1 ? 'turn' : 'turns'}`) + if (session.tokens_total > 0) bits.push(compactTokens(session.tokens_total)) + const millicents = + session.cost_tokens + session.cost_sandbox_cpu + session.cost_sandbox_memory + session.cost_fee + if (millicents > 0) bits.push(`$${(millicents / 100_000).toFixed(2)}`) + bits.push(shortAge(lastEventAt(session), now)) + return bits.join(' · ') +} + +// Where the session runs. The list row has no top-level `source` (that's the +// stream's wire shape); it rides `input.source` instead, with laptop syncs the +// only non-cloud kind the nav distinguishes. +export function sessionSource(session: AgentSession): string { + if (session.source === 'laptop') return 'laptop' + const input = session.input + if (input && typeof input === 'object') { + const source = (input as Record).source + if (source === 'laptop') return 'laptop' + } + return 'cloud' +} + +function numberField(container: unknown, key: string): number { + if (!container || typeof container !== 'object') return 0 + const value = (container as Record)[key] + return typeof value === 'number' && isFinite(value) ? value : 0 +} + // Whether the conversation is still open (alive/idle) — the sidebar's top // group. Terminal-status single-shot sessions and closed conversations sink // to the bottom group. @@ -144,6 +200,19 @@ export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSes }) } +// The sidebar list: the polled snapshot plus composer-spawned sessions the +// poll has not returned yet. A session can sit in both for one poll cycle +// (a list read that lands mid-create, then the create response), so it is +// deduped by id with the polled copy winning — that one is fresher. +export function mergeSidebarSessions( + polled: readonly AgentSession[], + local: readonly AgentSession[], +): AgentSession[] { + const byId = new Map() + for (const s of [...polled, ...local]) if (!byId.has(s.id)) byId.set(s.id, s) + return sortSidebarSessions([...byId.values()]) +} + // Attention transitions: a session that WAS in flight and now waits for a // human (waiting/sleeping/idle) deserves the sidebar dot. Pure step function // over consecutive poll snapshots. @@ -159,24 +228,30 @@ export type ComposerModel = { id: string | null; label: string } // The composer's model list when GET /v1/models is unavailable (an older // server): the agent-selectable set as of this build, most expensive first. -// `null` id = let the server pick (DEFAULT_AGENT_MODEL). +// `null` id = let the server pick (DEFAULT_AGENT_MODEL). Labels are the raw +// model ids — the CLI speaks the API's vocabulary, not marketing names. export const COMPOSER_MODELS: ReadonlyArray = [ { id: null, label: 'Default' }, - { id: 'claude-fable-5', label: 'Claude Fable 5' }, - { id: 'claude-opus-5', label: 'Claude Opus 5' }, - { id: 'claude-opus-4-8', label: 'Claude Opus 4.8' }, - { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }, - { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, + { id: 'claude-fable-5', label: 'claude-fable-5' }, + { id: 'claude-opus-5', label: 'claude-opus-5' }, + { id: 'claude-opus-4-8', label: 'claude-opus-4-8' }, + { id: 'claude-sonnet-5', label: 'claude-sonnet-5' }, + { id: 'claude-haiku-4-5-20251001', label: 'claude-haiku-4-5-20251001' }, ] -// The composer's model options from the server's list, keeping its order and -// naming the model "Default" resolves to. +// The composer's model options from the server's list, keeping its order. +// Labels are raw model ids; the null "let the server pick" entry IS the +// default model's row — labelled with the id it resolves to +// (DEFAULT_AGENT_MODEL), replacing that model's own entry so the id appears +// once in the list. export function composerModelOptions(models: readonly SupportedModel[]): ComposerModel[] { if (models.length === 0) return [...COMPOSER_MODELS] const fallback = models.find((m) => m.is_default_agent_model) return [ - { id: null, label: fallback ? `Default (${fallback.display_name})` : 'Default' }, - ...models.map((m) => ({ id: m.id as string | null, label: m.display_name })), + { id: null, label: fallback ? fallback.id : 'Default' }, + ...models + .filter((m) => !m.is_default_agent_model) + .map((m) => ({ id: m.id as string | null, label: m.id })), ] } @@ -217,3 +292,18 @@ export function sidebarSlice( if (selected < start) start = selected return { start, end: start + cap } } + +// Which slice of the session list the vertical nav shows: the highlight walks +// down to the second-to-last visible row and parks there while the list +// scrolls under it, so there is always one row of lookahead. Only the true +// end of the list lets the highlight sit on the last row. +export function navSlice( + count: number, + capacity: number, + selected: number, +): { start: number; end: number } { + if (count <= capacity) return { start: 0, end: count } + const cap = Math.max(2, capacity) + const start = Math.min(Math.max(0, selected - (cap - 2)), count - cap) + return { start, end: start + cap } +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..900642c --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,53 @@ +// The Ellipsis brand palette, dark mode — the CLI's one source of color. +// +// These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the +// canonical source). This repo can't reach that file, so when a brand color +// changes there, it has to be re-copied here by hand. +// +// Dark mode is the brand's main mode, which is what a terminal is, so the CLI +// only ever renders the dark palette — there is no light variant to switch to. +// +// One rule carried over from the web app (landing globals.css `.dark`): the +// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores +// 1.79:1 on the panel — unreadable as terminal text. So "you are here" is +// carried by brightness (bone against stone), not by hue. + +export const theme = { + // The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely + // a lift, which is the point — a panel should separate, not stripe. + canvas: '#1c1b1a', + panel: '#262523', + // One step lighter than `panel`: the brand border hairline, doing duty as + // the "you are here" surface (highlighted message, focused composer, + // selected nav row). Selection is a brightness step between surfaces — + // never the full inverse flash, which reads bone-white and far too loud. + panelActive: '#343330', + + // Type. `foreground` is body copy and doubles as the accent (see above); + // `muted` is every secondary string (meta, hints, timestamps). + foreground: '#f0efe9', + muted: '#a8a59c', + + // Status. Tuned for the charcoal canvas, not the light one. + success: '#4ebc7b', + error: '#e5544b', + // In-flight. brand/tokens.json has no dedicated "working" color; this is + // syntaxLiteral, the warm amber, which is the only brand hue that reads as + // activity without colliding with success green or error red. + active: '#d9bd8d', + + // Syntax, for rendered markdown in a transcript. Same values the web apps + // use for code blocks, so a snippet reads the same in the CLI as in the docs. + syntaxLiteral: '#d9bd8d', + syntaxString: '#c8c6bc', +} as const + +// The elevated surface an input area sits on. Named separately from +// `theme.panel` because call sites mean "this is an input", not "this is +// #262523" — the composers in ConnectApp/SessionsApp both use it. +export const SURFACE_ELEVATED = theme.panel + +// The elevated surface, active: the focused composer, the highlighted +// transcript message, the selected nav row. One brightness step above +// SURFACE_ELEVATED — enough to read "you are here" without the inverse flash. +export const SURFACE_ACTIVE = theme.panelActive diff --git a/src/marked-terminal.d.ts b/src/marked-terminal.d.ts new file mode 100644 index 0000000..0d646a2 --- /dev/null +++ b/src/marked-terminal.d.ts @@ -0,0 +1,6 @@ +// marked-terminal ships no types. The CLI uses one export, and marked's +// `use()` takes an opaque extension object, so the shape only needs to be +// nominal. +declare module 'marked-terminal' { + export function markedTerminal(options?: Record): unknown +} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 5d4dace..d998b59 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -35,7 +35,9 @@ import { ApiClient, ApiError } from '../lib/api' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' +import { hasMarkdown, renderMarkdown, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' +import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' import { VERSION } from '../lib/constants' // The interactive `agent session connect` UI, modelled on Claude Code: a @@ -124,6 +126,21 @@ function isWorkingStatus(status: string): boolean { // termRows comment in ConnectApp for what the slack absorbs. const TOP_PAD = 4 +// Text rows inside the composer panel, before its 1-cell pad. ink's minHeight +// counts padding, so the panel's own minHeight and the viewport budget both +// use this plus 2 (the pad above and below). +const COMPOSER_INTERIOR_ROWS = 3 + +// Horizontal breathing room inside the composer panel — wider than the 1-cell +// vertical pad so the caret and text start well clear of the panel edge. +const COMPOSER_PAD_X = 2 + +// The pad inside a chat message's panel, all four sides — the text sits one +// cell off the tint's edge, like the composer's interior. Counted by the +// viewport height estimates (two extra rows per message) and subtracted from +// the wrap width (two columns). +const MESSAGE_PAD = 1 + // One local send awaiting server acknowledgement: messageId is null while the // POST is in flight, then the created SessionMessage's id (protocol v2 §4.2) — // the chip retires the moment the store acknowledges that id (a messages @@ -254,10 +271,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // re-derivations. // Lifecycle records are excluded entirely: the sandbox story renders as the // one-line progress block up top (sandboxProgress), not as transcript rows. - // Each turn's closing summary is reshaped into stepMeta — the right-hand - // metadata column on the turn's final assistant message (see - // reshapeTranscript). - const { items, stepMeta } = useMemo( + // Each turn's closing duration/cost summary is dropped too (see + // reshapeTranscript) — the footer carries the session's spend. + const { items } = useMemo( () => reshapeTranscript(snapshot.records, props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) @@ -303,6 +319,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const [sandboxOpen, setSandboxOpen] = useState(false) const [stepCursor, setStepCursor] = useState(0) const [stepLogsOpen, setStepLogsOpen] = useState(false) + // Once the session settles the block collapses to the bare headline; → + // while highlighted re-reveals the config + sandbox child lines (and → + // again drills into the phases via sandboxOpen). Live starts always show + // the whole hierarchy — there's nothing to collapse until it's done. + const [sandboxDetails, setSandboxDetails] = useState(false) // Bodies of the server's PENDING inbox messages — the durable queued signal. const serverQueued = useMemo( @@ -590,6 +611,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // only the slice that fits the window renders, scrolled by wheel/trackpad // and snapped to the highlight. const infraActivity = statusActivityText(statusWord) + // The startup story has settled: the headline is final ("Session ready!"), + // nothing is live. This is when the block collapses to the bare headline. + const sandboxSettled = (sandbox?.done ?? false) && !infraActivity + useEffect(() => { + // A fresh start (wake/retry) re-opens the live hierarchy and re-arms the + // collapse for when it settles again. + if (!sandboxSettled) setSandboxDetails(false) + }, [sandboxSettled]) const entries = useMemo(() => { const width = Math.max(20, cols - 3) const keys: string[] = [] @@ -597,21 +626,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const byKey = new Map() if (infraActivity || sandbox) { keys.push('sandbox') - heights.push(sandboxBlockRows(sandbox, sandboxOpen, stepLogsOpen, stepCursor)) + heights.push( + sandboxBlockRows(sandbox, sandboxSettled, sandboxDetails, sandboxOpen, stepLogsOpen, stepCursor), + ) } for (const item of visible) { keys.push(item.key) - byKey.set(item.key, item) - // Assistant messages wrap inside their 80% column (the right 20% is - // the metadata gutter), so their height estimate uses that width; an - // opened fold's indented children lose 2 columns to the indent. - const itemWidth = item.kind === 'assistant' - ? Math.max(20, Math.floor(width * 0.8)) - : indented.has(item.key) - ? Math.max(20, width - 2) + const isMessage = item.kind === 'user' || item.kind === 'assistant' + // An opened fold's indented children lose 2 columns to the indent; + // a message panel loses its horizontal pad on both sides. + const itemWidth = indented.has(item.key) + ? Math.max(20, width - 2) + : isMessage + ? Math.max(20, width - MESSAGE_PAD * 2) : width + // Markdown is resolved HERE, once, so the height estimate and the + // rendered row measure the exact same (pre-wrapped) text. + const shown = withRenderedMarkdown(item, itemWidth) + byKey.set(item.key, shown) heights.push( - estimateItemRows(item, itemWidth, !expanded && !openedKeys.has(item.key)), + estimateItemRows(shown, itemWidth, !expanded && !openedKeys.has(item.key)) + + // The message panel's vertical pad rows (above and below the body). + (isMessage ? MESSAGE_PAD * 2 : 0), ) } return { keys, heights, byKey } @@ -620,6 +656,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { sandbox, visible, indented, + sandboxSettled, + sandboxDetails, sandboxOpen, stepLogsOpen, stepCursor, @@ -645,24 +683,29 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // conservative rather than overflow the window into scrollback. const viewBudget = useMemo(() => { const width = Math.max(20, cols - 3) - // The composer box: its rules (2, or 1 when the host's nav rule serves - // as the bottom) plus a 3-row minimum interior (grows with a taller - // multi-line input). + // The composer panel: a 3-row minimum interior, growing with a taller + // multi-line input, plus the 1-cell pad above and below it. No rules to + // account for — the tint is the frame. const composerRows = composerVisible - ? (props.hideMetaLine ? 1 : 2) + Math.max(3, composer.text.split('\n').length) + ? Math.max(COMPOSER_INTERIOR_ROWS, composer.text.split('\n').length) + 2 : 0 - // Live prose wraps inside the same 80% column its committed form uses. + // Live prose wraps inside the same 80% column its committed form uses; + // its message panel adds MESSAGE_PAD rows above and below. const liveWidth = Math.max(20, Math.floor(width * 0.8)) const liveReserve = working - ? 2 + (snapshot.liveText ? Math.ceil(snapshot.liveText.length / liveWidth) + 1 : 0) + ? 2 + + (snapshot.liveText + ? Math.ceil(snapshot.liveText.length / liveWidth) + 1 + MESSAGE_PAD * 2 + : 0) : 0 // The in-flight sends render inside the transcript area (below the // slice), so their rows come out of the viewport budget: spacer + - // wrapped lines each. + // wrapped lines + the message panel's pad rows each. const queuedReserve = inFlightSends.reduce( (acc, q) => acc + 1 + + MESSAGE_PAD * 2 + q.text.split('\n').reduce((a, l) => a + Math.max(1, Math.ceil(l.length / width)), 0), 0, ) @@ -834,9 +877,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } if (key.rightArrow || key.return) { if (navKey === 'sandbox') { - setSandboxOpen(true) - setStepLogsOpen(false) - setStepCursor(Math.max(0, (sandbox?.steps.length ?? 0) - 1)) + // Settled, → drills one level at a time: bare headline → the + // config + sandbox lines → the phase panel. Live, the hierarchy + // is already showing, so → goes straight to the panel. + if (sandboxSettled && !sandboxDetails) { + setSandboxDetails(true) + } else { + setSandboxOpen(true) + setStepLogsOpen(false) + setStepCursor(Math.max(0, (sandbox?.steps.length ?? 0) - 1)) + } } else { const item = visible.find((i) => i.key === navKey) if (item && (navKey.startsWith('grp:') || isCollapsible(item))) { @@ -848,7 +898,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (key.leftArrow) { // ← closes the thing opened in place; with nothing open it's inert // (the session nav lives BELOW the composer — ↓ walks to it). - if (openedKeys.has(navKey)) { + if (navKey === 'sandbox') { + setSandboxDetails(false) + } else if (openedKeys.has(navKey)) { setOpenedKeys((prev) => { const next = new Set(prev) next.delete(navKey) @@ -1020,6 +1072,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { width={hosted ? cols : undefined} height={hosted ? rows : undefined} minHeight={hosted ? undefined : Math.max(0, rows - bottomSlack)} + backgroundColor={theme.canvas} > {/* Top padding — see the termRows comment: absorbs terminal row- accounting quirks and the post-exit sign-off so the first content @@ -1034,11 +1087,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ✓ Preparing image · incremental build · 3.4s ✻ Running setup… While in progress the whole hierarchy shows, the live level ticking - with its log tail. Once ready the hierarchy STAYS, all ✓ with the - logs hidden — the durable trace of how the session came up. - Highlighting it (↑ from the composer) and pressing → opens the - panel, →/← on a phase shows/hides its logs. It is the viewport's - first entry, so it scrolls out of frame like any line. */} + with its log tail. Once ready it collapses to the bare headline; + highlighting it (↑ from the composer) and pressing → reveals the + config + sandbox lines, → again opens the phase panel, →/← on a + phase shows/hides its logs. It is the viewport's first entry, so + it scrolls out of frame like any line. */} {(infraActivity || sandbox) && entries.keys[0] === 'sandbox' && slice.start === 0 && ( {/* The conversation's opening line: where it lives. Plain text — @@ -1048,23 +1101,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ✦ Connected to ellipsis.dev + {/* The startup story sits on the same lifted panel chat messages + use (padded tint, no rule), so it reads as a block in the + conversation rather than bare canvas text. Highlighting it + lightens the WHOLE panel to the active surface, exactly like a + selected transcript line. */} + {/* Level 1: the session headline. The selection glyph replaces the mark while highlighted (same 1-char slot), so the header never - shifts. */} + shifts; the highlight is the panel-wide active surface, never + inverse. */} {navKey === 'sandbox' ? ( - {SELECTION_GLYPH} + {SELECTION_GLYPH} ) : sandbox?.done && !infraActivity ? ( - + ) : ( - + )}{' '} {/* The settled headline ("Session ready!") reads bold in the default (white) foreground over the dim trace beneath it; while starting it stays dim like the rest of the block. */} @@ -1074,20 +1137,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {sandbox?.done && !infraActivity ? sandbox.headline : `${(!sandbox || sandbox.done ? (infraActivity ?? 'Session starting') : sandbox.headline).replace(/…$/, '')}… (${humanDuration(elapsed)})`} - {inputActive && navKey === 'sandbox' && !sandboxOpen && sandbox?.sandboxLine - ? ' (→: details)' - : ''} - {/* Levels 2+3: the config line, the sandbox line and its phases — - always visible, live while starting and as the all-✓ trace once - the session is ready (logs behind →). */} - {sandbox && (sandbox.configName || sandbox.sandboxLine) && ( + {/* Levels 2+3: the config line, the sandbox line and its phases. + While starting the whole live hierarchy shows; once settled the + block collapses to the bare headline — → while highlighted + reveals level 2 (config + sandbox summary), → again opens the + phases. */} + {(!sandboxSettled || sandboxDetails) && + sandbox && (sandbox.configName || sandbox.sandboxLine) && ( {sandbox.configName && ( {' '} - {' '} + {' '} Using {sandbox.configName} {sandbox.configCommitSha @@ -1100,14 +1163,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {' '} {sandbox.sandboxDone ? ( - + ) : ( - + )}{' '} {oneLine(sandbox.sandboxLine, 110)} )} - {sandbox.steps.map((step, i) => { + {/* Level 3, the phases: live starts show them; a settled block + keeps them behind the second → (the open panel). */} + {(!sandboxSettled || sandboxOpen) && sandbox.steps.map((step, i) => { const running = step.status === 'running' && !sandbox.sandboxDone const cursor = Math.min(stepCursor, sandbox.steps.length - 1) const selected = sandboxOpen && i === cursor @@ -1119,37 +1184,39 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const logIndent = step.child ? ' ' : ' ' const mark = step.status === 'failed' ? ( - + ) : running ? ( - + ) : ( - + ) return ( {/* The cursor column is always reserved (a space when unselected), so opening the panel never shifts the - rows; the selected phase reads cyan like the - transcript highlight. */} + rows; the selected phase sits on the active surface + like the transcript highlight. Background on the + Box, not the Text — nested Texts inherit theirs from + ink's Box context and would repaint canvas over a + Text-level background. */} + {step.child ? ' ' : ' '} - {selected ? SELECTION_GLYPH : ' '} {mark}{' '} + {selected ? SELECTION_GLYPH : ' '} {mark}{' '} - {selected - ? ` ${oneLine(sandboxStepLine(step), 106)} ` - : oneLine(sandboxStepLine(step), 108)} + {oneLine(sandboxStepLine(step), 108)} {sandboxOpen && step.lines.length > 0 && ( - + {' '} ({step.lines.length} log line{step.lines.length === 1 ? '' : 's'}) )} + {/* A running step's live tail always shows (the last RUNNING_TAIL_LINES lines, ticking as chunks land); a finished step's logs stay behind →. Indented two @@ -1170,11 +1237,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) })} - {sandboxOpen && ( - {' '}↑/↓ phase · → logs · ← back · esc close - )} )} + )} {/* The transcript viewport grows through the middle of the terminal, @@ -1195,7 +1260,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { q.state === 'accepted') .map((q) => ( - + - + {q.text} @@ -1232,7 +1301,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {atBottom && runningTool && ( - + {runningToolLabel}… @@ -1249,7 +1318,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { column as the committed assistant item it becomes, so the text doesn't jump when it lands. */} {atBottom && liveText && ( - + {liveText} @@ -1259,7 +1328,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {atBottom && generating && ( - + Generating… @@ -1282,9 +1351,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { inFlightSends .filter((q) => q.state !== 'accepted') .map((q) => ( - + - + @@ -1314,7 +1388,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { (awaitingAgent !== null || sendPending) && ( - + @@ -1332,24 +1406,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { above, never on the composer/meta rows. */} {notice && · {notice}} - {/* The composer, framed by a full-width rule above and below (Claude - Code-style): the › prompt sits between the two lines. Only the top and - bottom borders are drawn, so the rules span the terminal width. */} - {/* The composer: a 3-row input area framed by full-width top and - bottom rules only (no side borders). With hideMetaLine the host - draws its own full-width rule directly beneath (the nav band), - which serves as the bottom bounding line — skip ours so the two - don't stack. */} + {/* The composer: a 3-row input area on the elevated surface — one + step lighter (the active surface) while it's where you are + (focused, no transcript highlight), matching the transcript's + selection treatment. The tint is the whole frame — no rules — so + the panel reads as lifted off the canvas rather than fenced in by + lines. A uniform 1-cell pad keeps the text off all four edges, + and because it's inside the tinted Box the gutter carries the + panel color too (a 5-row panel around a 3-row interior). */} {composerVisible && ( {/* One parent Text so a multi-line input flows as a single block (sibling Texts in a row Box would render as columns). The @@ -1365,7 +1436,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { you are (focused, no transcript highlight) — the same cyan marker as everywhere else — and dim when it isn't. */} - + {SELECTION_GLYPH}{' '} {composer.text.slice(0, composer.cursor)} @@ -1461,10 +1532,23 @@ export function viewportSlice( return { start, end: endIdx + 1 } } +// Agent and user prose rendered as markdown (bold, headings, bullets, tables, +// fenced code), pre-wrapped to the column it will occupy. Only these two kinds +// go through it: tool lines and system notices are the SDK's own formatting, +// where a stray asterisk or pipe is literal text. Items without markdown come +// back untouched, so the common case allocates nothing. Pure, for tests. +export function withRenderedMarkdown(item: TranscriptItem, width: number): TranscriptItem { + if (item.kind !== 'assistant' && item.kind !== 'user') return item + if (!hasMarkdown(item.text)) return item + const rendered = renderMarkdown(item.text, width) + return rendered === item.text ? item : { ...item, text: rendered } +} + // Estimated rows a transcript item occupies on screen: its (possibly // clamped) body lines, wrapped at the given width, plus the "+N lines" -// marker and the blank spacer row. An estimate is enough — the viewport -// budget leans conservative. Pure, for tests. +// marker and the blank spacer row. Widths are VISIBLE columns — markdown +// rendering leaves ANSI escapes in the text, which occupy none. Pure, for +// tests. export function estimateItemRows(item: TranscriptItem, width: number, clamp: boolean): number { const clamped = clamp && isCollapsible(item) @@ -1472,29 +1556,35 @@ export function estimateItemRows(item: TranscriptItem, width: number, clamp: boo : { body: item.text, more: 0 } let rows = (item.spaceBefore ? 1 : 0) + (clamped.more > 0 ? 1 : 0) for (const line of clamped.body.split('\n')) { - rows += Math.max(1, Math.ceil(line.length / width)) + rows += Math.max(1, Math.ceil(visibleWidth(line) / width)) } return rows } // Estimated rows of the startup block in its current shape: the headline, // plus — while starting or drilled into — the config line, the sandbox line, -// one row per phase, the selected phase's open log lines, and the key hint. +// one row per phase, and the selected phase's open log lines. Settled and +// not drilled into, the block is just the bare headline. function sandboxBlockRows( sandbox: SandboxState | null, + settled: boolean, + details: boolean, open: boolean, logsOpen: boolean, stepCursor: number, ): number { - // The "Connected to ellipsis.dev" opener + its blank row, then the headline. - let rows = 3 + // The "Connected to ellipsis.dev" opener + its blank row, then the headline + // inside its message panel (MESSAGE_PAD rows above and below the content). + let rows = 3 + MESSAGE_PAD * 2 const expanded = - sandbox != null && (sandbox.configName != null || sandbox.sandboxLine != null) + sandbox != null && + (sandbox.configName != null || sandbox.sandboxLine != null) && + (!settled || details) if (!expanded) return rows if (sandbox.configName != null) rows += 1 - const steps = sandbox.steps - if (sandbox.sandboxLine != null) rows += 1 + steps.length - if (open) rows += 1 // the key hint + if (sandbox.sandboxLine != null) rows += 1 + const steps = !settled || open ? sandbox.steps : [] + rows += steps.length const cursor = Math.min(stepCursor, Math.max(0, steps.length - 1)) for (const [i, step] of steps.entries()) { const running = step.status === 'running' && !sandbox.sandboxDone @@ -1608,41 +1698,19 @@ type LifecycleRecordLike = { } // The committed transcript items, with each turn's closing `result` summary -// reshaped: the "turn complete" label drops, the cost becomes the STEP's own -// (result events carry Claude Code's cumulative session total, tracked -// across the WHOLE feed so a --no-records first turn still subtracts the -// hidden history; a total below the previous one is a fresh process after a -// wake, whose step cost is the new total itself), and a summary directly -// following an assistant message moves into stepMeta — rendered as the -// right-hand metadata column on that message instead of its own line. An -// error summary keeps its label and its own (red) line: an error is content, -// not metadata. A summary with no assistant line to hang on stays a line -// too. Pure, for tests. +// dropped: its duration and cost are session bookkeeping, not conversation — +// the footer's running spend is where that story lives. An error summary +// survives as its own (red) line under a plain label: a failed turn is +// content. Pure, for tests. export function reshapeTranscript( records: readonly LifecycleRecordLike[], minRenderFeedSeq: number, -): { items: TranscriptItem[]; stepMeta: ReadonlyMap } { +): { items: TranscriptItem[] } { const items: TranscriptItem[] = [] - const stepMeta = new Map() - let prevTotalUsd = 0 for (const r of records) { if (r.source === 'lifecycle') continue - const p = r.payload - const isResult = r.source === 'claude_code' && p.type === 'result' - let stepBits: string[] | null = null - if (isResult) { - stepBits = [] - if (typeof p.duration_ms === 'number') { - stepBits.push(`(${humanDuration(p.duration_ms / 1000)})`) - } - if (typeof p.total_cost_usd === 'number') { - const step = - p.total_cost_usd >= prevTotalUsd ? p.total_cost_usd - prevTotalUsd : p.total_cost_usd - prevTotalUsd = p.total_cost_usd - stepBits.push(`$${step.toFixed(2)}`) - } - } if (r.feed_seq <= minRenderFeedSeq) continue + const isResult = r.source === 'claude_code' && r.payload.type === 'result' // recordToItems reads only the structural slice (source, record_type, // payload); its SessionRecordWire param type isn't exported from the // SDK's store entry, hence the cast. @@ -1650,26 +1718,14 @@ export function reshapeTranscript( r as Parameters[0], `s${r.feed_seq}`, )) { - if (item.kind === 'summary' && stepBits !== null) { - if (item.isError) { - items.push({ - ...item, - text: ['turn ended with an error', stepBits.join(' · ')] - .filter(Boolean) - .join(' '), - }) - continue - } - if (stepBits.length === 0) continue - const prev = items[items.length - 1] - if (prev?.kind === 'assistant') stepMeta.set(prev.key, stepBits.join(' · ')) - else items.push({ ...item, text: stepBits.join(' · ') }) + if (item.kind === 'summary' && isResult) { + if (item.isError) items.push({ ...item, text: 'turn ended with an error' }) continue } items.push(item) } } - return { items, stepMeta } + return { items } } // Whether a turn is IN FLIGHT (a turn_started record without its @@ -2007,22 +2063,22 @@ function styleFor(item: TranscriptItem): { const kind: ItemKind = item.kind switch (kind) { case 'tool': - return { gutterColor: 'green', bold: true, dim: false } + return { gutterColor: theme.success, bold: true, dim: false } case 'tool_result': return { - textColor: item.isError ? 'red' : undefined, + textColor: item.isError ? theme.error : undefined, dim: !item.isError, bold: false, } // User copy stays white like the assistant's (the ◆ icon marks the // sender); cyan text always and only means "the selection is here". case 'user': - return { gutterColor: 'cyan', bold: true, dim: false } + return { gutterColor: theme.foreground, bold: true, dim: false } case 'error': - return { gutterColor: 'red', textColor: 'red', dim: false, bold: false } + return { gutterColor: theme.error, textColor: theme.error, dim: false, bold: false } case 'summary': return { - textColor: item.isError ? 'red' : undefined, + textColor: item.isError ? theme.error : undefined, dim: true, bold: false, } @@ -2038,16 +2094,12 @@ function styleFor(item: TranscriptItem): { const TranscriptLine = React.memo(function TranscriptLine({ item, - meta, expanded, opened, selected, indent = false, }: { item: TranscriptItem - // The step's metadata ("4s · $0.10"), attached to a turn's closing - // assistant message and rendered right-aligned in the right-hand column. - meta?: string expanded: boolean // This line was opened in place with → while highlighted (un-clamps it). opened: boolean @@ -2074,37 +2126,40 @@ const TranscriptLine = React.memo(function TranscriptLine({ ? clampLines(item.text, COLLAPSE_LINES) : { body: item.text, more: 0 } - // Assistant messages keep to the left 80% of the row, leaving the right - // 20% as the metadata column (the turn's duration + step cost when this - // message closed one). Other kinds span the full width as before. - const isAssistant = item.kind === 'assistant' + // Messages (user + assistant prose) sit on the same lifted panel the + // composer uses, full width like the input box, with a 1-cell pad inside + // the tint on all four sides (MESSAGE_PAD — the viewport height estimates + // count the two pad rows); tool chatter and notices stay on the canvas. + // The selected line — any kind — steps onto the lighter active surface, + // the app-wide "you are here" treatment (the focused composer, sidebar + // rows, dropdown options all match). Never inverse: a bone-white bar is + // far too loud on the charcoal canvas. + const isMessage = item.kind === 'user' || item.kind === 'assistant' return ( - + {selected ? SELECTION_GLYPH : gutterFor(item)} - - {/* The selected line renders as an inverse cyan bar — the app-wide - "you are here" treatment (sidebar rows, buttons, dropdown - options all match). */} + {clamped.body} {item.detail ? ( - + {item.detail} ) : null} @@ -2115,11 +2170,6 @@ const TranscriptLine = React.memo(function TranscriptLine({ )} - {meta != null && ( - - {meta} - - )} ) }) diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 0db74a6..dce47f7 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -8,7 +8,7 @@ import React, { } from 'react' import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink' import type { OpenSocket } from '@ellipsis-dev/sdk/stream' -import { SESSION_STREAM_PROTOCOL_VERSION, sessionStatusWord } from '@ellipsis-dev/sdk/stream' +import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' import type { AgentSessionWire } from '@ellipsis-dev/sdk' import type { ApiClient } from '../lib/api' @@ -22,37 +22,42 @@ import type { import { applyEditShortcut } from '../lib/editing' import { hyperlink, sessionUrl } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' -import { VERSION } from '../lib/constants' import { attentionFlip, + compactTokens, composerModelOptions, configDisplayName, connectability, - lastEventAt, repoOverrideEntry, rowDescription, rowGlyph, + rowMeta, rowStatusWord, + navSlice, + sessionSource, SELECTION_GLYPH, - shortAge, sidebarSlice, - sortSidebarSessions, + mergeSidebarSessions, } from '../lib/sessions' +import { randomFact } from '../lib/facts' +import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' import { ConnectApp } from './ConnectApp' // The multi-session UI, a vertical stack of four bands: -// 1. the header — " ellipsis.dev" plus the focused session's meta -// (status · cost · model · id · version), closed by a rule +// 1. the header — " ellipsis.dev" top-left; top-right the focused +// session's meta (id · model · cost · tokens), or the who-tag when +// no session is focused // 2. the chat window (the hosted ConnectApp, full width) // 3. the text input (the ConnectApp's composer) -// 4. the session nav — your running sessions as a horizontal bar +// 4. the session nav — a vertical list: "+ New session" then your five +// most recent sessions // This is what a bare `agent`, `agent "prompt"`, and `agent session // connect ` all open. // // Focus is modal and esc steps outward: inside the chat esc closes panels, -// then transcript navigation, then lands on the nav bar. ↓ at the composer's -// last line reaches the nav too; enter (or ↑/esc) hands it back. Exactly one -// useInput handler is active at a time. +// then transcript navigation, then lands on the nav list. ↓ at the composer's +// last line reaches the nav too; enter (or esc, or ↑ off the top row) hands it +// back. Exactly one useInput handler is active at a time. // // Liveness: ONE WebSocket — the focused session's, owned by its ConnectApp — // plus a 5s REST poll of the session list for the nav. Transcript stores are @@ -63,13 +68,26 @@ const SIDEBAR_POLL_MS = 5_000 const SIDEBAR_LIMIT = 50 // The nav clock driving the "12s" age tags. const AGE_TICK_MS = 5_000 -// One nav cell: dot + truncated description + age, fixed width so the bar -// windows predictably. -const NAV_ITEM_W = 30 -// The pinned new-session cell, wide enough for its whole label plus the -// selected state's padding — it never truncates to "+ Ne…". const NAV_NEW_LABEL = '+ New session' -const NAV_NEW_W = NAV_NEW_LABEL.length + 3 +// The header title pinned to the left edge, and its width — the meta line on +// the right budgets itself against what's left of the row. +const HEADER_TITLE = 'ellipsis.dev' +const TITLE_WIDTH = HEADER_TITLE.length +// The nav shows six rows: the pinned new-session row plus the five most +// recent sessions, which scroll under the highlight. +const NAV_SESSION_ROWS = 5 + +// Blank canvas cells between the frame and the terminal edge, on all four +// sides. Everything inside lays out against the inset width/height. +const APP_INSET = 1 + +// Extra indent for the session-nav rows, on top of the app inset — the list +// reads as a distinct column rather than type hanging off the frame edge. +const NAV_GUTTER = 1 + +// Horizontal breathing room inside the composer panel — wider than the 1-cell +// vertical pad so the caret and text start well clear of the panel edge. +const COMPOSER_PAD_X = 2 export interface SessionsAppProps { api: ApiClient @@ -77,9 +95,16 @@ export interface SessionsAppProps { // app.ellipsis.dev base + the customer login, for per-session dashboard links. appBase: string customerLogin: string + // My GitHub login for the header's "@me in account" tag; null on an API-key + // credential, which has no GitHub user behind it. + ghLogin: string | null // My GitHub account id — the sidebar lists sessions attributed to me. null // (e.g. an API-key credential) lists the whole account's sessions. authorId: number | null + // The repo detected from the cwd's origin remote ("owner/name"), which is + // what the server's default resolution checks out — shown as the composer's + // resting Repository value. null when the cwd isn't an enrolled repo. + detectedRepo: string | null // Open focused on this session (connect / prompt shorthand); undefined // opens on the new-session composer (a bare `agent`). initialSessionId?: string @@ -124,6 +149,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } }, [stdout]) const height = Math.max(8, termRows - 1) + // The app inset: one blank cell of canvas on all four sides of the whole + // frame, so nothing sits flush against the terminal edge. The bands lay out + // inside it, so every width/height below is the INNER box, not the terminal. + const contentCols = Math.max(20, termCols - APP_INSET * 2) + const contentRows = Math.max(6, height - APP_INSET * 2) // ------------------------------ sidebar data ------------------------------ @@ -173,8 +203,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { return () => clearInterval(t) }, []) + // Cloud sessions only. A laptop session is a local `claude` run synced up for + // the record; opening one here has nothing to connect to, so it would be a + // dead row taking a slot from the five cloud sessions worth showing. const rows = useMemo( - () => sortSidebarSessions([...localSessions, ...sessions]), + () => mergeSidebarSessions(sessions, localSessions).filter((s) => sessionSource(s) !== 'laptop'), [localSessions, sessions], ) @@ -343,17 +376,21 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { setFocus('chat') }, [selected]) - // The selectable id list, left to right: the pinned new cell, then sessions. + // The selectable id list, top to bottom: the pinned new row, then sessions. const selectable = useMemo(() => ['new', ...rows.map((s) => s.id)], [rows]) useInput( (ch, key) => { - // The nav is a horizontal bar: ←/→ move the highlight, enter opens - // the highlighted session, ↑/esc return to the chat. - if (key.leftArrow || key.rightArrow) { + // The nav is a vertical list: ↑/↓ move the highlight, enter opens the + // highlighted session, esc (or ↑ off the top row) returns to the chat. + if (key.upArrow || key.downArrow) { const idx = selectable.indexOf(selected) - const next = key.leftArrow - ? Math.max(0, idx < 0 ? 0 : idx - 1) + if (key.upArrow && idx <= 0) { + setFocus('chat') + return + } + const next = key.upArrow + ? Math.max(0, idx - 1) : Math.min(selectable.length - 1, idx < 0 ? 0 : idx + 1) setSelected(selectable[next]) return @@ -362,7 +399,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { openSelected() return } - if (key.upArrow || key.escape) { + if (key.escape) { setFocus('chat') return } @@ -387,25 +424,59 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------- rendering -------------------------------- - // Band heights: header = blank + title line + rule (3); nav = rule + - // 2-line cells + hint (4). The chat band gets the rest. + // Band heights: header = blank + title line + rule (3); nav = rule + the + // new-session row + five session rows + hint (8). The chat band gets the + // rest, and a terminal too short for both drops session rows rather than + // growing the frame past the screen. const headerRows = 3 - const navRows = 4 - const chatRows = Math.max(4, height - headerRows - navRows) + const navSessionRows = Math.max(1, Math.min(NAV_SESSION_ROWS, contentRows - 10)) + const navRows = 3 + navSessionRows + const chatRows = Math.max(4, contentRows - headerRows - navRows) // ---- band 1: the header ---- - // The focused session's live meta (status · cost · model · id · version), - // derived from its transcript store so it ticks like the old footer did. + // "ellipsis.dev" pins the left edge as always; the right edge carries the + // focused session's live meta (id · model · cost · tokens), derived from + // its transcript store so it ticks like the old footer did. With no + // session focused the right edge falls back to the who-tag. const focusedEntry = mainPane.type === 'chat' ? entries.get(mainPane.sessionId) : undefined - const metaText = useHeaderMeta(focusedEntry, mainPane, appBase, customerLogin) + const metaText = useHeaderMeta(focusedEntry, mainPane, appBase, customerLogin, contentCols - 4) + const whoText = props.ghLogin + ? `@${props.ghLogin} in ${customerLogin}` + : customerLogin const header = ( - - - - ellipsis.dev - {metaText && · {metaText}} + // The top bar is a lifted surface, like the composer: the panel tint (not + // a rule) is what separates it from the transcript below. Its own 2-cell + // pad sits inside the tint, so the bar reads as a band with the title + // floating in it rather than type pinned to an edge. + + {/* The explicit width is what wrap="truncate" measures against: without + it the meta line sizes the row to its own content and overflows the + terminal instead of clipping. */} + + + + + {HEADER_TITLE} + + + + + {/* truncate, never wrap: the bar is budgeted at exactly one row, and + a wrapped meta line pushes the rule off the bottom of the band. */} + + {metaText ?? whoText} + + - {'─'.repeat(Math.max(0, termCols))} ) @@ -415,9 +486,9 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { main = ( // Full width, no side padding: the prompt input's rules span the // terminal exactly like the header's and the nav's do. - + void startSession(text, choices)} onLeave={focusNav} rawMode={isRawModeSupported} @@ -436,7 +508,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { if (!entry) { main = (