@itookit/dsht 0.3.8 → 0.5.1

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 (130) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -11
  3. package/README.zh.md +30 -11
  4. package/dist/cli/dsht.js +203 -18
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +616 -166
  18. package/dist/controller/controller.js +1395 -146
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +73 -72
  49. package/dist/session/controller.js +185 -209
  50. package/dist/session/history.d.ts +6 -18
  51. package/dist/session/history.js +1 -24
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +31 -1
  77. package/dist/shell/controller.js +34 -2
  78. package/dist/shell/index.d.ts +3 -3
  79. package/dist/shell/index.js +2 -2
  80. package/dist/shell/runner.d.ts +10 -0
  81. package/dist/shell/runner.js +48 -9
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +856 -441
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/loop-status.d.ts +11 -0
  102. package/dist/ui/chat/loop-status.js +28 -0
  103. package/dist/ui/chat/navigation-model.d.ts +86 -0
  104. package/dist/ui/chat/navigation-model.js +107 -0
  105. package/dist/ui/chat/shell-view.d.ts +15 -2
  106. package/dist/ui/chat/shell-view.js +37 -3
  107. package/dist/ui/chat/status.d.ts +47 -3
  108. package/dist/ui/chat/status.js +65 -50
  109. package/dist/ui/chat/viewport.d.ts +1 -1
  110. package/dist/ui/dialogs/cost.d.ts +21 -4
  111. package/dist/ui/dialogs/cost.js +7 -12
  112. package/dist/ui/dialogs/index.d.ts +22 -5
  113. package/dist/ui/dialogs/index.js +19 -3
  114. package/dist/ui/dialogs/loop.d.ts +43 -0
  115. package/dist/ui/dialogs/loop.js +224 -0
  116. package/dist/ui/dialogs/peek.d.ts +25 -0
  117. package/dist/ui/dialogs/peek.js +35 -0
  118. package/dist/ui/dialogs/picker.d.ts +2 -0
  119. package/dist/ui/dialogs/picker.js +4 -2
  120. package/dist/ui/input/mouse.d.ts +12 -2
  121. package/dist/ui/input/mouse.js +20 -7
  122. package/dist/ui/input/references.d.ts +1 -1
  123. package/dist/ui/status/model.d.ts +7 -0
  124. package/dist/ui/status/model.js +5 -0
  125. package/dist/ui/theme/index.d.ts +1 -1
  126. package/package.json +6 -4
  127. package/dist/ui/commands/parse.d.ts +0 -104
  128. package/dist/ui/commands/parse.js +0 -135
  129. package/dist/ui/commands/registry.d.ts +0 -33
  130. package/dist/ui/commands/registry.js +0 -73
