@visual-z/xeno 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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Copy for the bash approval prompt.
3
+ *
4
+ * The full command is the point: approval is the last look at what will
5
+ * run. Truncating it would hide the thing the person is being asked to
6
+ * see. The word this must not use is any that would make the prompt
7
+ * sound like a boundary.
8
+ */
9
+
10
+ import type { PermissionRequest } from "../core/permissions.ts";
11
+ import type { ApprovalChoice } from "../runtime/tools.ts";
12
+ import type { SelectItem } from "../tui/select.ts";
13
+
14
+ export function approvalHeading(request: PermissionRequest): string {
15
+ if (request.command !== undefined) {
16
+ return [
17
+ `${request.tool} wants to run:`,
18
+ "",
19
+ request.command,
20
+ "",
21
+ "Approval is the last look at this command, not a boundary.",
22
+ ].join("\n");
23
+ }
24
+ return `${request.tool} wants to run. Approval is the last look, not a boundary.`;
25
+ }
26
+
27
+ export function approvalItems(request: PermissionRequest): SelectItem[] {
28
+ const items: SelectItem[] = [{ value: "once", label: "this once" }];
29
+ if (request.command !== undefined) {
30
+ items.push({ value: "command", label: "this command this session" });
31
+ }
32
+ items.push(
33
+ { value: "tool", label: `${request.tool} this session` },
34
+ { value: "deny", label: "deny" },
35
+ );
36
+ return items;
37
+ }
38
+
39
+ export function choiceFromValue(value: string): ApprovalChoice {
40
+ switch (value) {
41
+ case "once":
42
+ case "command":
43
+ case "tool":
44
+ case "deny":
45
+ return value;
46
+ default:
47
+ return "deny";
48
+ }
49
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * A terminal session.
4
+ *
5
+ * Interactive output uses Pi's main-screen renderer and terminal lifecycle
6
+ * (D19). Durable messages are a pure log projection; deltas remain transient.
7
+ * Print and piped modes do not construct a terminal UI.
8
+ *
9
+ * Usage accounting is on by default rather than behind a flag, because the
10
+ * point of running this at all is to find out whether the prefix stays stable
11
+ * in practice. A cache that quietly stopped working raises no error -- it just
12
+ * costs more -- so the numbers have to be in front of you.
13
+ */
14
+
15
+ import {
16
+ parseArgs, startup, readPipedStdin, mergePrintPrompt, silenceTtyEarly,
17
+ } from "./startup.ts";
18
+ import { runPrompt, runRepl } from "./shell.ts";
19
+
20
+ const args = parseArgs(process.argv.slice(2));
21
+ // Before startup reads auth, scans context files and prints the header:
22
+ // in cooked mode the tty would echo anything typed during that window
23
+ // straight into the banner.
24
+ const restoreTty = silenceTtyEarly({ interactive: !args.print && !args.prompt });
25
+ if (args.print) {
26
+ // With a prompt already given, stdin is optional context: wait briefly for it.
27
+ args.prompt = mergePrintPrompt(args.prompt,
28
+ await readPipedStdin(process.stdin, args.prompt.length > 0 ? { firstByteMs: 3_000 } : {}));
29
+ }
30
+ const app = await startup(args);
31
+ if (args.prompt) {
32
+ restoreTty();
33
+ await runPrompt(app, args.prompt);
34
+ } else {
35
+ try { await runRepl(app); }
36
+ finally { restoreTty(); }
37
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Slash-command dispatch as a pure function.
3
+ *
4
+ * Matching the first token, not a string prefix, is the whole point: a
5
+ * prompt that starts with `/backdrop` used to be swallowed as `/back`.
6
+ * Unknown lone `/word`s are `unknown`; a slash-like word followed by
7
+ * more text is a prompt, so ordinary sentences stay ordinary.
8
+ *
9
+ * Prompt templates and skills register names into the same space. Built-in
10
+ * commands win, then templates, then skills; a loser is named, never silent.
11
+ */
12
+
13
+ import type { RewindMode } from "../runtime/loop.ts";
14
+ import { expandTemplate, type PromptTemplate } from "../core/prompts.ts";
15
+ import { skillInvocationMessage, type Skill } from "../core/skills.ts";
16
+
17
+ export type Command =
18
+ | { kind: "prompt"; text: string }
19
+ | { kind: "quit" }
20
+ | { kind: "usage" }
21
+ | { kind: "state" }
22
+ | { kind: "model"; pattern?: string }
23
+ | { kind: "login"; provider?: string }
24
+ | { kind: "logout"; provider?: string }
25
+ | { kind: "back"; arg?: string; mode: RewindMode; confirm: boolean }
26
+ | { kind: "tree" }
27
+ | { kind: "new" }
28
+ | { kind: "resume"; id?: string }
29
+ | { kind: "name"; name: string }
30
+ | { kind: "session" }
31
+ | { kind: "help" }
32
+ | { kind: "trust"; target: "here" | "parent" | "never" }
33
+ | { kind: "models-refresh" }
34
+ | { kind: "settings"; field?: string; value?: string }
35
+ | { kind: "hotkeys" }
36
+ | { kind: "thinking"; level?: string }
37
+ | { kind: "export"; file?: string }
38
+ | { kind: "copy" }
39
+ | { kind: "fork"; arg?: string }
40
+ | { kind: "clone" }
41
+ | { kind: "unknown"; input: string };
42
+
43
+ /** One catalog owns discoverability, parsing and busy-turn policy. */
44
+ export const COMMANDS = [
45
+ { name: "model", description: "Choose a model", argumentHint: "[id]", busy: "idle" },
46
+ { name: "thinking", description: "Set reasoning effort", argumentHint: "[level]", busy: "idle" },
47
+ { name: "settings", description: "Change theme, thinking and status bar", argumentHint: "[key value]", busy: "idle" },
48
+ { name: "login", description: "Sign in or store an API key", argumentHint: "[provider]", busy: "idle" },
49
+ { name: "logout", description: "Remove a stored credential", argumentHint: "[provider]", busy: "idle" },
50
+ { name: "new", description: "Start a new session", busy: "idle" },
51
+ { name: "resume", description: "Search saved sessions", argumentHint: "[id]", busy: "idle" },
52
+ { name: "tree", description: "Navigate session context", busy: "run" },
53
+ { name: "fork", description: "Fork at a user message", argumentHint: "[n]", busy: "idle" },
54
+ { name: "clone", description: "Copy this branch into a new session", busy: "idle" },
55
+ { name: "back", description: "Restore context or files explicitly", argumentHint: "[n [files|context|both] [confirm]]", busy: "idle" },
56
+ { name: "name", description: "Name this session", argumentHint: "<name>", busy: "idle" },
57
+ { name: "session", description: "Show session details", busy: "run" },
58
+ { name: "usage", description: "Show token usage and costs", busy: "run" },
59
+ { name: "state", description: "Show log and handoff state", busy: "run" },
60
+ { name: "export", description: "Export HTML or JSONL", argumentHint: "[file]", busy: "idle" },
61
+ { name: "copy", description: "Copy the last assistant reply", busy: "run" },
62
+ { name: "models", description: "Refresh the model catalog", argumentHint: "refresh", busy: "idle" },
63
+ { name: "trust", description: "Set workspace configuration trust", argumentHint: "[here|parent|never]", busy: "idle" },
64
+ { name: "hotkeys", description: "Show active keybindings", busy: "run" },
65
+ { name: "help", description: "Show commands and input help", busy: "run" },
66
+ { name: "quit", description: "Exit xeno", busy: "run" },
67
+ { name: "exit", description: "Exit xeno", busy: "run" },
68
+ ] as const;
69
+
70
+ export const BUILTIN_COMMAND_NAMES = COMMANDS.map((command) => command.name);
71
+
72
+ export function commandDefinition(input: string): (typeof COMMANDS)[number] | undefined {
73
+ const name = input.trim().split(/\s/, 1)[0]?.slice(1).toLowerCase();
74
+ return COMMANDS.find((command) => command.name === name);
75
+ }
76
+
77
+ const BUILTIN_SET = new Set<string>(BUILTIN_COMMAND_NAMES);
78
+
79
+ export interface SlashCatalog {
80
+ readonly prompts: ReadonlyMap<string, PromptTemplate>;
81
+ readonly skills: ReadonlyMap<string, Skill>;
82
+ }
83
+
84
+ export function resolveSlashCatalog(
85
+ prompts: readonly PromptTemplate[],
86
+ skills: readonly Skill[],
87
+ ): { catalog: SlashCatalog; diagnostics: readonly string[] } {
88
+ const diagnostics: string[] = [];
89
+ const promptMap = new Map<string, PromptTemplate>();
90
+ const skillMap = new Map<string, Skill>();
91
+
92
+ for (const prompt of prompts) {
93
+ if (BUILTIN_SET.has(prompt.name)) {
94
+ diagnostics.push(
95
+ `prompt template "${prompt.name}" from ${prompt.filePath} is shadowed by a built-in command`,
96
+ );
97
+ continue;
98
+ }
99
+ promptMap.set(prompt.name, prompt);
100
+ }
101
+
102
+ for (const skill of skills) {
103
+ if (BUILTIN_SET.has(skill.name)) {
104
+ diagnostics.push(
105
+ `skill "${skill.name}" from ${skill.filePath} is shadowed by a built-in command`,
106
+ );
107
+ continue;
108
+ }
109
+ const prompt = promptMap.get(skill.name);
110
+ if (prompt !== undefined) {
111
+ diagnostics.push(
112
+ `skill "${skill.name}" from ${skill.filePath} is shadowed by prompt template ${prompt.filePath}`,
113
+ );
114
+ continue;
115
+ }
116
+ skillMap.set(skill.name, skill);
117
+ }
118
+
119
+ return { catalog: { prompts: promptMap, skills: skillMap }, diagnostics };
120
+ }
121
+
122
+ export function parseCommand(input: string, catalog?: SlashCatalog): Command {
123
+ const text = input.trim();
124
+ if (!text.startsWith("/")) return { kind: "prompt", text };
125
+
126
+ const [head, ...rest] = text.split(/\s+/);
127
+ const token = (head ?? "").toLowerCase();
128
+ const arg = rest.join(" ");
129
+
130
+ if (!commandDefinition(text)) return resolveRegistered(token, arg, text, rest.length, catalog);
131
+
132
+ switch (token) {
133
+ case "/quit":
134
+ case "/exit":
135
+ return { kind: "quit" };
136
+ case "/usage":
137
+ return { kind: "usage" };
138
+ case "/state":
139
+ return { kind: "state" };
140
+ case "/model":
141
+ return arg.length > 0 ? { kind: "model", pattern: arg } : { kind: "model" };
142
+ case "/login":
143
+ return rest[0] === undefined ? { kind: "login" } : { kind: "login", provider: rest[0] };
144
+ case "/logout":
145
+ return rest[0] === undefined ? { kind: "logout" } : { kind: "logout", provider: rest[0] };
146
+ case "/back": {
147
+ const [n, ...flags] = rest;
148
+ const words = new Set(flags);
149
+ const mode: RewindMode =
150
+ words.has("files") ? "files" : words.has("context") ? "context" : "both";
151
+ const confirm = words.has("confirm");
152
+ return n === undefined
153
+ ? { kind: "back", mode, confirm }
154
+ : { kind: "back", arg: n, mode, confirm };
155
+ }
156
+ case "/tree":
157
+ return { kind: "tree" };
158
+ case "/new":
159
+ return { kind: "new" };
160
+ case "/resume":
161
+ return arg.length > 0 ? { kind: "resume", id: arg } : { kind: "resume" };
162
+ case "/name":
163
+ return { kind: "name", name: arg };
164
+ case "/session":
165
+ return { kind: "session" };
166
+ case "/help":
167
+ return { kind: "help" };
168
+ case "/models":
169
+ return arg === "refresh" ? { kind: "models-refresh" } : { kind: "unknown", input: text };
170
+ case "/settings": {
171
+ if (rest.length === 0) return { kind: "settings" };
172
+ const field = rest[0];
173
+ if (field === undefined) return { kind: "settings" };
174
+ if (rest.length === 1) return { kind: "settings", field };
175
+ return { kind: "settings", field, value: rest.slice(1).join(" ") };
176
+ }
177
+ case "/hotkeys":
178
+ return { kind: "hotkeys" };
179
+ case "/thinking":
180
+ return arg.length > 0 ? { kind: "thinking", level: arg } : { kind: "thinking" };
181
+ case "/export":
182
+ return arg.length > 0 ? { kind: "export", file: arg } : { kind: "export" };
183
+ case "/copy":
184
+ return { kind: "copy" };
185
+ case "/fork":
186
+ return arg.length > 0 ? { kind: "fork", arg } : { kind: "fork" };
187
+ case "/clone":
188
+ return { kind: "clone" };
189
+ case "/trust": {
190
+ const word = (rest[0] ?? "here").toLowerCase();
191
+ if (rest.length === 0 || word === "here" || word === "yes") {
192
+ return { kind: "trust", target: "here" };
193
+ }
194
+ if (word === "parent") return { kind: "trust", target: "parent" };
195
+ if (word === "never" || word === "no") return { kind: "trust", target: "never" };
196
+ return { kind: "unknown", input: text };
197
+ }
198
+ default:
199
+ return resolveRegistered(token, arg, text, rest.length, catalog);
200
+ }
201
+ }
202
+
203
+ function resolveRegistered(
204
+ token: string,
205
+ arg: string,
206
+ text: string,
207
+ restCount: number,
208
+ catalog: SlashCatalog | undefined,
209
+ ): Command {
210
+ if (catalog !== undefined) {
211
+ if (token.startsWith("/skill:")) {
212
+ const name = token.slice("/skill:".length);
213
+ const skill = catalog.skills.get(name);
214
+ if (skill !== undefined) {
215
+ return { kind: "prompt", text: skillInvocationMessage(skill, arg) };
216
+ }
217
+ }
218
+ const prompt = catalog.prompts.get(token.slice(1));
219
+ if (prompt !== undefined) {
220
+ return { kind: "prompt", text: expandTemplate(prompt.content, arg).text };
221
+ }
222
+ }
223
+ // A lone unknown /word is a failed command. More text means the user
224
+ // typed a sentence that happens to start with a slash-like token.
225
+ return restCount === 0 ? { kind: "unknown", input: text } : { kind: "prompt", text };
226
+ }
@@ -0,0 +1,28 @@
1
+ import { COMMANDS } from "./commands.ts";
2
+ import type { App } from "./startup.ts";
3
+ import { WorkspaceAutocomplete } from "../tui/autocomplete.ts";
4
+ import { SUBSCRIPTION_OPTIONS } from "../runtime/oauth.ts";
5
+
6
+ export function interactiveAutocomplete(app: App, paths: (refresh?: boolean) => Promise<readonly string[]>): WorkspaceAutocomplete {
7
+ const providers = [...new Set([...app.catalog.map((m) => m.provider), ...SUBSCRIPTION_OPTIONS.map((p) => p.id)])];
8
+ const values: Record<string, () => string[]> = {
9
+ model: () => app.catalog.map((m) => m.id),
10
+ thinking: () => ["low", "medium", "high", "xhigh", "max"].filter((level) =>
11
+ app.providerConfig.spec.thinkingLevelMap?.[level as "low"] !== null),
12
+ login: () => providers, logout: () => providers,
13
+ settings: () => ["theme", "thinking", "statusBar"],
14
+ models: () => ["refresh"], trust: () => ["here", "parent", "never"],
15
+ };
16
+ const commands = COMMANDS.map((definition) => ({
17
+ ...definition,
18
+ getArgumentCompletions(prefix: string) {
19
+ return (values[definition.name]?.() ?? []).filter((v) => v.toLowerCase().includes(prefix.toLowerCase()))
20
+ .map((v) => ({ value: v, label: v }));
21
+ },
22
+ }));
23
+ const registered = [
24
+ ...[...(app.slash?.prompts.values() ?? [])].map((p) => ({ name: p.name, description: p.description })),
25
+ ...[...(app.slash?.skills.values() ?? [])].map((s) => ({ name: `skill:${s.name}`, description: s.description })),
26
+ ];
27
+ return new WorkspaceAutocomplete([...commands, ...registered], app.workspace, paths);
28
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Expand @file and !cmd in a user prompt before the line is sent.
3
+ *
4
+ * These are the user's own keystrokes, not the model's: a shared path with
5
+ * the bash tool would either grant the model the user's authority or make
6
+ * the user ask permission to run their own shell.
7
+ *
8
+ * Shell commands trigger only at line start. Explicit @ references may
9
+ * occur inline; emails and code literals remain ordinary prompt text.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { isAbsolute, relative, resolve, sep } from "node:path";
14
+ import { fuzzyFilter } from "../tui/fuzzy.ts";
15
+ import type { FileIndex } from "../store/file-index.ts";
16
+ import { fileReferences } from "../core/file-references.ts";
17
+
18
+ /**
19
+ * Per-file cap for @ expansion. Assumed, not measured: 256KB of one file
20
+ * already crowds a turn; a silent half-file would let the model invent
21
+ * the rest. Hitting it truncates and names the file.
22
+ */
23
+ export const MAX_EXPAND_FILE_BYTES = 256 * 1024;
24
+
25
+ /**
26
+ * Bound on captured command output. Assumed, not measured: a command that
27
+ * dumps a build log would otherwise be an unbounded allocation. Truncated
28
+ * output still carries the exit code -- the exit line is appended after
29
+ * the body is capped, so a cut cannot hide whether the command passed.
30
+ */
31
+ export const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
32
+
33
+ /**
34
+ * Timeout for !cmd. Assumed, not measured: long enough for a test suite,
35
+ * short enough that a hung command cannot stall the editor. On expiry the
36
+ * process is killed and the timeout is reported as a nonzero exit.
37
+ */
38
+ export const COMMAND_TIMEOUT_MS = 120_000;
39
+
40
+ export interface ExpandContext {
41
+ readonly workspace: string;
42
+ readonly index: FileIndex;
43
+ /** Terminal writer for !!cmd (and user-visible notices). */
44
+ readonly say: (text: string) => void;
45
+ readonly timeoutMs?: number;
46
+ readonly maxFileBytes?: number;
47
+ readonly maxCommandBytes?: number;
48
+ }
49
+
50
+ export interface ExpandResult {
51
+ /** Text to send to the model. Empty when nothing should be sent. */
52
+ readonly message: string;
53
+ /** False when the input produced no model-facing text (a lone !!cmd). */
54
+ readonly send: boolean;
55
+ }
56
+
57
+ export function matchFiles(index: FileIndex, query: string): string[] {
58
+ return fuzzyFilter(index.paths, query);
59
+ }
60
+
61
+ export async function expandInput(text: string, ctx: ExpandContext): Promise<ExpandResult> {
62
+ const out: string[] = [];
63
+ const refs = fileReferences(text);
64
+ let offset = 0;
65
+ for (const line of text.split(/\r?\n/)) {
66
+ const start = offset;
67
+ offset += line.length + (text.slice(offset + line.length).startsWith("\r\n") ? 2 : 1);
68
+ if (line.startsWith("!!")) {
69
+ ctx.say(await runUserCommand(line.slice(2), ctx));
70
+ continue;
71
+ }
72
+ if (line.startsWith("!")) {
73
+ const rendered = await runUserCommand(line.slice(1), ctx);
74
+ ctx.say(rendered);
75
+ out.push(rendered);
76
+ continue;
77
+ }
78
+ if (line.trim() === "@") {
79
+ out.push(skip(ctx, "@", "empty file reference"));
80
+ continue;
81
+ }
82
+ const here = refs.filter((ref) => ref.start >= start && ref.end <= start + line.length);
83
+ if (here.length === 0) { out.push(line); continue; }
84
+ let end = 0;
85
+ for (const ref of here) {
86
+ const prefix = line.slice(end, ref.start - start);
87
+ if (prefix.trim()) out.push(prefix.trimEnd());
88
+ out.push(await expandReference(ref.path, ctx));
89
+ end = ref.end - start;
90
+ }
91
+ if (line.slice(end).trim()) out.push(line.slice(end).trimStart());
92
+ }
93
+ const message = out.join("\n");
94
+ return { message, send: message.trim().length > 0 };
95
+ }
96
+
97
+ async function expandReference(ref: string, ctx: ExpandContext): Promise<string> {
98
+ const abs = confine(ctx.workspace, ref);
99
+ if (abs === null) return skip(ctx, ref, "outside the workspace");
100
+
101
+ const got = await tryRead(abs);
102
+ if (got.status === "ok") return renderFile(posix(ref), got, ctx);
103
+ if (got.reason !== "not found") return skip(ctx, posix(ref), got.reason);
104
+
105
+ // Typed path missed; a unique fuzzy hit from the index is the query case.
106
+ const hits = matchFiles(ctx.index, ref);
107
+ if (hits.length === 1 && hits[0] !== posix(ref)) {
108
+ const path = hits[0]!;
109
+ const hitAbs = confine(ctx.workspace, path);
110
+ if (hitAbs === null) return skip(ctx, path, "outside the workspace");
111
+ const hit = await tryRead(hitAbs);
112
+ if (hit.status === "ok") return renderFile(path, hit, ctx);
113
+ return skip(ctx, path, hit.reason);
114
+ }
115
+ if (hits.length > 1) return skip(ctx, ref, `ambiguous (${hits.length} matches)`);
116
+ return skip(ctx, posix(ref), got.reason);
117
+ }
118
+
119
+ function posix(rel: string): string {
120
+ return rel.split(sep).join("/");
121
+ }
122
+
123
+ function confine(workspace: string, rel: string): string | null {
124
+ if (rel.length === 0) return null;
125
+ const abs = resolve(workspace, rel);
126
+ const inside = relative(workspace, abs);
127
+ if (inside === "" || inside.startsWith(`..${sep}`) || inside === ".." || isAbsolute(inside)) {
128
+ return null;
129
+ }
130
+ return abs;
131
+ }
132
+
133
+ type ReadResult =
134
+ | { status: "ok"; text: string; bytes: number }
135
+ | { status: "skip"; reason: string };
136
+
137
+ async function tryRead(abs: string): Promise<ReadResult> {
138
+ let buf: Buffer;
139
+ try {
140
+ buf = await readFile(abs);
141
+ } catch (err) {
142
+ const code = (err as { code?: string }).code;
143
+ if (code === "ENOENT") return { status: "skip", reason: "not found" };
144
+ if (code === "EACCES") return { status: "skip", reason: "permission denied" };
145
+ if (code === "EISDIR") return { status: "skip", reason: "is a directory" };
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ return { status: "skip", reason: message };
148
+ }
149
+ try {
150
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
151
+ return { status: "ok", text, bytes: buf.byteLength };
152
+ } catch {
153
+ return { status: "skip", reason: "not valid UTF-8" };
154
+ }
155
+ }
156
+
157
+ function renderFile(
158
+ path: string,
159
+ got: Extract<ReadResult, { status: "ok" }>,
160
+ ctx: ExpandContext,
161
+ ): string {
162
+ const cap = ctx.maxFileBytes ?? MAX_EXPAND_FILE_BYTES;
163
+ let content = got.text;
164
+ let truncated = false;
165
+ let omitted = 0;
166
+ if (got.bytes > cap) {
167
+ content = truncateUtf8(got.text, cap);
168
+ truncated = true;
169
+ omitted = Math.max(0, got.bytes - Buffer.byteLength(content, "utf8"));
170
+ ctx.say(`[truncated ${path}: ${omitted} bytes omitted]`);
171
+ }
172
+ const inner = truncated
173
+ ? `${content}\n[truncated: ${path} exceeded a size limit; ${omitted} bytes omitted]`
174
+ : content;
175
+ return `<file path="${escapeAttr(path)}">\n${inner}\n</file>`;
176
+ }
177
+
178
+ function skip(ctx: ExpandContext, path: string, reason: string): string {
179
+ const notice = `[skipped ${path}: ${reason}]`;
180
+ ctx.say(notice);
181
+ return notice;
182
+ }
183
+
184
+ function escapeAttr(value: string): string {
185
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
186
+ }
187
+
188
+ function truncateUtf8(text: string, maxBytes: number): string {
189
+ if (maxBytes <= 0) return "";
190
+ const buf = Buffer.from(text, "utf8");
191
+ if (buf.byteLength <= maxBytes) return text;
192
+ let end = maxBytes;
193
+ // Do not split a multibyte sequence: a dangling continuation byte is
194
+ // not valid UTF-8, and the model would see a replacement character.
195
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
196
+ return buf.subarray(0, end).toString("utf8");
197
+ }
198
+
199
+ async function runUserCommand(raw: string, ctx: ExpandContext): Promise<string> {
200
+ const cmd = raw.trim();
201
+ if (cmd.length === 0) return skip(ctx, "!", "empty command");
202
+
203
+ const timeoutMs = ctx.timeoutMs ?? COMMAND_TIMEOUT_MS;
204
+ const maxBytes = ctx.maxCommandBytes ?? MAX_COMMAND_OUTPUT_BYTES;
205
+
206
+ const proc = Bun.spawn(["bash", "-lc", cmd], {
207
+ cwd: ctx.workspace,
208
+ stdout: "pipe",
209
+ stderr: "pipe",
210
+ stdin: "ignore",
211
+ });
212
+
213
+ let timedOut = false;
214
+ let escalate: ReturnType<typeof setTimeout> | null = null;
215
+ const timer = setTimeout(() => {
216
+ timedOut = true;
217
+ proc.kill();
218
+ escalate = setTimeout(() => proc.kill("SIGKILL"), 1000);
219
+ }, timeoutMs);
220
+
221
+ let stdout = "";
222
+ let stderr = "";
223
+ let exit = 1;
224
+ try {
225
+ const [out, err] = await Promise.all([
226
+ readCapped(proc.stdout, maxBytes),
227
+ readCapped(proc.stderr, maxBytes),
228
+ ]);
229
+ stdout = out.text;
230
+ stderr = err.text;
231
+ if (out.truncated) stdout = `${stdout}\n[stdout truncated]`;
232
+ if (err.truncated) stderr = `${stderr}\n[stderr truncated]`;
233
+ const rawExit = await proc.exited;
234
+ exit = rawExit ?? 1;
235
+ // A kill that somehow reports success would put the model back to
236
+ // guessing. GNU timeout uses 124; never report 0 after a timeout.
237
+ if (timedOut && exit === 0) exit = 124;
238
+ } finally {
239
+ clearTimeout(timer);
240
+ if (escalate !== null) clearTimeout(escalate);
241
+ }
242
+
243
+ const parts: string[] = [`$ ${cmd}`];
244
+ if (stdout.length > 0) parts.push(stripTrailingNewline(stdout));
245
+ if (stderr.length > 0) parts.push(stripTrailingNewline(stderr));
246
+ if (timedOut) parts.push(`[timed out after ${timeoutMs}ms]`);
247
+ // Exit last so a capped body cannot cut the one number D11 needs.
248
+ parts.push(`exit ${exit}`);
249
+ return parts.join("\n");
250
+ }
251
+
252
+ function stripTrailingNewline(s: string): string {
253
+ return s.endsWith("\n") ? s.slice(0, -1) : s;
254
+ }
255
+
256
+ async function readCapped(
257
+ stream: ReadableStream<Uint8Array> | number | undefined | null,
258
+ max: number,
259
+ ): Promise<{ text: string; truncated: boolean }> {
260
+ if (stream === null || stream === undefined || typeof stream === "number") {
261
+ return { text: "", truncated: false };
262
+ }
263
+ const reader = stream.getReader();
264
+ const chunks: Uint8Array[] = [];
265
+ let n = 0;
266
+ let truncated = false;
267
+ while (true) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ if (value === undefined) continue;
271
+ if (n >= max) {
272
+ truncated = true;
273
+ continue;
274
+ }
275
+ const take = Math.min(value.length, max - n);
276
+ chunks.push(take === value.length ? value : value.subarray(0, take));
277
+ n += take;
278
+ if (take < value.length) truncated = true;
279
+ }
280
+ const buf = new Uint8Array(n);
281
+ let off = 0;
282
+ for (const c of chunks) {
283
+ buf.set(c, off);
284
+ off += c.length;
285
+ }
286
+ return { text: new TextDecoder().decode(buf), truncated };
287
+ }
288
+
289
+ /** True when the whole submission is `@` or `@query`, so the TUI can pick. */
290
+ export function bareAtQuery(text: string): string | null {
291
+ const m = /^@(\S*)$/.exec(text);
292
+ return m === null ? null : (m[1] ?? "");
293
+ }
294
+
295
+ export function isExactIndexedPath(index: FileIndex, query: string): boolean {
296
+ return query.length > 0 && index.paths.includes(query);
297
+ }