@matterailab/orbcode 0.1.3

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +471 -0
  3. package/bin/orbcode.js +2 -0
  4. package/dist/api/client.js +141 -0
  5. package/dist/api/headers.js +14 -0
  6. package/dist/api/models.js +49 -0
  7. package/dist/api/stream.js +1 -0
  8. package/dist/auth/auth.js +172 -0
  9. package/dist/branding.js +31 -0
  10. package/dist/config/promptHistory.js +33 -0
  11. package/dist/config/settings.js +112 -0
  12. package/dist/core/agent.js +459 -0
  13. package/dist/core/events.js +1 -0
  14. package/dist/core/sessions.js +44 -0
  15. package/dist/headless.js +64 -0
  16. package/dist/index.js +84 -0
  17. package/dist/prompts/system.js +379 -0
  18. package/dist/tools/executors/executeCommand.js +58 -0
  19. package/dist/tools/executors/files.js +197 -0
  20. package/dist/tools/executors/listFiles.js +65 -0
  21. package/dist/tools/executors/searchFiles.js +104 -0
  22. package/dist/tools/executors/web.js +72 -0
  23. package/dist/tools/index.js +85 -0
  24. package/dist/tools/schemas/ask_followup_question.js +40 -0
  25. package/dist/tools/schemas/attempt_completion.js +19 -0
  26. package/dist/tools/schemas/browser_action.js +60 -0
  27. package/dist/tools/schemas/check_past_chat_memories.js +23 -0
  28. package/dist/tools/schemas/codebase_search.js +23 -0
  29. package/dist/tools/schemas/execute_command.js +31 -0
  30. package/dist/tools/schemas/fetch_instructions.js +20 -0
  31. package/dist/tools/schemas/file_edit.js +31 -0
  32. package/dist/tools/schemas/file_write.js +27 -0
  33. package/dist/tools/schemas/generate_image.js +27 -0
  34. package/dist/tools/schemas/index.js +29 -0
  35. package/dist/tools/schemas/list_code_definition_names.js +19 -0
  36. package/dist/tools/schemas/list_files.js +23 -0
  37. package/dist/tools/schemas/lsp.js +46 -0
  38. package/dist/tools/schemas/multi_file_edit.js +42 -0
  39. package/dist/tools/schemas/new_task.js +27 -0
  40. package/dist/tools/schemas/read_file.js +27 -0
  41. package/dist/tools/schemas/run_slash_command.js +23 -0
  42. package/dist/tools/schemas/search_files.js +27 -0
  43. package/dist/tools/schemas/switch_mode.js +23 -0
  44. package/dist/tools/schemas/update_todo_list.js +19 -0
  45. package/dist/tools/schemas/use_skill.js +19 -0
  46. package/dist/tools/schemas/web_fetch.js +19 -0
  47. package/dist/tools/schemas/web_search.js +19 -0
  48. package/dist/tools/types.js +7 -0
  49. package/dist/ui/App.js +569 -0
  50. package/dist/ui/LoginView.js +82 -0
  51. package/dist/ui/components/ApprovalPrompt.js +21 -0
  52. package/dist/ui/components/FollowupPrompt.js +45 -0
  53. package/dist/ui/components/Header.js +12 -0
  54. package/dist/ui/components/InputBox.js +220 -0
  55. package/dist/ui/components/ModelPicker.js +51 -0
  56. package/dist/ui/components/SessionPicker.js +52 -0
  57. package/dist/ui/components/Spinner.js +19 -0
  58. package/dist/ui/components/StatusBar.js +18 -0
  59. package/dist/ui/components/rows.js +106 -0
  60. package/dist/ui/markdown.js +64 -0
  61. package/dist/utils/diff.js +144 -0
  62. package/dist/utils/shell.js +19 -0
  63. package/package.json +62 -0
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { COLORS } from "../../branding.js";
4
+ import { DiffView, formatToolName } from "./rows.js";
5
+ export function ApprovalPrompt({ request, onDecision }) {
6
+ useInput((input, key) => {
7
+ const lower = input.toLowerCase();
8
+ if (lower === "y" || key.return)
9
+ onDecision("yes");
10
+ else if (lower === "n" || key.escape)
11
+ onDecision("no");
12
+ else if (lower === "a" && !request.isDangerous)
13
+ onDecision("always");
14
+ });
15
+ const title = request.kind === "command"
16
+ ? request.isDangerous
17
+ ? "Run this command? (marked as potentially dangerous)"
18
+ : "Run this command?"
19
+ : `Apply this change? (${formatToolName(request.toolName)})`;
20
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: request.isDangerous ? COLORS.error : COLORS.warning, paddingX: 1, children: [_jsx(Text, { bold: true, color: request.isDangerous ? COLORS.error : COLORS.warning, children: title }), request.diff ? (_jsx(Box, { paddingLeft: 2, children: _jsx(DiffView, { diff: request.diff }) })) : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: request.detail }) })), _jsxs(Text, { dimColor: true, children: ["(y) yes \u00B7 (n) no", !request.isDangerous && " · (a) always for this session"] })] }));
21
+ }
@@ -0,0 +1,45 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { COLORS } from "../../branding.js";
5
+ export function FollowupPrompt({ question, suggestions, onAnswer }) {
6
+ const [selected, setSelected] = useState(0);
7
+ const [custom, setCustom] = useState("");
8
+ // The last virtual option is free-text input.
9
+ const optionCount = suggestions.length + 1;
10
+ const isCustomSelected = selected === suggestions.length;
11
+ useInput((input, key) => {
12
+ if (key.upArrow) {
13
+ setSelected((s) => (s - 1 + optionCount) % optionCount);
14
+ return;
15
+ }
16
+ if (key.downArrow || key.tab) {
17
+ setSelected((s) => (s + 1) % optionCount);
18
+ return;
19
+ }
20
+ if (key.return) {
21
+ if (isCustomSelected) {
22
+ if (custom.trim())
23
+ onAnswer(custom.trim());
24
+ }
25
+ else {
26
+ onAnswer(suggestions[selected].text);
27
+ }
28
+ return;
29
+ }
30
+ if (isCustomSelected) {
31
+ if (key.backspace || key.delete) {
32
+ setCustom((c) => c.slice(0, -1));
33
+ }
34
+ else if (input && !key.ctrl && !key.meta) {
35
+ setCustom((c) => c + input);
36
+ }
37
+ }
38
+ else if (/^[1-9]$/.test(input)) {
39
+ const index = Number(input) - 1;
40
+ if (index < suggestions.length)
41
+ onAnswer(suggestions[index].text);
42
+ }
43
+ });
44
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsxs(Text, { bold: true, color: COLORS.primary, children: ["? ", question] }), suggestions.map((suggestion, index) => (_jsxs(Text, { color: selected === index ? COLORS.accent : undefined, children: [selected === index ? "❯ " : " ", index + 1, ". ", suggestion.text] }, index))), _jsxs(Text, { color: isCustomSelected ? COLORS.accent : undefined, children: [isCustomSelected ? "❯ " : " ", "type your own: ", custom, isCustomSelected && _jsx(Text, { inverse: true, children: " " })] }), _jsxs(Text, { dimColor: true, children: ["\u2191/\u2193 select \u00B7 enter confirm \u00B7 1-", Math.min(suggestions.length, 9), " quick pick"] })] }));
45
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as os from "node:os";
3
+ import { Box, Text } from "ink";
4
+ import { COLORS, LOGO, PRODUCT_NAME, TAGLINE, VERSION } from "../../branding.js";
5
+ function shortenPath(cwd) {
6
+ const home = os.homedir();
7
+ return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
8
+ }
9
+ export function Header({ cwd, modelName }) {
10
+ const logoLines = LOGO.split("\n").filter((line) => line.trim().length > 0);
11
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.accent, paddingX: 2, alignSelf: "flex-start", children: [logoLines.map((line, i) => (_jsx(Text, { bold: true, color: COLORS.primary, children: line }, i))), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: COLORS.primary, children: PRODUCT_NAME }), _jsxs(Text, { dimColor: true, children: [" v", VERSION] }), _jsx(Text, { color: COLORS.thinking, children: " \u2726 " }), _jsx(Text, { dimColor: true, italic: true, children: TAGLINE })] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "model " }), _jsx(Text, { color: COLORS.accent, children: modelName })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd " }), _jsx(Text, { children: shortenPath(cwd) })] })] }), _jsxs(Text, { dimColor: true, children: [" ", "/help commands \u00B7 shift+tab approvals \u00B7 ctrl+o thinking \u00B7 esc interrupt \u00B7 ctrl+c quit"] })] }));
12
+ }
@@ -0,0 +1,220 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { COLORS } from "../../branding.js";
6
+ import { walkFiles } from "../../tools/executors/listFiles.js";
7
+ import { appendPromptHistory, loadPromptHistory } from "../../config/promptHistory.js";
8
+ const MAX_FILE_MATCHES = 8;
9
+ /** Higher is better; -1 means no match. Prefers basename prefix > basename > path > subsequence. */
10
+ function fuzzyScore(target, query) {
11
+ if (!query)
12
+ return 1;
13
+ const t = target.toLowerCase();
14
+ const q = query.toLowerCase();
15
+ const base = (target.split("/").pop() ?? target).toLowerCase();
16
+ if (base.startsWith(q))
17
+ return 4000 - target.length;
18
+ if (base.includes(q))
19
+ return 3000 - target.length;
20
+ const idx = t.indexOf(q);
21
+ if (idx !== -1)
22
+ return 2000 - idx - target.length;
23
+ let qi = 0;
24
+ for (let i = 0; i < t.length && qi < q.length; i++) {
25
+ if (t[i] === q[qi])
26
+ qi++;
27
+ }
28
+ if (qi === q.length)
29
+ return 1000 - target.length;
30
+ return -1;
31
+ }
32
+ /** The `@token` being typed at the cursor, if any. */
33
+ function findAtToken(value, cursor) {
34
+ const before = value.slice(0, cursor);
35
+ const match = /(?:^|\s)@([^\s@]*)$/.exec(before);
36
+ if (!match)
37
+ return null;
38
+ return { query: match[1], start: cursor - match[1].length - 1 };
39
+ }
40
+ export function InputBox({ active, slashCommands, onSubmit }) {
41
+ const [value, setValue] = useState("");
42
+ const [cursor, setCursor] = useState(0);
43
+ // Terminal-style prompt history: persisted across sessions in ~/.orbcode.
44
+ const [history, setHistory] = useState(() => loadPromptHistory());
45
+ const [historyIndex, setHistoryIndex] = useState(-1);
46
+ const [fileIndex, setFileIndex] = useState(0);
47
+ const [slashIndex, setSlashIndex] = useState(0);
48
+ const [dismissedValue, setDismissedValue] = useState(null);
49
+ // Workspace file list for @-references, computed once per session.
50
+ const files = useMemo(() => walkFiles(process.cwd(), true, 3000).filter((f) => !f.endsWith("/")), []);
51
+ const showSlashMenu = active && value.startsWith("/") && !value.includes(" ");
52
+ const slashMatches = showSlashMenu
53
+ ? slashCommands.filter((c) => c.name.startsWith(value)).slice(0, 8)
54
+ : [];
55
+ const atToken = active ? findAtToken(value, cursor) : null;
56
+ const fileMatches = useMemo(() => {
57
+ if (!atToken || value === dismissedValue)
58
+ return [];
59
+ return files
60
+ .map((file) => ({ file, score: fuzzyScore(file, atToken.query) }))
61
+ .filter((m) => m.score >= 0)
62
+ .sort((a, b) => b.score - a.score)
63
+ .slice(0, MAX_FILE_MATCHES)
64
+ .map((m) => m.file);
65
+ }, [files, atToken?.query, atToken?.start, value, dismissedValue]);
66
+ useEffect(() => {
67
+ setFileIndex(0);
68
+ }, [atToken?.query]);
69
+ useEffect(() => {
70
+ setSlashIndex(0);
71
+ }, [value]);
72
+ const submit = (text) => {
73
+ const trimmed = text.trim();
74
+ if (!trimmed)
75
+ return;
76
+ setHistory((h) => (h[h.length - 1] === trimmed ? h : [...h, trimmed]));
77
+ appendPromptHistory(trimmed);
78
+ setHistoryIndex(-1);
79
+ setValue("");
80
+ setCursor(0);
81
+ onSubmit(trimmed);
82
+ };
83
+ const recallHistory = (index) => {
84
+ setHistoryIndex(index);
85
+ const entry = index === -1 ? "" : history[index];
86
+ setValue(entry);
87
+ setCursor(entry.length);
88
+ };
89
+ const insertFile = (file) => {
90
+ if (!atToken)
91
+ return;
92
+ const next = `${value.slice(0, atToken.start)}@${file} ${value.slice(cursor)}`;
93
+ setValue(next);
94
+ setCursor(atToken.start + file.length + 2);
95
+ };
96
+ useInput((input, key) => {
97
+ // While actively browsing history, arrows keep navigating history even
98
+ // if a recalled entry opened the slash/file menu.
99
+ if ((key.upArrow || key.downArrow) && historyIndex !== -1) {
100
+ if (key.upArrow) {
101
+ recallHistory(Math.max(0, historyIndex - 1));
102
+ }
103
+ else {
104
+ const next = historyIndex + 1;
105
+ recallHistory(next >= history.length ? -1 : next);
106
+ }
107
+ return;
108
+ }
109
+ const fileMenuOpen = fileMatches.length > 0;
110
+ if (fileMenuOpen) {
111
+ if (key.upArrow) {
112
+ setFileIndex((i) => (i - 1 + fileMatches.length) % fileMatches.length);
113
+ return;
114
+ }
115
+ if (key.downArrow) {
116
+ setFileIndex((i) => (i + 1) % fileMatches.length);
117
+ return;
118
+ }
119
+ if (key.return || (key.tab && !key.shift)) {
120
+ insertFile(fileMatches[Math.min(fileIndex, fileMatches.length - 1)]);
121
+ return;
122
+ }
123
+ if (key.escape) {
124
+ setDismissedValue(value);
125
+ return;
126
+ }
127
+ }
128
+ if (slashMatches.length > 0) {
129
+ const selected = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
130
+ if (key.upArrow) {
131
+ setSlashIndex((i) => (i - 1 + slashMatches.length) % slashMatches.length);
132
+ return;
133
+ }
134
+ if (key.downArrow) {
135
+ setSlashIndex((i) => (i + 1) % slashMatches.length);
136
+ return;
137
+ }
138
+ if (key.return) {
139
+ // A partial command submits the highlighted match (/mod -> /model).
140
+ submit(selected.name);
141
+ return;
142
+ }
143
+ if (key.tab && !key.shift) {
144
+ // Tab completes the highlighted match without submitting.
145
+ setValue(selected.name);
146
+ setCursor(selected.name.length);
147
+ return;
148
+ }
149
+ }
150
+ if (key.return) {
151
+ submit(value);
152
+ return;
153
+ }
154
+ if (key.upArrow) {
155
+ if (history.length === 0)
156
+ return;
157
+ recallHistory(historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1));
158
+ return;
159
+ }
160
+ if (key.downArrow) {
161
+ if (historyIndex === -1)
162
+ return;
163
+ const next = historyIndex + 1;
164
+ recallHistory(next >= history.length ? -1 : next);
165
+ return;
166
+ }
167
+ if (key.leftArrow) {
168
+ setCursor((c) => Math.max(0, c - 1));
169
+ return;
170
+ }
171
+ if (key.rightArrow) {
172
+ setCursor((c) => Math.min(value.length, c + 1));
173
+ return;
174
+ }
175
+ if (key.backspace || key.delete) {
176
+ if (cursor > 0) {
177
+ setHistoryIndex(-1);
178
+ setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
179
+ setCursor((c) => c - 1);
180
+ }
181
+ return;
182
+ }
183
+ if (key.ctrl && input === "a") {
184
+ setCursor(0);
185
+ return;
186
+ }
187
+ if (key.ctrl && input === "e") {
188
+ setCursor(value.length);
189
+ return;
190
+ }
191
+ if (key.ctrl && input === "u") {
192
+ setHistoryIndex(-1);
193
+ setValue("");
194
+ setCursor(0);
195
+ return;
196
+ }
197
+ if (key.ctrl || key.meta || key.escape || key.tab) {
198
+ return;
199
+ }
200
+ if (input) {
201
+ // Multi-char chunks (paste) can carry an embedded trailing newline,
202
+ // which should submit like pressing Enter.
203
+ const endsWithNewline = /[\r\n]$/.test(input);
204
+ const clean = input.replace(/[\r\n]+$/, "").replace(/\r/g, "\n");
205
+ const next = value.slice(0, cursor) + clean + value.slice(cursor);
206
+ if (endsWithNewline) {
207
+ submit(next);
208
+ return;
209
+ }
210
+ setHistoryIndex(-1);
211
+ setValue(next);
212
+ setCursor((c) => c + clean.length);
213
+ }
214
+ }, { isActive: active });
215
+ // The cursor block is baked into one string with chalk: nested <Text>
216
+ // siblings around an inverse space make Ink's layout momentarily wrap the
217
+ // cursor to the next line on short values.
218
+ const display = value.slice(0, cursor) + chalk.inverse(value[cursor] ?? " ") + value.slice(cursor + 1);
219
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: active ? COLORS.primary : "gray", borderLeft: false, borderRight: false, paddingX: 1, children: [_jsx(Text, { color: COLORS.user, bold: true, children: "❯ " }), active ? _jsx(Text, { children: display }) : _jsx(Text, { dimColor: true, children: value || "waiting…" })] }), slashMatches.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [slashMatches.map((c, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === slashIndex ? COLORS.accent : undefined, children: [i === slashIndex ? "❯ " : " ", c.name] }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] }, c.name))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter run \u00B7 tab complete" })] })), fileMatches.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileMatches.map((file, i) => (_jsxs(Text, { color: i === fileIndex ? COLORS.accent : undefined, children: [i === fileIndex ? "❯ " : " ", file] }, file))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter/tab insert \u00B7 esc dismiss" })] }))] }));
220
+ }
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { COLORS } from "../../branding.js";
5
+ import { AXON_MODELS } from "../../api/models.js";
6
+ const VISIBLE_ROWS = 6;
7
+ function formatPrice(model) {
8
+ if (model.free)
9
+ return "free";
10
+ const perMillion = (price) => `$${(price * 1_000_000).toFixed(2)}`;
11
+ return `${perMillion(model.inputPrice)} in / ${perMillion(model.outputPrice)} out per 1M tokens`;
12
+ }
13
+ export function ModelPicker({ currentId, onSelect, onCancel }) {
14
+ const models = Object.values(AXON_MODELS);
15
+ const [selected, setSelected] = useState(() => {
16
+ const index = models.findIndex((m) => m.id === currentId);
17
+ return index === -1 ? 0 : index;
18
+ });
19
+ useInput((input, key) => {
20
+ if (key.upArrow) {
21
+ setSelected((s) => (s - 1 + models.length) % models.length);
22
+ return;
23
+ }
24
+ if (key.downArrow || key.tab) {
25
+ setSelected((s) => (s + 1) % models.length);
26
+ return;
27
+ }
28
+ if (key.return) {
29
+ onSelect(models[selected].id);
30
+ return;
31
+ }
32
+ if (key.escape) {
33
+ onCancel();
34
+ return;
35
+ }
36
+ if (/^[1-9]$/.test(input)) {
37
+ const index = Number(input) - 1;
38
+ if (index < models.length)
39
+ onSelect(models[index].id);
40
+ }
41
+ });
42
+ // Keep the selection inside the visible window when the list is long.
43
+ const windowStart = Math.max(0, Math.min(selected - VISIBLE_ROWS + 1, models.length - VISIBLE_ROWS));
44
+ const visible = models.slice(windowStart, windowStart + VISIBLE_ROWS);
45
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsx(Text, { bold: true, color: COLORS.primary, children: "Select a model" }), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((model, i) => {
46
+ const index = windowStart + i;
47
+ const isSelected = index === selected;
48
+ const isCurrent = model.id === currentId;
49
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: isSelected ? COLORS.accent : undefined, children: [isSelected ? "❯ " : " ", index + 1, ". ", model.name, isCurrent && _jsx(Text, { color: COLORS.success, children: " \u2713 current" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", formatPrice(model)] })] }), isSelected && (_jsx(Box, { paddingLeft: 5, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: model.description }) }))] }, model.id));
50
+ }), windowStart + VISIBLE_ROWS < models.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", models.length - windowStart - VISIBLE_ROWS, " more"] })), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter confirm \u00B7 esc cancel" })] }));
51
+ }
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { COLORS } from "../../branding.js";
5
+ const VISIBLE_ROWS = 8;
6
+ function relativeTime(iso) {
7
+ const ms = Date.now() - new Date(iso).getTime();
8
+ const minutes = Math.round(ms / 60_000);
9
+ if (minutes < 1)
10
+ return "just now";
11
+ if (minutes < 60)
12
+ return `${minutes}m ago`;
13
+ const hours = Math.round(minutes / 60);
14
+ if (hours < 24)
15
+ return `${hours}h ago`;
16
+ const days = Math.round(hours / 24);
17
+ return `${days}d ago`;
18
+ }
19
+ export function SessionPicker({ sessions, onSelect, onCancel }) {
20
+ const [selected, setSelected] = useState(0);
21
+ useInput((input, key) => {
22
+ if (key.upArrow) {
23
+ setSelected((s) => (s - 1 + sessions.length) % sessions.length);
24
+ return;
25
+ }
26
+ if (key.downArrow || key.tab) {
27
+ setSelected((s) => (s + 1) % sessions.length);
28
+ return;
29
+ }
30
+ if (key.return) {
31
+ onSelect(sessions[selected]);
32
+ return;
33
+ }
34
+ if (key.escape) {
35
+ onCancel();
36
+ return;
37
+ }
38
+ if (/^[1-9]$/.test(input)) {
39
+ const index = Number(input) - 1;
40
+ if (index < sessions.length)
41
+ onSelect(sessions[index]);
42
+ }
43
+ });
44
+ const windowStart = Math.max(0, Math.min(selected - VISIBLE_ROWS + 1, sessions.length - VISIBLE_ROWS));
45
+ const visible = sessions.slice(windowStart, windowStart + VISIBLE_ROWS);
46
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsx(Text, { bold: true, color: COLORS.primary, children: "Resume a previous session" }), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((session, i) => {
47
+ const index = windowStart + i;
48
+ const isSelected = index === selected;
49
+ const userTurns = session.messages.filter((m) => m.role === "user").length;
50
+ return (_jsxs(Text, { color: isSelected ? COLORS.accent : undefined, children: [isSelected ? "❯ " : " ", index + 1, ". ", session.title || "(untitled)", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", relativeTime(session.updatedAt), " \u00B7 ", userTurns, " message", userTurns === 1 ? "" : "s"] })] }, session.id));
51
+ }), windowStart + VISIBLE_ROWS < sessions.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", sessions.length - windowStart - VISIBLE_ROWS, " more"] })), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter resume \u00B7 esc cancel" })] }));
52
+ }
@@ -0,0 +1,19 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ import { COLORS } from "../../branding.js";
5
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ export function Spinner({ label }) {
7
+ const [frame, setFrame] = useState(0);
8
+ const [startedAt] = useState(Date.now());
9
+ const [, setTick] = useState(0);
10
+ useEffect(() => {
11
+ const timer = setInterval(() => {
12
+ setFrame((f) => (f + 1) % FRAMES.length);
13
+ setTick((t) => t + 1);
14
+ }, 80);
15
+ return () => clearInterval(timer);
16
+ }, []);
17
+ const seconds = Math.floor((Date.now() - startedAt) / 1000);
18
+ return (_jsxs(Text, { color: COLORS.thinking, children: [FRAMES[frame], " ", label, _jsxs(Text, { dimColor: true, children: [" (", seconds, "s \u00B7 esc to interrupt)"] })] }));
19
+ }
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { COLORS } from "../../branding.js";
4
+ import { getModel } from "../../api/models.js";
5
+ const MODE_LABELS = {
6
+ ask: "⏵ ask before changes",
7
+ edits: "⏵⏵ accept edits on",
8
+ auto: "⏵⏵⏵ auto-approve on",
9
+ };
10
+ function truncate(text, max) {
11
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
12
+ }
13
+ export function StatusBar({ modelId, contextTokens, totalCost, state, approvalMode, busy, title, plan, usagePercentage, }) {
14
+ const model = getModel(modelId);
15
+ const contextPct = Math.min(100, Math.round((contextTokens / model.contextWindow) * 100));
16
+ const remaining = typeof usagePercentage === "number" ? Math.max(0, Math.round(100 - usagePercentage)) : undefined;
17
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: approvalMode === "ask" ? undefined : COLORS.warning, children: MODE_LABELS[approvalMode] }), " (shift+tab to cycle)", busy && " · esc to interrupt", state ? _jsxs(Text, { color: COLORS.thinking, children: [" \u00B7 ", state] }) : null] }), _jsxs(Text, { dimColor: true, children: [title ? `${truncate(title, 32)} · ` : "", model.name, " \u00B7 ctx ", contextTokens.toLocaleString(), " (", contextPct, "%)", model.free ? " · free" : ` · $${totalCost.toFixed(4)}`] })] }), (plan || remaining !== undefined) && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { dimColor: true, children: [plan ? `${plan} plan` : "", plan && remaining !== undefined ? " · " : "", remaining !== undefined ? (_jsxs(Text, { color: remaining <= 10 ? COLORS.error : undefined, children: [remaining, "% usage remaining"] })) : null] }) }))] }));
18
+ }
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { COLORS } from "../../branding.js";
4
+ import { renderMarkdown } from "../markdown.js";
5
+ import { Header } from "./Header.js";
6
+ const TOOL_DISPLAY_NAMES = {
7
+ update_todo_list: "Update Tasks",
8
+ lsp: "LSP",
9
+ };
10
+ /** "read_file" -> "Read File", with overrides for names that don't title-case cleanly. */
11
+ export function formatToolName(name) {
12
+ return (TOOL_DISPLAY_NAMES[name] ??
13
+ name
14
+ .split("_")
15
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
16
+ .join(" "));
17
+ }
18
+ const MAX_DIFF_LINES = 60;
19
+ const ADDED_BG = "#1C4428";
20
+ const REMOVED_BG = "#5C1A1A";
21
+ /** Parse a unified diff (optionally with bare file-path header lines) into rows. */
22
+ function parseDiff(diff) {
23
+ const rows = [];
24
+ let added = 0;
25
+ let removed = 0;
26
+ let oldLine = 0;
27
+ let newLine = 0;
28
+ let seenHunk = false;
29
+ for (const line of diff.split("\n")) {
30
+ const hunk = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/.exec(line);
31
+ if (hunk) {
32
+ if (seenHunk)
33
+ rows.push({ kind: "gap" });
34
+ oldLine = Number(hunk[1]);
35
+ newLine = Number(hunk[2]);
36
+ seenHunk = true;
37
+ }
38
+ else if (line.startsWith("+")) {
39
+ rows.push({ kind: "line", type: "add", num: newLine++, text: line.slice(1) });
40
+ added++;
41
+ }
42
+ else if (line.startsWith("-")) {
43
+ rows.push({ kind: "line", type: "del", num: oldLine++, text: line.slice(1) });
44
+ removed++;
45
+ }
46
+ else if (line.startsWith(" ")) {
47
+ rows.push({ kind: "line", type: "ctx", num: newLine++, text: line.slice(1) });
48
+ oldLine++;
49
+ }
50
+ else if (line.trim()) {
51
+ // bare line = file path header (multi-file diffs)
52
+ rows.push({ kind: "file", text: line });
53
+ seenHunk = false;
54
+ }
55
+ }
56
+ return { rows, added, removed };
57
+ }
58
+ function plural(count, word) {
59
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
60
+ }
61
+ /** Claude Code-style diff: stats header, line-number gutter, red/green line backgrounds. */
62
+ export function DiffView({ diff }) {
63
+ const { rows, added, removed } = parseDiff(diff);
64
+ const visible = rows.slice(0, MAX_DIFF_LINES);
65
+ const numWidth = Math.max(3, ...visible.map((r) => (r.kind === "line" ? String(r.num).length : 0)));
66
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2514 " }), "Added ", plural(added, "line"), ", removed ", plural(removed, "line")] }), visible.map((row, i) => {
67
+ if (row.kind === "file") {
68
+ return (_jsx(Text, { bold: true, dimColor: true, children: row.text }, i));
69
+ }
70
+ if (row.kind === "gap") {
71
+ return (_jsxs(Text, { dimColor: true, children: [" ".repeat(numWidth), " \u22EF"] }, i));
72
+ }
73
+ const num = String(row.num).padStart(numWidth);
74
+ if (row.type === "add") {
75
+ return (_jsxs(Text, { backgroundColor: ADDED_BG, children: [num, " + ", row.text] }, i));
76
+ }
77
+ if (row.type === "del") {
78
+ return (_jsxs(Text, { backgroundColor: REMOVED_BG, children: [num, " - ", row.text] }, i));
79
+ }
80
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: num }), " ", row.text] }, i));
81
+ }), rows.length > MAX_DIFF_LINES && _jsxs(Text, { dimColor: true, children: ["\u2026 ", rows.length - MAX_DIFF_LINES, " more lines"] })] }));
82
+ }
83
+ export function formatDuration(durationMs) {
84
+ const seconds = durationMs / 1000;
85
+ return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
86
+ }
87
+ export function RowView({ row }) {
88
+ switch (row.kind) {
89
+ case "header":
90
+ return _jsx(Header, { cwd: row.cwd, modelName: row.modelName });
91
+ case "user":
92
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: COLORS.user, bold: true, children: "❯ " }), _jsx(Text, { color: COLORS.user, children: row.text })] }));
93
+ case "assistant":
94
+ return (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), renderMarkdown(row.text.trimEnd())] }) }));
95
+ case "reasoning":
96
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: COLORS.thinking, italic: true, children: ["\u2726 Thought for ", formatDuration(row.durationMs), !row.expanded && _jsx(Text, { dimColor: true, children: " (ctrl+o to show thinking)" })] }), row.expanded && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: row.text.trim() }) }))] }));
97
+ case "tool":
98
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: row.isError ? COLORS.error : COLORS.success, children: [row.isError ? "✗" : "✓", " "] }), _jsx(Text, { bold: true, children: formatToolName(row.name) }), _jsxs(Text, { dimColor: true, children: [" ", row.summary] })] }), row.diff ? (_jsx(Box, { paddingLeft: 2, children: _jsx(DiffView, { diff: row.diff }) })) : (row.resultPreview && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: row.resultPreview }) })))] }));
99
+ case "info":
100
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: row.text }) }));
101
+ case "error":
102
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.error, children: ["\u2717 ", row.text] }) }));
103
+ case "completion":
104
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.success, paddingX: 1, children: [_jsx(Text, { color: COLORS.success, bold: true, children: "\u2714 Task completed" }), _jsx(Text, { children: renderMarkdown(row.text.trimEnd()) })] }));
105
+ }
106
+ }
@@ -0,0 +1,64 @@
1
+ import chalk from "chalk";
2
+ import { COLORS } from "../branding.js";
3
+ /** Render inline markdown (bold, italic, code, links) to ANSI. */
4
+ function renderInline(text) {
5
+ let out = text;
6
+ // inline code first so other patterns don't fire inside it
7
+ out = out.replace(/`([^`]+)`/g, (_, code) => chalk.hex(COLORS.accent)(code));
8
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, t) => chalk.bold(t));
9
+ out = out.replace(/__([^_]+)__/g, (_, t) => chalk.bold(t));
10
+ out = out.replace(/(?<![\w*])\*([^*\n]+)\*(?![\w*])/g, (_, t) => chalk.italic(t));
11
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => `${label} ${chalk.dim.underline(url)}`);
12
+ return out;
13
+ }
14
+ /** Lightweight markdown → ANSI renderer for terminal chat output. */
15
+ export function renderMarkdown(markdown) {
16
+ const lines = markdown.split("\n");
17
+ const out = [];
18
+ let inCodeBlock = false;
19
+ for (const line of lines) {
20
+ const fence = line.match(/^\s*```(.*)$/);
21
+ if (fence) {
22
+ if (!inCodeBlock) {
23
+ inCodeBlock = true;
24
+ const lang = fence[1].trim();
25
+ out.push(chalk.dim(lang ? `╭─ ${lang}` : "╭─"));
26
+ }
27
+ else {
28
+ inCodeBlock = false;
29
+ out.push(chalk.dim("╰─"));
30
+ }
31
+ continue;
32
+ }
33
+ if (inCodeBlock) {
34
+ out.push(chalk.dim("│ ") + chalk.hex("#CE9178")(line));
35
+ continue;
36
+ }
37
+ const header = line.match(/^(#{1,6})\s+(.*)$/);
38
+ if (header) {
39
+ out.push(chalk.bold.underline(renderInline(header[2])));
40
+ continue;
41
+ }
42
+ const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
43
+ if (bullet) {
44
+ out.push(`${bullet[1]}${chalk.hex(COLORS.primary)("•")} ${renderInline(bullet[2])}`);
45
+ continue;
46
+ }
47
+ const ordered = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
48
+ if (ordered) {
49
+ out.push(`${ordered[1]}${chalk.hex(COLORS.primary)(ordered[2] + ".")} ${renderInline(ordered[3])}`);
50
+ continue;
51
+ }
52
+ const quote = line.match(/^>\s?(.*)$/);
53
+ if (quote) {
54
+ out.push(chalk.dim(`│ ${renderInline(quote[1])}`));
55
+ continue;
56
+ }
57
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) {
58
+ out.push(chalk.dim("─".repeat(40)));
59
+ continue;
60
+ }
61
+ out.push(renderInline(line));
62
+ }
63
+ return out.join("\n");
64
+ }