@@ -0,0 +1,259 @@
1
+ /** Slash-command syntax: one composer line in, one semantic command out.
2
+ *
3
+ * This module is a pure leaf. It reads no UI facts, performs no effects and imports nothing from the
4
+ * application, so "what the line means" stays separate from "what Enter currently does" (which is
5
+ * `slash/pipeline.ts` in the front end) and from "may this run now" (the pipeline's `authorize`).
6
+ */
7
+ import { commandMatches, resolveCommand } from "./registry.js";
8
+ /** How each loop flag names an option and validates its value. */
9
+ const LOOP_FLAGS = {
10
+ '--from': { key: 'from', valid: value => Number.isSafeInteger(value) && value >= 1 },
11
+ '--to': { key: 'to', valid: value => Number.isSafeInteger(value) && value >= 1 },
12
+ '--score': { key: 'score', valid: value => Number.isFinite(value) && value >= 0 && value <= 10 },
13
+ '--tries': { key: 'tries', valid: value => Number.isSafeInteger(value) && value >= 1 },
14
+ };
15
+ /** The flag that carries one option, so a form keyed by option validates with the same rule. */
16
+ const LOOP_OPTION_FLAGS = {
17
+ from: '--from', to: '--to', score: '--score', tries: '--tries',
18
+ };
19
+ /** Whether one numeric value is acceptable for one loop option.
20
+ *
21
+ * The command line and the interactive form share this rule, so a value the form accepts is exactly
22
+ * one the syntax would have accepted if it had been typed.
23
+ * @param key - Option being set.
24
+ * @param value - Candidate number.
25
+ * @returns True when the option may carry that value.
26
+ */
27
+ export function validLoopOption(key, value) {
28
+ return LOOP_FLAGS[LOOP_OPTION_FLAGS[key]].valid(value);
29
+ }
30
+ /** The record name the composer is currently typing after `/loop`, if any.
31
+ *
32
+ * The loop-name menu appears while the line is `/loop` or `/loop <one unfinished token>`; a second
33
+ * token means the operator moved on to the flags, so the menu stays out of the way. A trailing space
34
+ * after a complete name still counts: the menu then confirms that name rather than filtering it out.
35
+ * @param line - Composer draft exactly as typed.
36
+ * @returns The unfinished name (empty when none was started), or undefined for any other line.
37
+ */
38
+ export function loopNameQuery(line) {
39
+ if (!line.startsWith('/loop'))
40
+ return undefined;
41
+ const rest = line.slice('/loop'.length);
42
+ if (rest === '')
43
+ return '';
44
+ const match = /^[ \t]+(\S*)[ \t]*$/.exec(rest);
45
+ // The subcommands are never records, so the menu must not filter records by them.
46
+ return match?.[1] !== undefined && LOOP_SUBCOMMANDS.includes(match[1]) ? undefined : match?.[1];
47
+ }
48
+ /** Names `/loop` itself owns, so the record menu never treats one as the start of a record name. */
49
+ const LOOP_SUBCOMMANDS = ['stop', 'abort', 'answer'];
50
+ /** The one message every malformed `/loop` line receives. */
51
+ export const LOOP_USAGE = 'Use /loop <name> [score] [tries] [--from N] [--to N] [--score X] [--tries N]';
52
+ /** The one message a malformed `/loop stop` line receives. */
53
+ export const LOOP_STOP_USAGE = 'Use /loop stop (no arguments)';
54
+ /** The one message a `/loop answer` line without an answer receives. */
55
+ export const LOOP_ANSWER_USAGE = 'Use /loop answer <text>';
56
+ /** The one message a malformed `/loop abort` line receives. */
57
+ export const LOOP_ABORT_USAGE = 'Use /loop abort (no arguments)';
58
+ /** Parse `/loop <name> [score] [tries] [flags]`.
59
+ *
60
+ * The name is a record in `loop.yaml`; the syntax layer cannot know which records exist, so it only
61
+ * requires a name that is not a flag and leaves the lookup (and its error, which lists the available
62
+ * names) to the application. Score and tries may be positional or flagged; the last one wins. A line
63
+ * with no name at all is the request to be offered the records instead of typing one; `stop`, `abort`
64
+ * and `answer <text>` are `/loop`'s own subcommands rather than records.
65
+ * @param value - Trimmed line that starts with `/loop`.
66
+ * @returns The command, or the usage error.
67
+ */
68
+ function loopCommand(value) {
69
+ const words = value.slice('/loop'.length).trim().split(/\s+/).filter(Boolean);
70
+ const name = words[0];
71
+ if (name === undefined)
72
+ return { kind: 'loops' };
73
+ // `stop`, `answer` and `abort` belong to `/loop` itself, which is why a record may not take them.
74
+ if (name === 'stop')
75
+ return words.length === 1 ? { kind: 'loopStop' } : { kind: 'error', message: LOOP_STOP_USAGE };
76
+ if (name === 'abort')
77
+ return words.length === 1 ? { kind: 'loopStop' } : { kind: 'error', message: LOOP_ABORT_USAGE };
78
+ if (name === 'answer') {
79
+ const text = words.slice(1).join(' ').trim();
80
+ return text === '' ? { kind: 'error', message: LOOP_ANSWER_USAGE } : { kind: 'loopAnswer', text };
81
+ }
82
+ if (name.startsWith('--'))
83
+ return { kind: 'error', message: LOOP_USAGE };
84
+ const options = {};
85
+ let positionals = 0;
86
+ for (let index = 1; index < words.length; index += 1) {
87
+ const word = words[index];
88
+ if (word.startsWith('--')) {
89
+ const spec = LOOP_FLAGS[word];
90
+ const raw = words[index + 1];
91
+ if (spec === undefined || raw === undefined)
92
+ return { kind: 'error', message: LOOP_USAGE };
93
+ const number = Number(raw);
94
+ if (!spec.valid(number))
95
+ return { kind: 'error', message: LOOP_USAGE };
96
+ options[spec.key] = number;
97
+ index += 1;
98
+ continue;
99
+ }
100
+ // Two optional positionals: the passing score, then the attempt budget.
101
+ const number = Number(word);
102
+ if (positionals > 1)
103
+ return { kind: 'error', message: LOOP_USAGE };
104
+ const spec = positionals === 0 ? LOOP_FLAGS['--score'] : LOOP_FLAGS['--tries'];
105
+ if (!spec.valid(number))
106
+ return { kind: 'error', message: LOOP_USAGE };
107
+ options[spec.key] = number;
108
+ positionals += 1;
109
+ }
110
+ return { kind: 'loop', name, options };
111
+ }
112
+ /** Parse workspace and resume navigation, including their long aliases. */
113
+ function navigationCommand(value) {
114
+ const match = /^\/(ws|workspace|workspaces|resume|session|sessions)(?:\s+(.+))?$/.exec(value);
115
+ if (!match)
116
+ return undefined;
117
+ return { kind: match[1] === 'ws' || match[1].startsWith('workspace') ? 'workspace' : 'session', query: match[2] };
118
+ }
119
+ /** Strip one pair of surrounding quotes from an argument. */
120
+ function unquote(value) { return value.replace(/^(["'])(.*)\1$/, '$2'); }
121
+ /** Parse one composer line into a command without performing any of its effects.
122
+ *
123
+ * The order of the checks is the command precedence: the in-place panels, navigation and removal,
124
+ * then the session and host commands, then a plain prompt. Facts about the current screen or a
125
+ * pending interaction are deliberately absent: they decide whether a command may run, not what it is.
126
+ *
127
+ * A leading token that names exactly one command is resolved first, so `/pro Add tests` runs
128
+ * `/prompt Add tests`; an ambiguous token is left alone and reported with its candidates.
129
+ * @param line - Draft exactly as submitted.
130
+ * @returns The parsed command.
131
+ */
132
+ export function parseCommand(line) {
133
+ const raw = line.trim();
134
+ if (!raw)
135
+ return { kind: 'ignore' };
136
+ const value = resolveToken(raw);
137
+ // `!` runs on the machine this client is on; it never reaches the host or the model.
138
+ if (value.startsWith('!')) {
139
+ const command = value.slice(1).trim();
140
+ return command ? { kind: 'shell', command } : { kind: 'error', message: 'Type a command after !' };
141
+ }
142
+ if (value === '/copy')
143
+ return { kind: 'copy' };
144
+ if (value === '/quit')
145
+ return { kind: 'quit' };
146
+ if (value === '/cost')
147
+ return { kind: 'panel', panel: 'cost' };
148
+ if (value === '/status')
149
+ return { kind: 'panel', panel: 'status' };
150
+ if (value === '/help')
151
+ return { kind: 'panel', panel: 'help' };
152
+ const navigation = navigationCommand(value);
153
+ if (navigation && /^--(?:delete|archive)(?:\s|$)/.test(navigation.query ?? '')) {
154
+ const query = navigation.query.replace(/^--(?:delete|archive)\s*/, '');
155
+ if (!query)
156
+ return { kind: 'error', message: 'Specify the name or ID to remove' };
157
+ return { kind: 'remove', target: navigation.kind, query };
158
+ }
159
+ if (navigation)
160
+ return { kind: 'navigate', target: navigation.kind, query: navigation.query };
161
+ if (value === '/latest')
162
+ return { kind: 'latest' };
163
+ if (/^\/model(?: |$)/.test(value)) {
164
+ const args = value.split(/\s+/).slice(1);
165
+ if (args.length && (args.length < 2 || args.length > 3))
166
+ return { kind: 'error', message: 'Use /model [provider model [effort]]' };
167
+ return { kind: 'models', args };
168
+ }
169
+ if (value === '/queue')
170
+ return { kind: 'queue' };
171
+ if (value === '/new')
172
+ return { kind: 'newSession' };
173
+ // `/prompt` alone opens the list; any trailing text is the shortcut being saved. Quoting is
174
+ // deliberately not stripped here: the saved prompt is stored exactly as it will be sent.
175
+ if (/^\/prompt(?:\s|$)/.test(value)) {
176
+ const text = value.slice(7).trim();
177
+ return text ? { kind: 'savePrompt', text } : { kind: 'prompts' };
178
+ }
179
+ if (value === '/history' || value.startsWith('/history '))
180
+ return { kind: 'history', query: value.slice(8).trim() };
181
+ if (/^\/(?:search|ssearch|wsearch)(?: |$)/.test(value)) {
182
+ const [command, ...words] = value.split(' ');
183
+ const query = words.join(' ').trim();
184
+ if (!query)
185
+ return { kind: 'error', message: `Use ${command} <text>` };
186
+ return command === '/search' ? { kind: 'historySearch', query }
187
+ : { kind: 'sessionSearch', command: command, query };
188
+ }
189
+ if (/^\/think(?: |$)/.test(value))
190
+ return { kind: 'think', target: value.slice(6).trim() };
191
+ if (value === '/older')
192
+ return { kind: 'older' };
193
+ if (/^\/compact(?: |$)/.test(value)) {
194
+ if (value !== '/compact')
195
+ return { kind: 'error', message: 'Use /compact (no arguments)' };
196
+ return { kind: 'compact' };
197
+ }
198
+ if (/^\/handoff(?: |$)/.test(value)) {
199
+ if (value !== '/handoff')
200
+ return { kind: 'error', message: 'Use /handoff (no arguments)' };
201
+ return { kind: 'handoff' };
202
+ }
203
+ if (/^\/loop(?: |$)/.test(value))
204
+ return loopCommand(value);
205
+ if (value === '/cancel')
206
+ return { kind: 'cancel' };
207
+ if (value === '/allow')
208
+ return { kind: 'approval', allowed: true };
209
+ if (value === '/deny')
210
+ return { kind: 'approval', allowed: false };
211
+ if (/^\/(?:plan|goal|permission|feedback)(?:\s|$)/.test(value))
212
+ return { kind: 'hostCommand', line: value };
213
+ if (/^\/export(?:\s|$)/.test(value)) {
214
+ const destination = unquote(value.slice(7).trim());
215
+ return { kind: 'export', ...(destination ? { destination } : {}) };
216
+ }
217
+ if (/^\/export-html(?:\s|$)/.test(value)) {
218
+ const destination = unquote(value.slice(12).trim());
219
+ return { kind: 'exportHtml', ...(destination ? { destination } : {}) };
220
+ }
221
+ if (/^\/coredump(?:\s|$)/.test(value)) {
222
+ // A diagnostic of this client's own heap needs neither a session nor a connected host.
223
+ const tag = unquote(value.slice(9).trim());
224
+ return { kind: 'coredump', ...(tag ? { tag } : {}) };
225
+ }
226
+ if (value.startsWith('/'))
227
+ return unresolved(value);
228
+ return { kind: 'prompt', text: value };
229
+ }
230
+ /** First word of a trimmed draft, so arguments are never part of a command name. */
231
+ function commandToken(value) {
232
+ const space = value.search(/\s/);
233
+ return space === -1 ? value : value.slice(0, space);
234
+ }
235
+ /** Resolve a uniquely-named command prefix to the command itself, keeping the arguments.
236
+ * @param value - Trimmed draft.
237
+ * @returns The draft with its command token resolved when it names exactly one command.
238
+ */
239
+ function resolveToken(value) {
240
+ if (!value.startsWith('/'))
241
+ return value;
242
+ const token = commandToken(value);
243
+ const resolved = resolveCommand(token);
244
+ return resolved === undefined || resolved === token ? value : resolved + value.slice(token.length);
245
+ }
246
+ /** Report a leading token that names no single command, naming what it could mean.
247
+ * @param value - Trimmed draft that starts with `/`.
248
+ * @returns The error command.
249
+ */
250
+ function unresolved(value) {
251
+ const matches = commandMatches(commandToken(value));
252
+ // Exactly one match here is an `exactOnly` command that refused prefix resolution.
253
+ if (matches.length === 1)
254
+ return { kind: 'error', message: `Type the full command: ${matches[0]}` };
255
+ // A short candidate list helps more than a generic message; a bare `/` matches every command.
256
+ if (matches.length > 1 && matches.length <= 6)
257
+ return { kind: 'error', message: `Ambiguous command. Matches: ${matches.join(' ')}` };
258
+ return { kind: 'error', message: 'Unknown command. Use /help.' };
259
+ }
@@ -0,0 +1,140 @@
1
+ /** The pipeline one submitted line travels: interpret → normalize → authorize.
2
+ *
3
+ * Parsing, completion and policy live here; effects never do. The three stages answer three different
4
+ * questions and must not be merged:
5
+ *
6
+ * * `interpret` reads **front-end facts only** (an open menu, copy mode, the path screen) and turns the
7
+ * raw draft into a `Submission`. It is the last place a UI concept exists;
8
+ * * `normalize` reads **application facts** (is a question waiting?) and turns a submission into the
9
+ * one `LineCommand` the application can execute. Both front ends share it;
10
+ * * `authorize` reads the command's own declared policy plus application facts and returns a verdict.
11
+ *
12
+ * This module is a pure leaf like the rest of `slash/`: it imports no application, feature or UI code,
13
+ * so every stage can be tested without mounting anything.
14
+ */
15
+ import { type Command } from './parse.ts';
16
+ /** A front-end action: a mode of the composer, never an application effect. */
17
+ export type UiAction = {
18
+ kind: 'ignore';
19
+ } | {
20
+ kind: 'reference';
21
+ };
22
+ /** A line the application can execute.
23
+ *
24
+ * `Command` is what the syntax layer produces; the two extra kinds are lines the front end only reads
25
+ * while it has context the syntax cannot see — a free-text answer to a waiting question, and a
26
+ * directory typed on the path screen. They travel the same `runCommand` path as every command so the
27
+ * application keeps one dispatch.
28
+ */
29
+ export type LineCommand = Command | {
30
+ kind: 'answer';
31
+ text: string;
32
+ } | {
33
+ kind: 'path';
34
+ value: string;
35
+ };
36
+ /** What the front end made of one draft, before any application semantics. */
37
+ export type Submission = {
38
+ kind: 'mode';
39
+ action: UiAction;
40
+ } | {
41
+ kind: 'line';
42
+ line: string;
43
+ } | {
44
+ kind: 'path';
45
+ value: string;
46
+ };
47
+ /** A submission that still needs normalizing: a front-end mode is handled by the front end itself. */
48
+ export type ExecutableSubmission = Extract<Submission, {
49
+ kind: 'line' | 'path';
50
+ }>;
51
+ /** Front-end facts `interpret` may read; nothing else is needed. */
52
+ export interface InterpretFacts {
53
+ /** The draft exactly as submitted. */
54
+ line: string;
55
+ /** The `@` completion menu owns the draft while open. */
56
+ referenceOpen: boolean;
57
+ /** Copy mode freezes the display and ignores submissions. */
58
+ copyMode: boolean;
59
+ /** The screen the draft was typed on. The only UI concept the pipeline may touch. */
60
+ screen: 'workspaces' | 'sessions' | 'chat' | 'path';
61
+ }
62
+ /** Decide what the front end has, without any application semantics.
63
+ *
64
+ * On the path screen an absolute directory starts with `/`, which is also how a command starts, so the
65
+ * screen cannot simply claim every line: a line that names a known command (or a `!` shell line) still
66
+ * escapes the screen and is normalized as usual. Everything else there is the directory that was asked
67
+ * for. Without this rule `/srv/data` would be parsed as a command and refused as unknown.
68
+ * @param facts - Current front-end facts.
69
+ * @returns The submission the application will normalize.
70
+ */
71
+ export declare function interpret(facts: InterpretFacts): Submission;
72
+ /** Application facts `normalize` needs to classify free text. */
73
+ export interface NormalizeFacts {
74
+ /** A conversation is selected, so free text has somewhere to go. */
75
+ sessionSelected: boolean;
76
+ /** A question of the selected session is waiting, so free text answers it. */
77
+ question: boolean;
78
+ /** An approval of the selected session is waiting, so free text is not accepted. */
79
+ pending: boolean;
80
+ }
81
+ /** Turn a submission into the one line the application can execute.
82
+ *
83
+ * The order is the precedence: a directory the screen asked for, then a `/` or `!` line, then the two
84
+ * meanings free text can have while an interaction waits, then a plain message.
85
+ * @param submission - What the front end made of the draft.
86
+ * @param facts - Current application facts.
87
+ * @returns The executable line.
88
+ */
89
+ export declare function normalize(submission: ExecutableSubmission, facts: NormalizeFacts): LineCommand;
90
+ /** Application facts `authorize` needs; all of them exist with or without a UI. */
91
+ export interface AuthorizeFacts {
92
+ /** A conversation is selected, so a command that needs one may run. */
93
+ sessionSelected: boolean;
94
+ /** An interaction of that conversation is waiting. */
95
+ pending: boolean;
96
+ /** Another operation already owns the client's single foreground slot.
97
+ *
98
+ * A front end reads this from the controller, so "may this line run while something else is running"
99
+ * is answered here — with a reason — instead of by each front end dropping the line in silence.
100
+ */
101
+ foreground: boolean;
102
+ /** Whether the selected conversation has a turn or a loop in flight.
103
+ *
104
+ * A loop is reported as `loop` rather than as the turn it runs, because stopping the loop is what
105
+ * the operator has to do first, and that is the reason the refusal has to name.
106
+ */
107
+ during: 'idle' | 'turn' | 'loop';
108
+ }
109
+ /** Why a line the policy accepted is not running yet.
110
+ *
111
+ * The fact that has to clear: the foreground slot, a turn, or a whole loop. One fact, because a line is
112
+ * held behind the longest-lived of them and a front end only needs to know what to watch for.
113
+ */
114
+ export type DeferReason = 'busy' | 'turn' | 'loop';
115
+ /** Whether one command may run now, may run later, or may not run at all.
116
+ *
117
+ * A refusal is always the error line, never an arbitrary command.
118
+ */
119
+ export type Verdict = {
120
+ allow: true;
121
+ command: LineCommand;
122
+ defer?: DeferReason;
123
+ } | {
124
+ allow: false;
125
+ error: Extract<Command, {
126
+ kind: 'error';
127
+ }>;
128
+ };
129
+ /** Apply one command's declared policy to the current application facts.
130
+ *
131
+ * The policy is data on the command kind, so this function never enumerates commands. A kind with no
132
+ * entry has no constraint. The constraint names describe the fact, not the screen: `requiresSession`
133
+ * is refused in headless too (where a session always exists, so the check simply passes), and
134
+ * `requiresNoInteraction` is refused wherever an interaction waits, because a front end without an
135
+ * answer channel cannot satisfy it either.
136
+ * @param command - Line about to be executed.
137
+ * @param facts - Current application facts.
138
+ * @returns The verdict; on refusal, the error command the application should report.
139
+ */
140
+ export declare function authorize(command: LineCommand, facts: AuthorizeFacts): Verdict;
@@ -0,0 +1,115 @@
1
+ /** The pipeline one submitted line travels: interpret → normalize → authorize.
2
+ *
3
+ * Parsing, completion and policy live here; effects never do. The three stages answer three different
4
+ * questions and must not be merged:
5
+ *
6
+ * * `interpret` reads **front-end facts only** (an open menu, copy mode, the path screen) and turns the
7
+ * raw draft into a `Submission`. It is the last place a UI concept exists;
8
+ * * `normalize` reads **application facts** (is a question waiting?) and turns a submission into the
9
+ * one `LineCommand` the application can execute. Both front ends share it;
10
+ * * `authorize` reads the command's own declared policy plus application facts and returns a verdict.
11
+ *
12
+ * This module is a pure leaf like the rest of `slash/`: it imports no application, feature or UI code,
13
+ * so every stage can be tested without mounting anything.
14
+ */
15
+ import { parseCommand } from "./parse.js";
16
+ import { COMMAND_POLICY, resolveCommand } from "./registry.js";
17
+ /** Decide what the front end has, without any application semantics.
18
+ *
19
+ * On the path screen an absolute directory starts with `/`, which is also how a command starts, so the
20
+ * screen cannot simply claim every line: a line that names a known command (or a `!` shell line) still
21
+ * escapes the screen and is normalized as usual. Everything else there is the directory that was asked
22
+ * for. Without this rule `/srv/data` would be parsed as a command and refused as unknown.
23
+ * @param facts - Current front-end facts.
24
+ * @returns The submission the application will normalize.
25
+ */
26
+ export function interpret(facts) {
27
+ if (facts.referenceOpen)
28
+ return { kind: 'mode', action: { kind: 'reference' } };
29
+ if (facts.copyMode)
30
+ return { kind: 'mode', action: { kind: 'ignore' } };
31
+ const value = facts.line.trim();
32
+ if (!value)
33
+ return { kind: 'mode', action: { kind: 'ignore' } };
34
+ if (facts.screen === 'path' && !namesCommand(value))
35
+ return { kind: 'path', value };
36
+ return { kind: 'line', line: facts.line };
37
+ }
38
+ /** Whether a line is meant as a command rather than as the directory the path screen asked for.
39
+ * @param value - Trimmed draft.
40
+ * @returns True for a `!` line or a line whose token names exactly one command.
41
+ */
42
+ function namesCommand(value) {
43
+ if (value.startsWith('!'))
44
+ return true;
45
+ const token = value.split(/\s+/)[0] ?? '';
46
+ return token.startsWith('/') && resolveCommand(token) !== undefined;
47
+ }
48
+ /** Turn a submission into the one line the application can execute.
49
+ *
50
+ * The order is the precedence: a directory the screen asked for, then a `/` or `!` line, then the two
51
+ * meanings free text can have while an interaction waits, then a plain message.
52
+ * @param submission - What the front end made of the draft.
53
+ * @param facts - Current application facts.
54
+ * @returns The executable line.
55
+ */
56
+ export function normalize(submission, facts) {
57
+ if (submission.kind === 'path')
58
+ return { kind: 'path', value: submission.value };
59
+ const value = submission.line.trim();
60
+ if (value.startsWith('/') || value.startsWith('!'))
61
+ return parseCommand(value);
62
+ // A question takes free text as its answer; an approval keeps every choice explicit.
63
+ if (facts.question)
64
+ return { kind: 'answer', text: value };
65
+ if (facts.pending)
66
+ return { kind: 'error', message: 'Answer the approval with /allow or /deny' };
67
+ // Free text without a conversation has nowhere to go; saying so beats sending it to no session.
68
+ if (!facts.sessionSelected)
69
+ return { kind: 'error', message: 'Choose a session or type /ws or /resume' };
70
+ return { kind: 'prompt', text: value };
71
+ }
72
+ /** Apply one command's declared policy to the current application facts.
73
+ *
74
+ * The policy is data on the command kind, so this function never enumerates commands. A kind with no
75
+ * entry has no constraint. The constraint names describe the fact, not the screen: `requiresSession`
76
+ * is refused in headless too (where a session always exists, so the check simply passes), and
77
+ * `requiresNoInteraction` is refused wherever an interaction waits, because a front end without an
78
+ * answer channel cannot satisfy it either.
79
+ * @param command - Line about to be executed.
80
+ * @param facts - Current application facts.
81
+ * @returns The verdict; on refusal, the error command the application should report.
82
+ */
83
+ export function authorize(command, facts) {
84
+ // `answer` and `path` are lines, not catalog kinds, so the table lookup is by kind string.
85
+ const policy = COMMAND_POLICY[command.kind];
86
+ if (policy?.requiresSession === true && !facts.sessionSelected) {
87
+ return { allow: false, error: { kind: 'error', message: 'Select a session first' } };
88
+ }
89
+ if (policy?.requiresNoInteraction === true && facts.pending) {
90
+ return { allow: false, error: { kind: 'error', message: 'Answer the pending question or approval first' } };
91
+ }
92
+ // `during` is checked last: "answer what is waiting" is a more actionable reason than "something is
93
+ // running", and a pending interaction usually means a turn is running too.
94
+ const during = facts.during === 'loop' ? policy?.duringLoop : facts.during === 'turn' ? policy?.duringTurn : undefined;
95
+ const whileBusy = policy?.whileBusy ?? 'deny';
96
+ if (during === 'deny') {
97
+ return { allow: false, error: { kind: 'error',
98
+ message: facts.during === 'loop' ? 'Stop the running loop first' : 'Wait for the running turn to finish' } };
99
+ }
100
+ // A queue behind a turn or a loop is decided first: the line runs later anyway, so the slot being
101
+ // busy right now says nothing about whether it should. The held line waits for both facts.
102
+ if (during === 'queue')
103
+ return { allow: true, command, defer: facts.during === 'loop' ? 'loop' : 'turn' };
104
+ // Only the commands declared to answer the operator or the host are admitted while the client is
105
+ // busy (§3.4); everything else waits for a reason the operator can read, because running it now
106
+ // would interleave with the operation they are watching.
107
+ if (facts.foreground) {
108
+ if (whileBusy === 'deny') {
109
+ return { allow: false, error: { kind: 'error', message: 'Wait for the running operation to finish' } };
110
+ }
111
+ if (whileBusy === 'queue')
112
+ return { allow: true, command, defer: 'busy' };
113
+ }
114
+ return { allow: true, command };
115
+ }
@@ -0,0 +1,88 @@
1
+ /** Slash-command catalog shared by completion, `/help` and the submission router. */
2
+ import type { Command } from './parse.ts';
3
+ /** One slash command advertised by completion and `/help`. */
4
+ export interface CommandHint {
5
+ /** Slash command as typed without arguments. */
6
+ command: string;
7
+ /** Argument hint shown after the command; absent when it takes none. */
8
+ usage?: string;
9
+ /** One-line action description shown by `/help`. */
10
+ description: string;
11
+ /** Must be typed in full: a unique prefix of it is completed, never executed on Enter. */
12
+ exactOnly?: boolean;
13
+ }
14
+ /** Command discovery catalog shared by Tab completion and the `/help` panel. */
15
+ export declare const COMMAND_HINTS: readonly CommandHint[];
16
+ /** Command names only, in catalog order. */
17
+ export declare const COMMANDS: string[];
18
+ /** Command column text per hint, aligned in the `/help` panel. */
19
+ export declare const COMMAND_LABELS: string[];
20
+ /** Widest command column, so descriptions start on one column. */
21
+ export declare const COMMAND_LABEL_WIDTH: number;
22
+ /** Commands whose name starts with one token, in catalog order.
23
+ * @param token - First word of a draft, such as `/pro`.
24
+ * @returns Matching command names, empty when the token names nothing.
25
+ */
26
+ export declare function commandMatches(token: string): string[];
27
+ /** Resolve one typed command token to the command it names.
28
+ *
29
+ * An exact command resolves to itself. A prefix that matches exactly one command resolves to that
30
+ * command too, so Enter can run it without typing the whole name; an unknown or ambiguous token,
31
+ * and any prefix of an `exactOnly` command, resolves to undefined so the draft stays as typed.
32
+ * @param token - First word of a draft, without its arguments.
33
+ * @returns The command name, or undefined when the token does not name one command.
34
+ */
35
+ export declare function resolveCommand(token: string): string | undefined;
36
+ /** What may happen to a command while the selected conversation is busy.
37
+ *
38
+ * `'queue'` means the line is accepted and held until the fact clears; the front end fulfils it (it
39
+ * owns the port and the view), and a scripted caller waits for the same fact before running the line.
40
+ */
41
+ export type DuringExecution = 'run' | 'queue' | 'deny';
42
+ /** Where one parsed command may run, and what it must wait for.
43
+ *
44
+ * The names describe the application fact, not the screen that happens to represent it: a command
45
+ * that needs a session is refused wherever no session is selected, and a front end cannot make that
46
+ * requirement disappear by not having screens. An absent `duringTurn`/`duringLoop` means "no
47
+ * constraint" — the many reads and local commands need no entry to keep running.
48
+ */
49
+ export interface CommandPolicy {
50
+ /** Needs a selected conversation. */
51
+ requiresSession?: boolean;
52
+ /** Refused while an approval or a question of that conversation is waiting. */
53
+ requiresNoInteraction?: boolean;
54
+ /** Belongs to the control lane of the session write gate: it preempts waiting writes (§6.3.2). */
55
+ control?: boolean;
56
+ /** While another operation owns the foreground slot, which is what the operator is watching. */
57
+ whileBusy?: DuringExecution;
58
+ /** While a turn of the selected conversation runs. */
59
+ duringTurn?: DuringExecution;
60
+ /** While a client-driven loop runs, which is what the operator must deal with first. */
61
+ duringLoop?: DuringExecution;
62
+ }
63
+ export declare const COMMAND_POLICY: Readonly<Partial<Record<Command['kind'], CommandPolicy>>>;
64
+ /** Longest common prefix of the candidate commands, so Tab can extend an ambiguous draft.
65
+ * @param values - Command candidates.
66
+ * @returns The shared leading prefix.
67
+ */
68
+ export declare function commonPrefix(values: string[]): string;
69
+ /** Complete the leading slash command; an ambiguous draft extends to the shared prefix.
70
+ * @param input - Current composer draft.
71
+ * @returns The completed draft, or undefined when nothing can be completed.
72
+ */
73
+ export declare function completeCommand(input: string): string | undefined;
74
+ /** Commands matching the current draft, shown under the composer.
75
+ * @param input - Current composer draft.
76
+ * @returns Candidate command names.
77
+ */
78
+ export declare function suggestedCommands(input: string): string[];
79
+ /** The catalog entry whose arguments the draft is currently typing.
80
+ *
81
+ * A usage line is only useful once the name is settled and arguments have begun, so this requires
82
+ * whitespace after a token that names one command: `/model ` hints the model command, while `/co `
83
+ * names none and `/think` is still completing a name. A unique prefix counts, because the same prefix
84
+ * already runs that command.
85
+ * @param input - Current composer draft.
86
+ * @returns The command's hint, or undefined when no arguments are being typed.
87
+ */
88
+ export declare function argumentHint(input: string): CommandHint | undefined;