sakuracode 0.0.0-stage → 0.1.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 (117) hide show
  1. package/README.md +256 -2
  2. package/README.zh-CN.md +257 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/THIRD_PARTY_NOTICES.md +13 -0
  5. package/dist/code/bin/sakuracode.mjs +5 -0
  6. package/dist/code/src/agent-session.js +41 -0
  7. package/dist/code/src/ansi.js +220 -0
  8. package/dist/code/src/cli.js +211 -0
  9. package/dist/code/src/config.js +183 -0
  10. package/dist/code/src/exec-mode.js +84 -0
  11. package/dist/code/src/framework-testing.js +2 -0
  12. package/dist/code/src/framework.js +3 -0
  13. package/dist/code/src/idle-compaction.js +38 -0
  14. package/dist/code/src/images.js +18 -0
  15. package/dist/code/src/invocation.js +39 -0
  16. package/dist/code/src/markdown.js +383 -0
  17. package/dist/code/src/mentions.js +22 -0
  18. package/dist/code/src/permissions.js +31 -0
  19. package/dist/code/src/providers.js +56 -0
  20. package/dist/code/src/reports.js +81 -0
  21. package/dist/code/src/run-driver.js +76 -0
  22. package/dist/code/src/session-controller.js +405 -0
  23. package/dist/code/src/session-store.js +92 -0
  24. package/dist/code/src/shell.js +36 -0
  25. package/dist/code/src/slash-commands.js +83 -0
  26. package/dist/code/src/status-line.js +26 -0
  27. package/dist/code/src/toml.js +256 -0
  28. package/dist/code/src/transcript.js +288 -0
  29. package/dist/code/src/tui/app.js +267 -0
  30. package/dist/code/src/tui/keys.js +151 -0
  31. package/dist/code/src/tui/line-editor.js +324 -0
  32. package/dist/code/src/tui/live-region.js +71 -0
  33. package/dist/code/src/tui/screen.js +69 -0
  34. package/dist/code/src/tui/transcript-view.js +102 -0
  35. package/dist/licenses/codex-Apache-2.0.txt +201 -0
  36. package/dist/licenses/gitleaks-MIT.txt +21 -0
  37. package/dist/licenses/pi-mono-MIT.txt +21 -0
  38. package/dist/packages/agent/src/compose.js +91 -0
  39. package/dist/packages/agent/src/index.js +161 -0
  40. package/dist/packages/agent/src/restrict-tools.js +11 -0
  41. package/dist/packages/agent/src/system-prompt.js +49 -0
  42. package/dist/packages/agent/src/wake.js +71 -0
  43. package/dist/packages/env/src/confine.js +40 -0
  44. package/dist/packages/env/src/connect-ssh.js +242 -0
  45. package/dist/packages/env/src/index.js +4 -0
  46. package/dist/packages/env/src/local.js +206 -0
  47. package/dist/packages/env/src/remote-stream.js +143 -0
  48. package/dist/packages/env/src/remote-types.js +10 -0
  49. package/dist/packages/env/src/remote.js +183 -0
  50. package/dist/packages/evals/src/bfcl.js +71 -0
  51. package/dist/packages/evals/src/index.js +2 -0
  52. package/dist/packages/evals/src/png.js +30 -0
  53. package/dist/packages/evals/src/pool.js +22 -0
  54. package/dist/packages/evals/src/providers.js +23 -0
  55. package/dist/packages/kernel/src/channel.js +34 -0
  56. package/dist/packages/kernel/src/compaction.js +86 -0
  57. package/dist/packages/kernel/src/constants.js +5 -0
  58. package/dist/packages/kernel/src/goal.js +65 -0
  59. package/dist/packages/kernel/src/hooks.js +153 -0
  60. package/dist/packages/kernel/src/index.js +4 -0
  61. package/dist/packages/kernel/src/loop.js +556 -0
  62. package/dist/packages/kernel/src/prompts.js +99 -0
  63. package/dist/packages/kernel/src/system.js +49 -0
  64. package/dist/packages/kernel/src/types.js +23 -0
  65. package/dist/packages/kernel/src/usage.js +16 -0
  66. package/dist/packages/llm/src/chat/compat.js +16 -0
  67. package/dist/packages/llm/src/chat/errors.js +61 -0
  68. package/dist/packages/llm/src/chat/request.js +109 -0
  69. package/dist/packages/llm/src/chat/retry.js +57 -0
  70. package/dist/packages/llm/src/chat/stream.js +122 -0
  71. package/dist/packages/llm/src/index.js +120 -0
  72. package/dist/packages/plugins/agents/src/index.js +206 -0
  73. package/dist/packages/plugins/agents/src/view.js +60 -0
  74. package/dist/packages/plugins/context/src/index.js +67 -0
  75. package/dist/packages/plugins/limits/src/cost.js +3 -0
  76. package/dist/packages/plugins/limits/src/index.js +31 -0
  77. package/dist/packages/plugins/redaction/src/engine.js +110 -0
  78. package/dist/packages/plugins/redaction/src/index.js +69 -0
  79. package/dist/packages/plugins/redaction/src/restore.js +44 -0
  80. package/dist/packages/plugins/redaction/src/rules.gen.js +2059 -0
  81. package/dist/packages/plugins/resilience/src/index.js +86 -0
  82. package/dist/packages/plugins/review/src/chain.js +27 -0
  83. package/dist/packages/plugins/review/src/hard-rules.js +156 -0
  84. package/dist/packages/plugins/review/src/index.js +93 -0
  85. package/dist/packages/plugins/review/src/policy.js +65 -0
  86. package/dist/packages/plugins/review/src/reviewer.js +207 -0
  87. package/dist/packages/plugins/review/src/shell.js +124 -0
  88. package/dist/packages/plugins/tools/src/artifacts.js +61 -0
  89. package/dist/packages/plugins/tools/src/builtin/args.js +46 -0
  90. package/dist/packages/plugins/tools/src/builtin/bash.js +110 -0
  91. package/dist/packages/plugins/tools/src/builtin/edit.js +71 -0
  92. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  93. package/dist/packages/plugins/tools/src/builtin/read.js +89 -0
  94. package/dist/packages/plugins/tools/src/builtin/write.js +35 -0
  95. package/dist/packages/plugins/tools/src/edit-match.js +159 -0
  96. package/dist/packages/plugins/tools/src/fold.js +65 -0
  97. package/dist/packages/plugins/tools/src/index.js +10 -0
  98. package/dist/packages/plugins/toolset/src/bm25.js +35 -0
  99. package/dist/packages/plugins/toolset/src/codemode.js +226 -0
  100. package/dist/packages/plugins/toolset/src/index.js +3 -0
  101. package/dist/packages/plugins/toolset/src/mcp.js +173 -0
  102. package/dist/packages/plugins/toolset/src/search.js +57 -0
  103. package/dist/packages/plugins/trace/src/index.js +29 -0
  104. package/dist/packages/plugins/trace/src/modules.js +28 -0
  105. package/dist/packages/session/src/file-log.js +144 -0
  106. package/dist/packages/session/src/fork.js +70 -0
  107. package/dist/packages/session/src/index.js +3 -0
  108. package/dist/packages/session/src/log-core.js +47 -0
  109. package/dist/packages/session/src/memory-log.js +7 -0
  110. package/dist/packages/session/src/projection.js +107 -0
  111. package/dist/packages/session/src/tree.js +35 -0
  112. package/dist/packages/testing/src/faux.js +69 -0
  113. package/dist/packages/testing/src/index.js +2 -0
  114. package/dist/packages/testing/src/rng.js +17 -0
  115. package/dist/sdk/index.js +10 -0
  116. package/dist/sdk/testing.js +2 -0
  117. package/package.json +54 -4
