@tianmucreations/jeeves 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/bin/jeeves +2 -0
  4. package/dist/agent/context.js +50 -0
  5. package/dist/agent/errors.js +41 -0
  6. package/dist/agent/loop.js +84 -0
  7. package/dist/agent/permissions.js +27 -0
  8. package/dist/app.js +68 -0
  9. package/dist/commands/clear.js +9 -0
  10. package/dist/commands/help.js +17 -0
  11. package/dist/commands/keys.js +15 -0
  12. package/dist/commands/model.js +4 -0
  13. package/dist/commands/verbose.js +8 -0
  14. package/dist/components/AlternateScreen.js +74 -0
  15. package/dist/components/Footer.js +114 -0
  16. package/dist/components/Header.js +6 -0
  17. package/dist/components/HelpView.js +14 -0
  18. package/dist/components/Input.js +76 -0
  19. package/dist/components/KeysManager.js +281 -0
  20. package/dist/components/ModelPicker.js +457 -0
  21. package/dist/components/ProjectPicker.js +334 -0
  22. package/dist/components/TrafficLight.js +116 -0
  23. package/dist/components/Transcript.js +23 -0
  24. package/dist/components/UsageBar.js +35 -0
  25. package/dist/components/transcript-layout.js +103 -0
  26. package/dist/index.js +53 -0
  27. package/dist/ink/AlternateScreen.js +106 -0
  28. package/dist/keys/store.js +58 -0
  29. package/dist/models/filter.js +4 -0
  30. package/dist/models/registry.js +112 -0
  31. package/dist/platform/config.js +60 -0
  32. package/dist/platform/paths.js +60 -0
  33. package/dist/platform/shell.js +9 -0
  34. package/dist/providers/index.js +165 -0
  35. package/dist/providers/ollama.js +103 -0
  36. package/dist/providers/openrouter.js +109 -0
  37. package/dist/providers/types.js +1 -0
  38. package/dist/providers/zai.js +104 -0
  39. package/dist/state/session.js +315 -0
  40. package/dist/tools/index.js +106 -0
  41. package/dist/tools/listDir.js +55 -0
  42. package/dist/tools/readFile.js +15 -0
  43. package/dist/tools/runBash.js +22 -0
  44. package/dist/tools/writeFile.js +15 -0
  45. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tianmucreations
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # Jeeves
2
+
3
+ A plain-English terminal assistant: describe what you need in ordinary words and it reads files, writes files, lists folders, and runs commands for you - asking permission before anything that changes your computer.
4
+
5
+ ## Platforms
6
+
7
+ Designed and built for **macOS, Windows, and Linux** from a single codebase.
8
+
9
+ - macOS is fully verified.
10
+ - Windows and Linux will be verified on those machines after the MVP, with any fixes applied to this same codebase - no platform forks.
11
+
12
+ ## Quick start (local development)
13
+
14
+ 1. Install Node.js 20 or later.
15
+ 2. `npm install`
16
+ 3. `npm run dev`
17
+
18
+ On first launch you are asked for an OpenRouter API key, which is stored securely in your operating system's credential store (macOS Keychain, Windows Credential Vault, Linux Secret Service) - never in a plain file.
19
+
20
+ ## Keyboard
21
+
22
+ Everything is visible on screen: arrow keys move, Enter selects, Esc goes back. `Tab` zooms a footer metric. `Ctrl+R` reveals the model's last reasoning. Slash commands: `/model`, `/keys`, `/verbose`.
23
+
24
+ Jeeves takes over the whole terminal window (the same way vim does). The shell's own scrollback is unavailable while it runs, so the up and down arrows scroll the conversation instead - `Page Up` / `Page Down` jump a whole screen. When you quit, the terminal returns exactly as it was.
25
+
26
+ ## Providers
27
+
28
+ - **OpenRouter (default).** One key unlocks 400+ models from every major provider. Get a key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys). Repeat conversation context is cached (sticky routing), so long conversations cost a fraction of the fresh-token price.
29
+ - **Z.ai — GLM Coding Plan.** A flat-rate option for heavy daily use: from $18/month, no per-token billing. Z.ai's endpoint speaks the Anthropic protocol and Jeeves connects to it directly. To use it: subscribe at [z.ai](https://z.ai) if you want the plan, copy your Z.ai API key, then in Jeeves type `/keys`, choose Z.ai, and paste the key. Pick Z.ai in `/model` and choose a GLM model (for example GLM-5.3). Subscribing is optional - Jeeves works fine with OpenRouter alone; this is simply a money-saving option for daily drivers.
30
+ - **Ollama.** Local models, no key needed. Start the Ollama app first.
31
+
32
+ API keys are stored in your operating system's credential store - never in a plain file.
package/bin/jeeves ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -0,0 +1,50 @@
1
+ import { session } from '../state/session.js';
2
+ import { getActiveProvider } from '../providers/index.js';
3
+ // Builds the message list for one turn; automatic summarisation of older turns (spec 3.3)
4
+ // is deferred until long-conversation handling lands.
5
+ export function buildTurnMessages(history, userText) {
6
+ const userMessage = { role: 'user', content: userText };
7
+ return [...history, userMessage];
8
+ }
9
+ // Condenses the whole conversation into a single summary message so a newly
10
+ // selected model can continue without re-reading every turn.
11
+ export async function summariseHistory() {
12
+ if (session.history.length === 0)
13
+ return;
14
+ session.setStatus('working');
15
+ try {
16
+ const provider = getActiveProvider();
17
+ const messages = [
18
+ ...session.history,
19
+ {
20
+ role: 'user',
21
+ content: 'Summarise this conversation so far as compact context a model can continue from. Reply with only the summary.',
22
+ },
23
+ ];
24
+ const result = await provider.stream({
25
+ modelId: session.model,
26
+ messages,
27
+ tools: {},
28
+ onToken: () => { },
29
+ onReasoning: () => { },
30
+ onToolCall: () => { },
31
+ });
32
+ const summary = result.text.trim();
33
+ if (summary) {
34
+ session.setHistory([
35
+ {
36
+ role: 'user',
37
+ content: `A summary of the conversation so far:\n\n${summary}\n\nContinue helping from this point.`,
38
+ },
39
+ ]);
40
+ session.addNotice('Conversation summarised for the new model.');
41
+ }
42
+ else {
43
+ session.addNotice('Could not summarise - kept the conversation as-is.');
44
+ }
45
+ }
46
+ catch {
47
+ session.addNotice('Could not summarise - kept the conversation as-is.');
48
+ }
49
+ session.setStatus('idle');
50
+ }
@@ -0,0 +1,41 @@
1
+ function firstLine(text) {
2
+ const line = text.split('\n')[0].trim();
3
+ return line.length > 0 ? line : 'Something went wrong - please ask again.';
4
+ }
5
+ // Turns technical failures into plain English (Phase 9 polish).
6
+ export function plainError(error) {
7
+ const raw = error instanceof Error ? error.message : String(error);
8
+ const text = raw.toLowerCase();
9
+ if (text.includes('no openrouter api key')) {
10
+ return { message: "There's no key yet - type /keys to add one.", kind: 'auth' };
11
+ }
12
+ if (text.includes('401') || text.includes('unauthorized') || text.includes('invalid api key') || text.includes('not authenticated')) {
13
+ return { message: "That key wasn't accepted - type /keys to check or replace it.", kind: 'auth' };
14
+ }
15
+ if (text.includes('402') || text.includes('insufficient') || text.includes('out of credit') || text.includes('quota')) {
16
+ return { message: 'OpenRouter credit ran out - top up at openrouter.ai/credits, then ask again.', kind: 'payment' };
17
+ }
18
+ if (text.includes('429') || text.includes('rate limit') || text.includes('rate_limit') || text.includes('too many requests')) {
19
+ return { message: 'OpenRouter is asking us to slow down - wait a few seconds and ask again.', kind: 'rate-limit' };
20
+ }
21
+ if (text.includes('fetch failed') ||
22
+ text.includes('network') ||
23
+ text.includes('enotfound') ||
24
+ text.includes('econnrefused') ||
25
+ text.includes('etimedout') ||
26
+ text.includes('timeout') ||
27
+ text.includes('eai_again') ||
28
+ text.includes('socket hang up')) {
29
+ return { message: 'Lost connection - please ask again in a moment.', kind: 'network' };
30
+ }
31
+ if (text.includes('model') && (text.includes('not found') || text.includes('404'))) {
32
+ return { message: "That model isn't available any more - type /model to pick another.", kind: 'model' };
33
+ }
34
+ if (text.includes('context') && (text.includes('length') || text.includes('too long'))) {
35
+ return {
36
+ message: 'This conversation grew too long for the model - type /model to switch, or /clear to start fresh.',
37
+ kind: 'context',
38
+ };
39
+ }
40
+ return { message: firstLine(raw), kind: 'other' };
41
+ }
@@ -0,0 +1,84 @@
1
+ import { session } from '../state/session.js';
2
+ import { getActiveProvider, refreshCredit } from '../providers/index.js';
3
+ import { getTools } from '../tools/index.js';
4
+ import { buildTurnMessages } from './context.js';
5
+ import { plainError } from './errors.js';
6
+ import { toggleVerbose } from '../commands/verbose.js';
7
+ import { openModelPicker } from '../commands/model.js';
8
+ import { clearConversation } from '../commands/clear.js';
9
+ import { isToolCapable } from '../models/filter.js';
10
+ const DISCONNECTING = new Set(['auth', 'network', 'payment']);
11
+ export async function runTurn(input) {
12
+ if (input.startsWith('/') && input.length > 1 && !input.startsWith('/ ')) {
13
+ if (input === '/help') {
14
+ session.openHelp();
15
+ }
16
+ else if (input === '/model') {
17
+ openModelPicker();
18
+ }
19
+ else if (input === '/keys') {
20
+ session.openKeys();
21
+ }
22
+ else if (input === '/verbose') {
23
+ session.addNotice(toggleVerbose());
24
+ }
25
+ else if (input === '/clear') {
26
+ clearConversation();
27
+ }
28
+ else if (input === '/exit') {
29
+ session.requestExit();
30
+ }
31
+ else {
32
+ session.addNotice('Unknown command. Try /help.');
33
+ }
34
+ return;
35
+ }
36
+ session.addUser(input);
37
+ session.beginTurn();
38
+ session.setStatus('working');
39
+ let assistantId = null;
40
+ try {
41
+ const provider = getActiveProvider();
42
+ const messages = buildTurnMessages(session.history, input);
43
+ // Models without tool support get a tool-free chat mode automatically (spec 4.2).
44
+ const currentModel = session.models.find((model) => model.id === session.model);
45
+ const tools = !currentModel || isToolCapable(currentModel) ? getTools() : {};
46
+ const result = await provider.stream({
47
+ modelId: session.model,
48
+ messages,
49
+ tools,
50
+ onToken: (token) => {
51
+ if (assistantId === null)
52
+ assistantId = session.startAssistant();
53
+ session.appendToken(assistantId, token);
54
+ },
55
+ onReasoning: (delta) => {
56
+ if (session.verbose)
57
+ session.appendReasoning(delta);
58
+ },
59
+ onToolCall: () => {
60
+ // Hide pre-tool chatter so only the final answer stays visible (spec 2.3).
61
+ if (assistantId !== null)
62
+ session.setAssistantText(assistantId, '');
63
+ session.closeReasoningEntry();
64
+ },
65
+ });
66
+ if (assistantId === null)
67
+ assistantId = session.startAssistant();
68
+ session.setAssistantText(assistantId, result.text);
69
+ session.finishAssistant(assistantId);
70
+ session.setHistory([...messages, ...result.messages]);
71
+ session.setLastReasoning(result.reasoning);
72
+ session.addUsage(result.usage.input, result.usage.output, result.cost, result.usage.cached ?? 0);
73
+ session.setRateLimit(result.rateLimit);
74
+ void refreshCredit();
75
+ session.setStatus('idle');
76
+ }
77
+ catch (error) {
78
+ const plain = plainError(error);
79
+ if (assistantId !== null)
80
+ session.finishAssistant(assistantId);
81
+ session.addError(plain.message);
82
+ session.setStatus(DISCONNECTING.has(plain.kind) ? 'disconnected' : 'idle');
83
+ }
84
+ }
@@ -0,0 +1,27 @@
1
+ import { session } from '../state/session.js';
2
+ // Approvals are queued so that parallel tool calls never overwrite each other's prompt.
3
+ // The amber transcript prompt itself is rendered by the tool line in 'awaiting' state.
4
+ const queue = [];
5
+ export function hasPendingApproval() {
6
+ return queue.length > 0;
7
+ }
8
+ export function requestApproval() {
9
+ return new Promise((resolve) => {
10
+ queue.push({ resolve });
11
+ if (queue.length === 1) {
12
+ session.setActiveApproval();
13
+ }
14
+ });
15
+ }
16
+ export function answerApproval(approved) {
17
+ const current = queue.shift();
18
+ if (!current)
19
+ return;
20
+ current.resolve(approved);
21
+ if (queue.length > 0) {
22
+ session.setActiveApproval();
23
+ }
24
+ else {
25
+ session.clearActiveApproval();
26
+ }
27
+ }
package/dist/app.js ADDED
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, Text, useStdout } from 'ink';
4
+ import { Header } from './components/Header.js';
5
+ import { Transcript } from './components/Transcript.js';
6
+ import { Input } from './components/Input.js';
7
+ import { Footer } from './components/Footer.js';
8
+ import { session, useSession } from './state/session.js';
9
+ import { initKeys, hasCredentials, refreshCredit } from './providers/index.js';
10
+ import { getHiddenMetrics, getFavorites, getRecents, getRecentProjects, getDefaultModel, getDefaultProvider, getVerbosePreference } from './platform/config.js';
11
+ import { loadModels } from './models/registry.js';
12
+ import { ModelPicker } from './components/ModelPicker.js';
13
+ import { KeysManager } from './components/KeysManager.js';
14
+ import { HelpView } from './components/HelpView.js';
15
+ import { ProjectPicker } from './components/ProjectPicker.js';
16
+ // The whole frame is exactly the height of the terminal window, so nothing ever
17
+ // scrolls away: the header is pinned at the top, the footer and input at the bottom,
18
+ // and only the fixed-height transcript area in the middle re-clips its content.
19
+ export function App() {
20
+ const s = useSession();
21
+ const { stdout } = useStdout();
22
+ const rows = Math.max(stdout.rows ?? 24, 8);
23
+ const columns = Math.max(stdout.columns ?? 80, 40);
24
+ const transcriptHeight = Math.max(1, rows - 7);
25
+ const innerWidth = columns - 4;
26
+ const separator = '─'.repeat(innerWidth);
27
+ useEffect(() => {
28
+ session.setHiddenMetrics(getHiddenMetrics());
29
+ session.setFavorites(getFavorites());
30
+ session.setRecents(getRecents());
31
+ session.setRecentProjects(getRecentProjects());
32
+ if (getVerbosePreference())
33
+ session.setVerbose(true);
34
+ const defaultModel = getDefaultModel();
35
+ if (defaultModel)
36
+ session.setModel(defaultModel);
37
+ const defaultProvider = getDefaultProvider();
38
+ if (defaultProvider)
39
+ session.setProvider(defaultProvider);
40
+ void loadModels().then(({ models, error }) => session.setModels(models, error));
41
+ // Keys resolve from the Mac keychain first; the first-run wizard now starts
42
+ // after the project is chosen (the project list always shows first).
43
+ void initKeys().then(() => {
44
+ if (hasCredentials()) {
45
+ void refreshCredit();
46
+ }
47
+ else {
48
+ session.setStatus('disconnected');
49
+ }
50
+ });
51
+ }, []);
52
+ if (s.wizardActive) {
53
+ return _jsx(KeysManager, { mode: "wizard", rows: rows, columns: columns });
54
+ }
55
+ if (s.keysOpen) {
56
+ return _jsx(KeysManager, { mode: "manage", rows: rows, columns: columns });
57
+ }
58
+ if (s.pickerOpen) {
59
+ return _jsx(ModelPicker, { rows: rows, columns: columns });
60
+ }
61
+ if (s.helpOpen) {
62
+ return _jsx(HelpView, { rows: rows });
63
+ }
64
+ if (s.launchStage === 'project') {
65
+ return _jsx(ProjectPicker, { rows: rows, columns: columns });
66
+ }
67
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, height: rows, children: [_jsx(Header, {}), _jsx(Transcript, { height: transcriptHeight, width: innerWidth }), _jsx(Text, { dimColor: true, children: separator }), _jsx(Input, { scrollPage: transcriptHeight }), _jsx(Text, { dimColor: true, children: separator }), _jsx(Footer, {})] }));
68
+ }
@@ -0,0 +1,9 @@
1
+ import { session } from '../state/session.js';
2
+ import { resetStickySession } from '../providers/openrouter.js';
3
+ // Starts fresh: wipes the screen and the conversation the model remembers.
4
+ export function clearConversation() {
5
+ session.clearTranscript();
6
+ session.setHistory([]);
7
+ session.setLastReasoning('');
8
+ resetStickySession();
9
+ }
@@ -0,0 +1,17 @@
1
+ export const COMMANDS = [
2
+ { command: '/help', description: 'show this list' },
3
+ { command: '/model', description: 'pick a different AI model' },
4
+ { command: '/keys', description: 'add or remove API keys' },
5
+ { command: '/verbose', description: "show the model's thinking on screen" },
6
+ { command: '/clear', description: 'start a fresh conversation and clear the screen' },
7
+ { command: '/exit', description: 'quit' },
8
+ ];
9
+ export const KEY_BINDINGS = [
10
+ { command: 'up down', description: 'move in lists' },
11
+ { command: 'Enter', description: 'select' },
12
+ { command: 'Esc', description: 'go back' },
13
+ { command: 'Tab', description: 'zoom a footer bar' },
14
+ { command: 'Ctrl+R', description: "show the model's last thinking" },
15
+ { command: 'y / n', description: 'allow or deny a permission request' },
16
+ { command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen' },
17
+ ];
@@ -0,0 +1,15 @@
1
+ // Plain-English key helpers; the interactive screens live in KeysManager.
2
+ export function keyLooksValid(key, provider) {
3
+ const trimmed = key.trim();
4
+ if (provider === 'openrouter' || provider === 'openrouter-management') {
5
+ return trimmed.startsWith('sk-or-') && trimmed.length >= 20;
6
+ }
7
+ return trimmed.length >= 20;
8
+ }
9
+ export function describeKeySource(source) {
10
+ if (source === 'keychain')
11
+ return 'key stored in your Mac keychain';
12
+ if (source === 'env')
13
+ return 'key in the .env development file';
14
+ return 'no key';
15
+ }
@@ -0,0 +1,4 @@
1
+ import { session } from '../state/session.js';
2
+ export function openModelPicker() {
3
+ session.openPicker();
4
+ }
@@ -0,0 +1,8 @@
1
+ import { session } from '../state/session.js';
2
+ import { setVerbosePreference } from '../platform/config.js';
3
+ // Toggles the permanent reasoning trace; default is off (spec 2.3).
4
+ export function toggleVerbose() {
5
+ session.setVerbose(!session.verbose);
6
+ setVerbosePreference(session.verbose);
7
+ return session.verbose ? 'Verbose reasoning: on' : 'Verbose reasoning: off';
8
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useInsertionEffect } from 'react';
3
+ import { Box, useStdout } from 'ink';
4
+ import { createRequire } from 'node:module';
5
+ // signal-exit is the cleanup hook the mechanism prescribes: it runs on process
6
+ // exit AND on every termination signal, before the default signal action - so
7
+ // the terminal is handed back on every exit path: normal exit, Ctrl+C, SIGTERM,
8
+ // hangup. Already present in the tree as Ink's own dependency; declared ours.
9
+ const onSignalExit = createRequire(import.meta.url)('signal-exit');
10
+ const CLEAR_SCREEN = '\x1b[2J';
11
+ const HOME_CURSOR = '\x1b[H';
12
+ const ERASE_SCROLLBACK = '\x1b[3J';
13
+ const ENTER_ALT_SCREEN = '\x1b[?1049h';
14
+ const LEAVE_ALT_SCREEN = '\x1b[?1049l';
15
+ const HIDE_CURSOR = '\x1b[?25l';
16
+ const SHOW_CURSOR = '\x1b[?25h';
17
+ let altScreenActive = false;
18
+ let cleanupRegistered = false;
19
+ function write(data) {
20
+ try {
21
+ process.stdout.write(data);
22
+ }
23
+ catch {
24
+ // A closed stream must never crash the takeover or the exit path.
25
+ }
26
+ }
27
+ // Claude Code's Ink fork exposes this on the render instance; stock Ink has no
28
+ // such method, so the notification lives here: the flag that says the alternate
29
+ // screen owns the terminal, consulted by every exit path below.
30
+ export function setAltScreenActive(active, mouseTracking) {
31
+ altScreenActive = active;
32
+ // Mouse tracking is deliberately left off: it would break click-drag text
33
+ // selection in Terminal.app, and the app already owns scrolling by key.
34
+ void mouseTracking;
35
+ }
36
+ // Hands the terminal back. Safe to call from anywhere, any number of times.
37
+ export function leaveAltScreen() {
38
+ if (!altScreenActive)
39
+ return;
40
+ altScreenActive = false;
41
+ // 1049l restores the shell's screen; the scrollback erase follows because
42
+ // macOS Terminal.app archives the app's own frames into the scrollback the
43
+ // moment the alternate screen is handed back, and they must not linger.
44
+ write(LEAVE_ALT_SCREEN + ERASE_SCROLLBACK + SHOW_CURSOR);
45
+ }
46
+ // The terminal must be restored on every exit path. Registered once for the
47
+ // whole process; the callback fires after Ink's own teardown (registered
48
+ // earlier), so frame cleanup lands on the alternate screen before we leave it.
49
+ function registerCleanup() {
50
+ if (cleanupRegistered)
51
+ return;
52
+ cleanupRegistered = true;
53
+ onSignalExit(() => leaveAltScreen(), { alwaysLast: false });
54
+ }
55
+ export function AlternateScreen({ children }) {
56
+ const { stdout } = useStdout();
57
+ // Entered once, before the first frame. Claude Code's order is erase
58
+ // scrollback, clear, home, switch - but macOS Terminal.app implements the
59
+ // clear (2J) by pushing the cleared screen INTO the scrollback, so erasing
60
+ // first lets the shell's last screen survive as scrollable history (measured:
61
+ // the banner came back on scroll). Clearing first and erasing the scrollback
62
+ // second kills both the old history and the clear's own snapshot; on terminals
63
+ // that clear without archiving, both orders are equivalent. The cursor is
64
+ // hidden for the same reason Ink's own mode hides it.
65
+ useInsertionEffect(() => {
66
+ write(CLEAR_SCREEN + HOME_CURSOR + ERASE_SCROLLBACK + ENTER_ALT_SCREEN + HIDE_CURSOR);
67
+ setAltScreenActive(true, false);
68
+ registerCleanup();
69
+ }, []);
70
+ const rows = Math.max(stdout.rows ?? 24, 8);
71
+ // The alternate screen has no native scrollback, so the app owns its own
72
+ // scrolling: everything is constrained to the terminal's row count.
73
+ return (_jsx(Box, { height: rows, flexDirection: "column", overflow: "hidden", children: children }));
74
+ }
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useSession, DEFAULT_CONTEXT_TOKENS } from '../state/session.js';
5
+ import { UsageBar, usageFraction, usageColor, benefitColor } from './UsageBar.js';
6
+ export function shortModelName(model) {
7
+ const short = model.split('/').pop();
8
+ return short && short.length > 0 ? short : model;
9
+ }
10
+ export function compactNumber(value) {
11
+ if (value >= 1_000_000)
12
+ return `${(value / 1_000_000).toFixed(1)}M`;
13
+ if (value >= 1000)
14
+ return `${(value / 1000).toFixed(1)}k`;
15
+ return `${Math.round(value)}`;
16
+ }
17
+ function pct(fraction) {
18
+ return `${Math.round(fraction * 100)}%`;
19
+ }
20
+ function forecastContext(contextTokens, tokensPerMinute) {
21
+ if (tokensPerMinute <= 0 || contextTokens <= 0)
22
+ return '';
23
+ const minutes = (DEFAULT_CONTEXT_TOKENS - contextTokens) / tokensPerMinute;
24
+ if (minutes <= 0 || minutes > 600)
25
+ return '';
26
+ return `~${Math.max(1, Math.round(minutes))}m left`;
27
+ }
28
+ function forecastRateReset(resetEpochSeconds) {
29
+ if (!resetEpochSeconds)
30
+ return '';
31
+ const seconds = resetEpochSeconds - Date.now() / 1000;
32
+ if (seconds <= 0 || seconds > 3600)
33
+ return '';
34
+ return `resets in ${Math.max(1, Math.round(seconds / 60))}m`;
35
+ }
36
+ export function Footer() {
37
+ const s = useSession();
38
+ const contextTokens = s.estimateContextTokens();
39
+ const tpm = s.tokensPerMinute();
40
+ const cacheRate = s.cacheHitRate();
41
+ if (s.footerExpanded !== null) {
42
+ let bar = {
43
+ label: 'context',
44
+ value: contextTokens,
45
+ max: DEFAULT_CONTEXT_TOKENS,
46
+ suffix: forecastContext(contextTokens, tpm),
47
+ };
48
+ if (s.footerExpanded === 'session') {
49
+ bar = { label: 'session', value: s.tokensIn + s.tokensOut, max: DEFAULT_CONTEXT_TOKENS, suffix: '' };
50
+ }
51
+ else if (s.footerExpanded === 'cache' && cacheRate !== null) {
52
+ bar = {
53
+ label: 'cache',
54
+ value: s.tokensCached,
55
+ max: s.tokensIn,
56
+ unit: 'of input read from cache',
57
+ suffix: '',
58
+ goodWhenFull: true,
59
+ };
60
+ }
61
+ else if (s.footerExpanded === 'today' && s.spend > 0 && s.creditLimit) {
62
+ bar = { label: 'today', value: s.spend, max: s.creditLimit, suffix: '' };
63
+ }
64
+ else if (s.footerExpanded === 'credit' && s.creditRemaining !== null && s.creditLimit) {
65
+ bar = {
66
+ label: 'credit',
67
+ value: s.creditLimit - s.creditRemaining,
68
+ max: s.creditLimit,
69
+ unit: 'used',
70
+ suffix: `· $${s.creditRemaining.toFixed(2)} ${s.creditIsAccount ? 'left' : 'key cap'}`,
71
+ };
72
+ }
73
+ else if (s.footerExpanded === 'speed' && s.rateLimit && s.rateLimit.limit > 0) {
74
+ bar = {
75
+ label: 'speed',
76
+ value: s.rateLimit.limit - s.rateLimit.remaining,
77
+ max: s.rateLimit.limit,
78
+ suffix: forecastRateReset(s.rateLimit.reset),
79
+ };
80
+ }
81
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: shortModelName(s.model) }), _jsxs(Text, { dimColor: true, children: [_jsx(UsageBar, { label: bar.label, value: bar.value, max: bar.max, unit: bar.unit, width: 20, goodWhenFull: bar.goodWhenFull }), bar.suffix ? _jsxs(Text, { dimColor: true, children: [" ", bar.suffix] }) : null] })] }));
82
+ }
83
+ // The compact view shows every metric at all times - nothing needs a key press.
84
+ const sessionFraction = usageFraction(s.tokensIn + s.tokensOut, DEFAULT_CONTEXT_TOKENS);
85
+ const contextFraction = usageFraction(contextTokens, DEFAULT_CONTEXT_TOKENS);
86
+ const segments = [
87
+ {
88
+ key: 'session',
89
+ render: _jsxs(Text, { color: usageColor(sessionFraction), children: ["sess ", pct(sessionFraction)] }),
90
+ },
91
+ {
92
+ key: 'context',
93
+ render: _jsxs(Text, { color: usageColor(contextFraction), children: ["ctx ", pct(contextFraction)] }),
94
+ },
95
+ {
96
+ key: 'cache',
97
+ render: cacheRate === null ? (_jsx(Text, { children: "cache \u2014" })) : (_jsxs(Text, { color: benefitColor(cacheRate), children: ["cache ", pct(cacheRate)] })),
98
+ },
99
+ {
100
+ key: 'today',
101
+ render: _jsxs(Text, { children: ["today $", s.spend.toFixed(2)] }),
102
+ },
103
+ {
104
+ key: 'credit',
105
+ render: (_jsx(Text, { children: s.creditRemaining !== null
106
+ ? s.creditIsAccount
107
+ ? `$${s.creditRemaining.toFixed(2)} left`
108
+ : `$${s.creditRemaining.toFixed(2)} key cap`
109
+ : '$— left' })),
110
+ },
111
+ ];
112
+ const visible = segments.filter((segment) => !s.hiddenMetrics.includes(segment.key));
113
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: shortModelName(s.model) }), _jsxs(Text, { dimColor: true, children: [visible.map((segment, index) => (_jsxs(React.Fragment, { children: [index > 0 ? ' · ' : null, segment.render] }, segment.key))), visible.length > 0 ? ' · ' : null, _jsxs(Text, { children: [compactNumber(tpm), " tpm"] })] })] }));
114
+ }
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { TrafficLight } from './TrafficLight.js';
4
+ export function Header() {
5
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: "cyan", children: "Jeeves" }), _jsx(TrafficLight, {})] }));
6
+ }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useSession } from '../state/session.js';
4
+ import { COMMANDS, KEY_BINDINGS } from '../commands/help.js';
5
+ // Plain-English help; every command and key is visible with a one-line description.
6
+ export function HelpView({ rows }) {
7
+ const s = useSession();
8
+ useInput((input, key) => {
9
+ if (key.escape || key.return) {
10
+ s.closeHelp();
11
+ }
12
+ });
13
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Help - what you can type" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsx(Text, { children: "Commands" }), COMMANDS.map((entry) => (_jsxs(Text, { children: [_jsx(Text, { children: ' ' + entry.command.padEnd(9) }), _jsx(Text, { dimColor: true, children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { children: "Keys" }), KEY_BINDINGS.map((entry) => (_jsxs(Text, { children: [_jsx(Text, { children: ' ' + entry.command.padEnd(9) }), _jsx(Text, { dimColor: true, children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Type anything else in plain English and press Enter - that's all you need." })] }), _jsx(Text, { dimColor: true, children: "Esc close" })] }));
14
+ }