@@ -0,0 +1,151 @@
1
+ // Decoding raw terminal input into key events. Understands arrows, Home/End/Delete, Ctrl and Alt combinations,
2
+ // Shift-Enter in the common encodings, and bracketed paste (which may arrive in several chunks).
3
+ const PASTE_START = "\u001b[200~";
4
+ const PASTE_END = "\u001b[201~";
5
+ const CSI_FINAL = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end", Z: "tab" };
6
+ const CSI_TILDE = { "1": "home", "2": "insert", "3": "delete", "4": "end", "5": "pageup", "6": "pagedown", "7": "home", "8": "end" };
7
+ export function createKeyDecoder() {
8
+ let pasteBuffer;
9
+ let carry = "";
10
+ function decode(input) {
11
+ const events = [];
12
+ let text = "";
13
+ const flushText = () => {
14
+ if (text)
15
+ events.push({ name: "char", text });
16
+ text = "";
17
+ };
18
+ let index = 0;
19
+ while (index < input.length) {
20
+ const char = input[index];
21
+ if (char === "\u001b") {
22
+ flushText();
23
+ const parsed = parseEscape(input, index);
24
+ if (parsed.incomplete) {
25
+ carry = input.slice(index);
26
+ return events;
27
+ }
28
+ events.push(...parsed.events);
29
+ index += parsed.length;
30
+ continue;
31
+ }
32
+ const code = char.charCodeAt(0);
33
+ if (char === "\r") {
34
+ flushText();
35
+ events.push({ name: "enter" });
36
+ }
37
+ else if (char === "\n") {
38
+ flushText();
39
+ events.push({ name: "enter", ctrl: true });
40
+ }
41
+ else if (char === "\t") {
42
+ flushText();
43
+ events.push({ name: "tab" });
44
+ }
45
+ else if (code === 0x7f || code === 0x08) {
46
+ flushText();
47
+ events.push({ name: "backspace" });
48
+ }
49
+ else if (code >= 1 && code <= 26) {
50
+ flushText();
51
+ events.push({ name: String.fromCharCode(code + 96), ctrl: true });
52
+ }
53
+ else if (code < 32) {
54
+ flushText();
55
+ }
56
+ else
57
+ text += char;
58
+ index += 1;
59
+ }
60
+ flushText();
61
+ return events;
62
+ }
63
+ return {
64
+ push(chunk) {
65
+ let input = carry + chunk;
66
+ carry = "";
67
+ const events = [];
68
+ for (;;) {
69
+ if (pasteBuffer !== undefined) {
70
+ const end = input.indexOf(PASTE_END);
71
+ if (end === -1) {
72
+ pasteBuffer += input;
73
+ return events;
74
+ }
75
+ events.push({ name: "paste", text: (pasteBuffer + input.slice(0, end)).replace(/\r\n?/g, "\n") });
76
+ pasteBuffer = undefined;
77
+ input = input.slice(end + PASTE_END.length);
78
+ continue;
79
+ }
80
+ const start = input.indexOf(PASTE_START);
81
+ if (start === -1) {
82
+ events.push(...decode(input));
83
+ return events;
84
+ }
85
+ events.push(...decode(input.slice(0, start)));
86
+ pasteBuffer = "";
87
+ input = input.slice(start + PASTE_START.length);
88
+ }
89
+ },
90
+ };
91
+ }
92
+ function modifiers(code) {
93
+ const bits = code - 1;
94
+ return { ...(bits & 1 ? { shift: true } : {}), ...(bits & 2 ? { alt: true } : {}), ...(bits & 4 ? { ctrl: true } : {}) };
95
+ }
96
+ function parseEscape(input, index) {
97
+ const next = input[index + 1];
98
+ if (next === undefined)
99
+ return { events: [{ name: "escape" }], length: 1 };
100
+ if (next === "[") {
101
+ const match = /^\u001b\[([0-9;:?]*)([@-~])/.exec(input.slice(index));
102
+ if (!match)
103
+ return { events: [], length: 0, incomplete: true };
104
+ const [whole, params = "", final = ""] = match;
105
+ const numbers = params.split(";").map((part) => Number.parseInt(part, 10));
106
+ const mod = numbers[1] ? modifiers(numbers[1]) : {};
107
+ if (final === "~") {
108
+ if (numbers[0] === 27 && numbers[2] === 13)
109
+ return { events: [{ name: "enter", ...modifiers(numbers[1] ?? 1) }], length: whole.length };
110
+ const name = CSI_TILDE[String(numbers[0])];
111
+ return { events: name ? [{ name, ...mod }] : [], length: whole.length };
112
+ }
113
+ if (final === "u") {
114
+ const code = numbers[0] ?? 0;
115
+ const m = modifiers(numbers[1] ?? 1);
116
+ if (code === 13)
117
+ return { events: [{ name: "enter", ...m }], length: whole.length };
118
+ if (code === 27)
119
+ return { events: [{ name: "escape" }], length: whole.length };
120
+ if (code === 9)
121
+ return { events: [{ name: "tab", ...m }], length: whole.length };
122
+ if (code === 127)
123
+ return { events: [{ name: "backspace", ...m }], length: whole.length };
124
+ if (code >= 32 && !m.ctrl && !m.alt)
125
+ return { events: [{ name: "char", text: String.fromCodePoint(code) }], length: whole.length };
126
+ if (code >= 97 && code <= 122)
127
+ return { events: [{ name: String.fromCharCode(code), ...m }], length: whole.length };
128
+ return { events: [], length: whole.length };
129
+ }
130
+ const name = CSI_FINAL[final];
131
+ if (name === "tab")
132
+ return { events: [{ name: "tab", shift: true }], length: whole.length };
133
+ return { events: name ? [{ name, ...mod }] : [], length: whole.length };
134
+ }
135
+ if (next === "O") {
136
+ const final = input[index + 2];
137
+ if (final === undefined)
138
+ return { events: [], length: 0, incomplete: true };
139
+ const name = CSI_FINAL[final];
140
+ return { events: name ? [{ name }] : [], length: 3 };
141
+ }
142
+ if (next === "\r")
143
+ return { events: [{ name: "enter", alt: true }], length: 2 };
144
+ if (next === "\u007f")
145
+ return { events: [{ name: "backspace", alt: true }], length: 2 };
146
+ const code = next.codePointAt(0);
147
+ const length = 1 + String.fromCodePoint(code).length;
148
+ if (code >= 1 && code <= 26)
149
+ return { events: [{ name: String.fromCharCode(code + 96), ctrl: true, alt: true }], length };
150
+ return { events: [{ name: "char", text: String.fromCodePoint(code), alt: true }], length };
151
+ }
@@ -0,0 +1,324 @@
1
+ // The text of the prompt: a buffer with a cursor, multi-line editing, history and the usual readline keys. Pure state; no output.
2
+ import { displayWidth } from "../ansi.js";
3
+ /** A paste longer than this many characters, or with more lines, is shown as a placeholder (the limit Codex uses, plus a line limit). */
4
+ export const PASTE_FOLD_CHARS = 1000;
5
+ export const PASTE_FOLD_LINES = 5;
6
+ /** Placeholder of the n-th paste of a size, in Codex's wording: "[Pasted Content 1234 chars]", then "… chars #2". */
7
+ function placeholderFor(chars, taken) {
8
+ const base = `[Pasted Content ${chars} chars`;
9
+ let label = `${base}]`;
10
+ for (let copy = 2; taken.has(label); copy += 1)
11
+ label = `${base} #${copy}]`;
12
+ return label;
13
+ }
14
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
15
+ function graphemes(text) {
16
+ return Array.from(segmenter.segment(text), (entry) => entry.segment);
17
+ }
18
+ export function createLineEditor() {
19
+ let text = "";
20
+ let cursor = 0; // index into `text` in UTF-16 units, always on a grapheme boundary
21
+ let historyIndex;
22
+ let draft = "";
23
+ let killed = "";
24
+ /** placeholder -> the pasted text it stands for; every key occurs in `text` */
25
+ let pastes = new Map();
26
+ let draftPastes = new Map();
27
+ const history = [];
28
+ /** Where a placeholder starts that contains position `at` strictly inside, else `at`. */
29
+ const snapToStart = (at) => {
30
+ for (const key of pastes.keys())
31
+ for (let from = text.indexOf(key); from !== -1; from = text.indexOf(key, from + 1))
32
+ if (at > from && at < from + key.length)
33
+ return from;
34
+ return at;
35
+ };
36
+ const snapToEnd = (at) => {
37
+ for (const key of pastes.keys())
38
+ for (let from = text.indexOf(key); from !== -1; from = text.indexOf(key, from + 1))
39
+ if (at > from && at < from + key.length)
40
+ return from + key.length;
41
+ return at;
42
+ };
43
+ const prune = () => {
44
+ for (const key of pastes.keys())
45
+ if (!text.includes(key))
46
+ pastes.delete(key);
47
+ };
48
+ /** Text that is too big for the composer, as a placeholder that remembers it. */
49
+ const fold = (value) => {
50
+ if (value.length <= PASTE_FOLD_CHARS && value.split("\n").length <= PASTE_FOLD_LINES)
51
+ return value;
52
+ const label = placeholderFor(value.length, pastes);
53
+ pastes.set(label, value);
54
+ return label;
55
+ };
56
+ const lineStart = (at) => text.lastIndexOf("\n", at - 1) + 1;
57
+ const lineEnd = (at) => {
58
+ const found = text.indexOf("\n", at);
59
+ return found === -1 ? text.length : found;
60
+ };
61
+ const previousBoundary = (at) => {
62
+ const before = graphemes(text.slice(0, at));
63
+ return snapToStart(at - (before[before.length - 1]?.length ?? 0));
64
+ };
65
+ const nextBoundary = (at) => snapToEnd(at + (graphemes(text.slice(at))[0]?.length ?? 0));
66
+ const isWordChar = (char) => /[\p{L}\p{N}_]/u.test(char);
67
+ function wordLeft(at) {
68
+ let position = at;
69
+ while (position > 0 && !isWordChar(text[position - 1]))
70
+ position -= 1;
71
+ while (position > 0 && isWordChar(text[position - 1]))
72
+ position -= 1;
73
+ return snapToStart(position);
74
+ }
75
+ function wordRight(at) {
76
+ let position = at;
77
+ while (position < text.length && !isWordChar(text[position]))
78
+ position += 1;
79
+ while (position < text.length && isWordChar(text[position]))
80
+ position += 1;
81
+ return snapToEnd(position);
82
+ }
83
+ const insert = (value) => {
84
+ text = text.slice(0, cursor) + value + text.slice(cursor);
85
+ cursor += value.length;
86
+ historyIndex = undefined;
87
+ };
88
+ const remove = (from, to) => {
89
+ killed = text.slice(from, to);
90
+ text = text.slice(0, from) + text.slice(to);
91
+ cursor = from;
92
+ prune();
93
+ historyIndex = undefined;
94
+ };
95
+ function column(at) {
96
+ return displayWidth(text.slice(lineStart(at), at));
97
+ }
98
+ function indexAtColumn(start, end, wanted) {
99
+ let position = start;
100
+ let used = 0;
101
+ for (const part of graphemes(text.slice(start, end))) {
102
+ const width = displayWidth(part);
103
+ if (used + width > wanted)
104
+ break;
105
+ used += width;
106
+ position += part.length;
107
+ }
108
+ return position;
109
+ }
110
+ function moveUp() {
111
+ const start = lineStart(cursor);
112
+ if (start === 0)
113
+ return false;
114
+ const previousStart = lineStart(start - 1);
115
+ cursor = snapToStart(indexAtColumn(previousStart, start - 1, column(cursor)));
116
+ return true;
117
+ }
118
+ function moveDown() {
119
+ const end = lineEnd(cursor);
120
+ if (end >= text.length)
121
+ return false;
122
+ const nextStart = end + 1;
123
+ cursor = snapToStart(indexAtColumn(nextStart, lineEnd(nextStart), column(cursor)));
124
+ return true;
125
+ }
126
+ function showHistory(index) {
127
+ historyIndex = index;
128
+ const entry = index === undefined ? { text: draft, pastes: draftPastes } : history[index];
129
+ text = entry.text;
130
+ pastes = new Map(entry.pastes);
131
+ cursor = text.length;
132
+ }
133
+ return {
134
+ value: () => text,
135
+ expanded() {
136
+ let result = text;
137
+ for (const [label, full] of pastes)
138
+ result = result.split(label).join(full);
139
+ return result;
140
+ },
141
+ cursor: () => cursor,
142
+ set(value) {
143
+ pastes = new Map();
144
+ text = fold(value);
145
+ cursor = text.length;
146
+ historyIndex = undefined;
147
+ },
148
+ clear() {
149
+ text = "";
150
+ cursor = 0;
151
+ pastes = new Map();
152
+ historyIndex = undefined;
153
+ },
154
+ remember(value) {
155
+ if (value.trim() !== "" && history[history.length - 1]?.text !== value)
156
+ history.push({ text: value, pastes: new Map([...pastes].filter(([label]) => value.includes(label))) });
157
+ historyIndex = undefined;
158
+ },
159
+ handle(key) {
160
+ const withoutCtrl = !key.ctrl && !key.alt;
161
+ switch (key.name) {
162
+ case "char":
163
+ insert(key.text.replace(/\r\n?/g, "\n"));
164
+ return "none";
165
+ case "paste":
166
+ insert(fold((key.text ?? "").replace(/\r\n?/g, "\n")));
167
+ return "none";
168
+ case "enter":
169
+ if (key.shift || key.alt || key.ctrl) {
170
+ insert("\n");
171
+ return "none";
172
+ }
173
+ if (text[cursor - 1] === "\\") {
174
+ // a backslash before Enter means "new line" in terminals that cannot tell Shift-Enter apart
175
+ text = text.slice(0, cursor - 1) + "\n" + text.slice(cursor);
176
+ return "none";
177
+ }
178
+ return "submit";
179
+ case "tab":
180
+ return key.shift ? "shift-tab" : "tab";
181
+ case "escape":
182
+ return "escape";
183
+ case "backspace":
184
+ if (key.alt)
185
+ remove(wordLeft(cursor), cursor);
186
+ else if (cursor > 0)
187
+ remove(previousBoundary(cursor), cursor);
188
+ return "none";
189
+ case "delete":
190
+ if (cursor < text.length)
191
+ remove(cursor, nextBoundary(cursor));
192
+ return "none";
193
+ case "left":
194
+ cursor = key.ctrl || key.alt ? wordLeft(cursor) : previousBoundary(cursor);
195
+ return "none";
196
+ case "right":
197
+ cursor = key.ctrl || key.alt ? wordRight(cursor) : nextBoundary(cursor);
198
+ return "none";
199
+ case "home":
200
+ cursor = lineStart(cursor);
201
+ return "none";
202
+ case "end":
203
+ cursor = lineEnd(cursor);
204
+ return "none";
205
+ case "up":
206
+ if (moveUp())
207
+ return "none";
208
+ if (history.length > 0 && withoutCtrl) {
209
+ if (historyIndex === undefined) {
210
+ draft = text;
211
+ draftPastes = pastes;
212
+ showHistory(history.length - 1);
213
+ }
214
+ else if (historyIndex > 0)
215
+ showHistory(historyIndex - 1);
216
+ cursor = 0;
217
+ }
218
+ return "none";
219
+ case "down":
220
+ if (moveDown())
221
+ return "none";
222
+ if (historyIndex !== undefined && withoutCtrl)
223
+ showHistory(historyIndex + 1 < history.length ? historyIndex + 1 : undefined);
224
+ return "none";
225
+ case "pageup":
226
+ return "page-up";
227
+ case "pagedown":
228
+ return "page-down";
229
+ case "a":
230
+ if (key.ctrl)
231
+ cursor = lineStart(cursor);
232
+ else if (key.alt)
233
+ insert("a");
234
+ return "none";
235
+ case "e":
236
+ if (key.ctrl)
237
+ cursor = lineEnd(cursor);
238
+ return "none";
239
+ case "b":
240
+ if (key.ctrl)
241
+ cursor = previousBoundary(cursor);
242
+ return "none";
243
+ case "f":
244
+ if (key.ctrl)
245
+ cursor = nextBoundary(cursor);
246
+ return "none";
247
+ case "k":
248
+ if (key.ctrl)
249
+ remove(cursor, cursor === lineEnd(cursor) ? Math.min(text.length, cursor + 1) : lineEnd(cursor));
250
+ return "none";
251
+ case "u":
252
+ if (key.ctrl)
253
+ remove(lineStart(cursor), cursor);
254
+ return "none";
255
+ case "w":
256
+ if (key.ctrl)
257
+ remove(wordLeft(cursor), cursor);
258
+ return "none";
259
+ case "y":
260
+ if (key.ctrl && killed)
261
+ insert(killed);
262
+ return "none";
263
+ case "d":
264
+ if (key.ctrl) {
265
+ if (text === "")
266
+ return "eof";
267
+ if (cursor < text.length)
268
+ remove(cursor, nextBoundary(cursor));
269
+ }
270
+ return "none";
271
+ case "c":
272
+ return key.ctrl ? "interrupt" : "none";
273
+ case "j":
274
+ if (key.ctrl)
275
+ insert("\n");
276
+ return "none";
277
+ case "l":
278
+ return "none";
279
+ default:
280
+ return "none";
281
+ }
282
+ },
283
+ layout(width) {
284
+ return layoutText(text, cursor, width);
285
+ },
286
+ };
287
+ }
288
+ /** Cuts text into display rows (hard wrap by columns) and locates the cursor. */
289
+ export function layoutText(text, cursor, width) {
290
+ const limit = Math.max(2, width);
291
+ const rows = [];
292
+ let cursorRow = 0;
293
+ let cursorCol = 0;
294
+ let offset = 0;
295
+ for (const line of text.split("\n")) {
296
+ let row = "";
297
+ let used = 0;
298
+ let rowStartOffset = offset;
299
+ const place = (at) => {
300
+ if (cursor === at) {
301
+ cursorRow = rows.length;
302
+ cursorCol = used;
303
+ }
304
+ };
305
+ for (const part of graphemes(line)) {
306
+ const partWidth = displayWidth(part);
307
+ if (used + partWidth > limit) {
308
+ rows.push(row);
309
+ row = "";
310
+ used = 0;
311
+ rowStartOffset = offset;
312
+ }
313
+ place(offset);
314
+ row += part;
315
+ used += partWidth;
316
+ offset += part.length;
317
+ }
318
+ place(offset); // the position after the last character of this line
319
+ void rowStartOffset;
320
+ rows.push(row);
321
+ offset += 1; // the newline
322
+ }
323
+ return { rows, cursorRow, cursorCol };
324
+ }
@@ -0,0 +1,71 @@
1
+ // The bottom part of the screen that is redrawn in place (working indicator, prompt, status line), while finished
2
+ // output is printed above it and stays in the terminal's own scrollback. No alternate screen, so the user can scroll
3
+ // back and select text as in any shell.
4
+ const SYNC_BEGIN = "\u001b[?2026h";
5
+ const SYNC_END = "\u001b[?2026l";
6
+ export function createLiveRegion(out, options = {}) {
7
+ let live = [];
8
+ let cursor = { row: 0, col: 0 };
9
+ let drawn = false;
10
+ /** Blank rows between the last printed line and the live area. */
11
+ let gap = 0;
12
+ /** Rows of printed output since the screen was empty (capped at the screen height). */
13
+ let contentRows = 0;
14
+ const screenRows = () => out.rows ?? 0;
15
+ const wantedGap = () => (options.pinned && screenRows() > 0 ? Math.max(0, screenRows() - contentRows - live.length) : 0);
16
+ /** Moves from the text cursor back to the first column of the end of the printed output and erases down. */
17
+ const eraseCommand = () => {
18
+ if (!drawn)
19
+ return "";
20
+ const up = cursor.row + gap;
21
+ return `${up > 0 ? `\u001b[${up}A` : ""}\r\u001b[J`;
22
+ };
23
+ function paintCommand() {
24
+ if (live.length === 0) {
25
+ gap = 0;
26
+ return "";
27
+ }
28
+ gap = wantedGap();
29
+ const lastRow = live.length - 1;
30
+ const up = lastRow - cursor.row;
31
+ return `${"\n".repeat(gap)}${live.join("\n")}${up > 0 ? `\u001b[${up}A` : ""}\r${cursor.col > 0 ? `\u001b[${cursor.col}C` : ""}`;
32
+ }
33
+ const emit = (text) => out.write(`${SYNC_BEGIN}${text}${SYNC_END}`);
34
+ return {
35
+ print(lines) {
36
+ if (lines.length === 0)
37
+ return;
38
+ const erase = eraseCommand();
39
+ contentRows = Math.min(contentRows + lines.length, Math.max(screenRows(), 1));
40
+ emit(`${erase}${lines.map((line) => `${line}\n`).join("")}${paintCommand()}`);
41
+ drawn = live.length > 0;
42
+ },
43
+ setLive(lines, nextCursor) {
44
+ const erase = eraseCommand();
45
+ live = lines;
46
+ cursor = nextCursor;
47
+ emit(`${erase}${paintCommand()}`);
48
+ drawn = live.length > 0;
49
+ },
50
+ clear() {
51
+ emit(eraseCommand());
52
+ live = [];
53
+ cursor = { row: 0, col: 0 };
54
+ drawn = false;
55
+ gap = 0;
56
+ },
57
+ forget() {
58
+ // after a resize or a screen clear what is on the screen is unknown: carry on from the cursor, without padding
59
+ drawn = false;
60
+ live = [];
61
+ cursor = { row: 0, col: 0 };
62
+ gap = 0;
63
+ contentRows = Math.max(screenRows(), 1);
64
+ },
65
+ cleared() {
66
+ this.forget();
67
+ contentRows = 0;
68
+ },
69
+ width: () => out.columns ?? 80,
70
+ };
71
+ }
@@ -0,0 +1,69 @@
1
+ // What the live area looks like for a given state: progress above, the prompt box, the status line and a popup under it.
2
+ // Pure: state in, lines and cursor position out.
3
+ import { createPalette, truncate, wrapAnsi } from "../ansi.js";
4
+ import { sessionLabel } from "../session-store.js";
5
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const PROMPT = "› ";
7
+ export function composeLive(state) {
8
+ const pal = createPalette(state.color);
9
+ const width = Math.max(20, state.width);
10
+ const lines = [];
11
+ if (state.streamingTail.trim() !== "") {
12
+ const tail = wrapAnsi(state.streamingTail.replace(/\s+$/, ""), width - 2).slice(-2);
13
+ for (const line of tail)
14
+ lines.push(truncate(pal.dim(` ${line}`), width));
15
+ }
16
+ if (state.reasoning.trim() !== "")
17
+ lines.push(truncate(pal.dim(` thinking: …${state.reasoning.replace(/\s+/g, " ").trim().slice(-Math.max(10, width - 16))}`), width));
18
+ const spinner = SPINNER[state.frame % SPINNER.length];
19
+ for (const tool of state.runningTools.slice(-3))
20
+ lines.push(truncate(`${pal.cyan(spinner)} ${pal.bold(tool.name)} ${tool.summary}`, width));
21
+ if (state.working)
22
+ lines.push(truncate(pal.dim(`${spinner} Working (${state.elapsedSeconds}s) · Esc to cancel · Enter to steer · Tab to queue`), width));
23
+ for (const message of state.pending.slice(-3))
24
+ lines.push(truncate(pal.dim(` ↳ ${message.kind === "steer" ? "steering" : "queued"}: ${message.text.replace(/\s+/g, " ")}`), width));
25
+ if (state.hint)
26
+ lines.push(truncate(pal.yellow(state.hint), width));
27
+ if (state.picker) {
28
+ const { picker } = state;
29
+ lines.push(pal.dim("─".repeat(width)));
30
+ lines.push(pal.bold(picker.title) + pal.dim(" ↑/↓ choose · Enter open · Esc cancel"));
31
+ const first = Math.max(0, Math.min(picker.index - 3, picker.items.length - 8));
32
+ for (let index = first; index < Math.min(picker.items.length, first + 8); index += 1) {
33
+ const item = picker.items[index];
34
+ const when = new Date(item.updatedAt).toISOString().slice(0, 16).replace("T", " ");
35
+ const row = `${index === picker.index ? "›" : " "} ${when} ${item.id.slice(0, 8)} ${sessionLabel(item)}`;
36
+ lines.push(truncate(index === picker.index ? pal.cyan(row) : row, width));
37
+ }
38
+ return { lines, cursor: { row: lines.length - 1, col: 0 } };
39
+ }
40
+ lines.push(pal.dim("─".repeat(width)));
41
+ const window = composerWindow(state.composer, state.maxComposerRows ?? 8);
42
+ const composerTop = lines.length;
43
+ if (window.above > 0)
44
+ lines.push(pal.dim(` … +${window.above} lines`));
45
+ window.rows.forEach((row, index) => lines.push(`${index === 0 && window.above === 0 ? pal.bold(pal.cyan(PROMPT)) : " "}${row}`));
46
+ if (window.below > 0)
47
+ lines.push(pal.dim(` … +${window.below} lines`));
48
+ const cursor = { row: composerTop + (window.above > 0 ? 1 : 0) + window.cursorRow, col: PROMPT.length + state.composer.cursorCol };
49
+ lines.push(pal.dim(truncate(` ${state.statusLine}`, width)));
50
+ if (state.popup) {
51
+ const { commands, index } = state.popup;
52
+ const first = Math.max(0, Math.min(index - 2, commands.length - 6));
53
+ for (let position = first; position < Math.min(commands.length, first + 6); position += 1) {
54
+ const command = commands[position];
55
+ const row = `${position === index ? "›" : " "} /${command.name.padEnd(12)} ${command.summary}`;
56
+ lines.push(truncate(position === index ? pal.cyan(row) : pal.dim(row), width));
57
+ }
58
+ }
59
+ return { lines, cursor };
60
+ }
61
+ /** The rows of the composer that fit in `limit` rows (two of them may be taken by "… +N lines" markers), around the cursor. */
62
+ export function composerWindow(composer, limit) {
63
+ const total = composer.rows.length;
64
+ if (total <= limit)
65
+ return { rows: composer.rows, above: 0, below: 0, cursorRow: composer.cursorRow };
66
+ const visible = Math.max(1, limit - 2);
67
+ const start = Math.max(0, Math.min(composer.cursorRow - Math.floor(visible / 2), total - visible));
68
+ return { rows: composer.rows.slice(start, start + visible), above: start, below: total - start - visible, cursorRow: composer.cursorRow - start };
69
+ }