team 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +38 -12
  2. package/dist/ansi.d.ts +13 -0
  3. package/dist/ansi.js +75 -0
  4. package/dist/approve/approval.d.ts +11 -0
  5. package/dist/approve/approval.js +80 -8
  6. package/dist/approve/fingerprint.d.ts +29 -6
  7. package/dist/approve/fingerprint.js +57 -32
  8. package/dist/budgets/gate.d.ts +5 -4
  9. package/dist/budgets/gate.js +23 -14
  10. package/dist/budgets/readings.d.ts +57 -9
  11. package/dist/budgets/readings.js +134 -18
  12. package/dist/budgets/run.js +8 -2
  13. package/dist/budgets/table.d.ts +4 -2
  14. package/dist/budgets/table.js +29 -11
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.js +8 -5
  17. package/dist/commands/add.d.ts +0 -1
  18. package/dist/commands/add.js +18 -9
  19. package/dist/commands/approve.js +17 -1
  20. package/dist/commands/doctor.d.ts +17 -1
  21. package/dist/commands/doctor.js +128 -8
  22. package/dist/commands/down.d.ts +6 -2
  23. package/dist/commands/down.js +40 -14
  24. package/dist/commands/init.d.ts +1 -1
  25. package/dist/commands/init.js +4 -2
  26. package/dist/commands/remove.d.ts +2 -0
  27. package/dist/commands/remove.js +26 -4
  28. package/dist/commands/status.d.ts +2 -0
  29. package/dist/commands/status.js +22 -3
  30. package/dist/commands/up.d.ts +3 -1
  31. package/dist/commands/up.js +12 -6
  32. package/dist/commands/watch.d.ts +4 -0
  33. package/dist/commands/watch.js +99 -19
  34. package/dist/conformance/adapter.d.ts +5 -0
  35. package/dist/conformance/adapter.js +44 -0
  36. package/dist/file/check.d.ts +20 -0
  37. package/dist/file/check.js +112 -0
  38. package/dist/file/lines.js +13 -2
  39. package/dist/file/sections/budgets.d.ts +15 -0
  40. package/dist/file/sections/budgets.js +180 -0
  41. package/dist/file/sections/coordinator.d.ts +2 -0
  42. package/dist/file/sections/coordinator.js +11 -0
  43. package/dist/file/sections/format.d.ts +2 -0
  44. package/dist/file/sections/format.js +13 -0
  45. package/dist/file/sections/identity.d.ts +2 -0
  46. package/dist/file/sections/identity.js +103 -0
  47. package/dist/file/sections/index.d.ts +9 -0
  48. package/dist/file/sections/index.js +43 -0
  49. package/dist/file/sections/lead.d.ts +4 -0
  50. package/dist/file/sections/lead.js +18 -0
  51. package/dist/file/sections/limits.d.ts +2 -0
  52. package/dist/file/sections/limits.js +43 -0
  53. package/dist/file/sections/machine.d.ts +2 -0
  54. package/dist/file/sections/machine.js +38 -0
  55. package/dist/file/sections/operator.d.ts +2 -0
  56. package/dist/file/sections/operator.js +11 -0
  57. package/dist/file/sections/project.d.ts +2 -0
  58. package/dist/file/sections/project.js +13 -0
  59. package/dist/file/sections/rules.d.ts +2 -0
  60. package/dist/file/sections/rules.js +9 -0
  61. package/dist/file/sections/seats.d.ts +7 -0
  62. package/dist/file/sections/seats.js +161 -0
  63. package/dist/file/sections/section.d.ts +32 -0
  64. package/dist/file/sections/section.js +7 -0
  65. package/dist/file/sections/session.d.ts +2 -0
  66. package/dist/file/sections/session.js +16 -0
  67. package/dist/file/sections/tools.d.ts +2 -0
  68. package/dist/file/sections/tools.js +43 -0
  69. package/dist/file/sections/trust.d.ts +2 -0
  70. package/dist/file/sections/trust.js +20 -0
  71. package/dist/file/sections/units.d.ts +16 -0
  72. package/dist/file/sections/units.js +8 -0
  73. package/dist/file/sections/visibility.d.ts +2 -0
  74. package/dist/file/sections/visibility.js +12 -0
  75. package/dist/file/sections/watch-checks.d.ts +9 -0
  76. package/dist/file/sections/watch-checks.js +14 -0
  77. package/dist/file/sections/watch.d.ts +14 -0
  78. package/dist/file/sections/watch.js +111 -0
  79. package/dist/file/sections/workspace.d.ts +4 -0
  80. package/dist/file/sections/workspace.js +77 -0
  81. package/dist/file/types.d.ts +6 -0
  82. package/dist/file/validate.d.ts +4 -12
  83. package/dist/file/validate.js +45 -668
  84. package/dist/herdr.d.ts +5 -0
  85. package/dist/herdr.js +51 -12
  86. package/dist/launch/agent.d.ts +1 -0
  87. package/dist/launch/agent.js +13 -0
  88. package/dist/launch/deliver.d.ts +7 -1
  89. package/dist/launch/deliver.js +159 -7
  90. package/dist/launch/execute.d.ts +4 -2
  91. package/dist/launch/execute.js +36 -13
  92. package/dist/launch/plan.js +16 -2
  93. package/dist/launch/rules.d.ts +4 -2
  94. package/dist/launch/rules.js +17 -3
  95. package/dist/profiles/antigravity.yaml +25 -2
  96. package/dist/profiles/claude-code.yaml +51 -6
  97. package/dist/profiles/codex.yaml +14 -1
  98. package/dist/profiles/cursor.yaml +46 -3
  99. package/dist/profiles/overrides.d.ts +67 -0
  100. package/dist/profiles/overrides.js +190 -0
  101. package/dist/profiles/profile.d.ts +3 -0
  102. package/dist/profiles/profile.js +5 -1
  103. package/dist/state.d.ts +9 -4
  104. package/dist/state.js +37 -0
  105. package/dist/status/compare.js +7 -4
  106. package/dist/status/statusline.js +3 -1
  107. package/dist/store/store.d.ts +5 -0
  108. package/dist/version.d.ts +1 -0
  109. package/dist/version.js +7 -0
  110. package/dist/watch/check.d.ts +1 -0
  111. package/dist/watch/checks/budget.js +50 -51
  112. package/dist/watch/dialect.d.ts +6 -1
  113. package/dist/watch/dialect.js +68 -12
  114. package/dist/watch/pass.d.ts +10 -2
  115. package/dist/watch/pass.js +70 -29
  116. package/dist/watch/screen-core.d.ts +41 -4
  117. package/dist/watch/screen-core.js +510 -89
  118. package/dist/watch/screen-data.d.ts +34 -0
  119. package/dist/watch/screen-file.d.ts +8 -2
  120. package/dist/watch/screen-file.js +344 -39
  121. package/dist/watch/screen-profile.d.ts +19 -0
  122. package/dist/watch/screen-profile.js +1 -0
  123. package/dist/watch/screen.d.ts +25 -6
  124. package/dist/watch/screen.js +48 -9
  125. package/examples/team.yaml +8 -0
  126. package/package.json +9 -3
  127. package/schema/team.schema.json +498 -0
@@ -1,3 +1,7 @@
1
+ // The screen core. Stage order, dialog primitives, the four composer modes and the
2
+ // safety floor live here, never in a profile. A profile is data: it can add a
3
+ // pattern, and it cannot move a stage or turn the floor off.
4
+ import { allDimAfter, hasSgr, stripSgr } from "../ansi.js";
1
5
  const BUDGET_MS = 100;
2
6
  // The rule a `below_last_rule` pattern has to sit under. It is the core's, not the profile's.
3
7
  const RULE_LINE = /^\s*[─━]{8,}\s*$/;
@@ -39,71 +43,380 @@ export function statusRowOf(data, lines) {
39
43
  const at = statusIndex(composer, lines, composer.mode === 'status-then-one', () => false);
40
44
  return at === 'stop' || at < 0 ? null : (lines[at] ?? null);
41
45
  }
46
+ /**
47
+ * The fold-shaped frame a window shows, or null: the marker row and the count it names (any
48
+ * integer, zero included), the tail rows below it, the box's two rules around both, and a footer
49
+ * under the bottom one. The top rule's width is the width the text wrapped at. The shape alone;
50
+ * whether the fold holds the typed text is the caller's to verify.
51
+ */
52
+ function foldFrame(data, lines) {
53
+ const composer = data.composer;
54
+ if (composer.mode !== 'two-rules-footer-below' || !composer.fold)
55
+ return null;
56
+ let marker = -1;
57
+ let found = null;
58
+ for (let i = lines.length - 1; i >= 0; i--) {
59
+ const hit = composer.fold.exec(lines[i] ?? '');
60
+ if (hit) {
61
+ marker = i;
62
+ found = hit;
63
+ break;
64
+ }
65
+ }
66
+ if (marker < 0 || !found)
67
+ return null;
68
+ // The frame, as the capture draws it (folded-rules.txt): the box's opening rule at the pane's
69
+ // first column — the profile's rule pattern is anchored to it — sits directly above the
70
+ // marker, and the box's closing rule is the window's last rule row, drawn at the opening
71
+ // rule's own width. A rule-looking row between the marker and the closing rule is a tail row
72
+ // then, content that cannot match the typed text's own rows; a rule row where the capture
73
+ // draws none — directly above the opening rule, or at a width the opening rule does not have
74
+ // — leaves the read unable to tell the frame from content, and it fails closed.
75
+ let top = -1;
76
+ for (let i = marker - 1; i >= 0; i--) {
77
+ if (composer.rule.test(lines[i] ?? '')) {
78
+ top = i;
79
+ break;
80
+ }
81
+ }
82
+ if (top < 0)
83
+ return null;
84
+ if (top > 0 && composer.rule.test(lines[top - 1] ?? ''))
85
+ return null;
86
+ for (let i = top + 1; i < marker; i++) {
87
+ if ((lines[i] ?? '').trim())
88
+ return null;
89
+ }
90
+ let bottom = -1;
91
+ for (let i = lines.length - 1; i > marker; i--) {
92
+ if (composer.rule.test(lines[i] ?? '')) {
93
+ bottom = i;
94
+ break;
95
+ }
96
+ }
97
+ if (bottom < 0)
98
+ return null;
99
+ const width = (lines[top] ?? '').trim().length;
100
+ if ((lines[bottom] ?? '').trim().length !== width)
101
+ return null;
102
+ const rows = lines.slice(marker + 1, bottom).filter((line) => line.trim());
103
+ if (rows.length === 0)
104
+ return null;
105
+ if (!lines.slice(bottom + 1).some((line) => composer.footers.some((pattern) => pattern.test(line))))
106
+ return null;
107
+ const count = Number(found[1]);
108
+ if (!Number.isInteger(count))
109
+ return null;
110
+ return { count, rows, width };
111
+ }
112
+ /**
113
+ * The fold a `two-rules-footer-below` composer's window shows, or null. A marker whose count is
114
+ * zero names no hidden rows to compare against, so it is no fold; it is still a marker, and
115
+ * `foldMarked` catches it.
116
+ */
117
+ export function foldOf(data, lines) {
118
+ const frame = foldFrame(data, lines);
119
+ if (frame === null || frame.count < 1)
120
+ return null;
121
+ return frame;
122
+ }
123
+ /**
124
+ * Whether the window shows a fold marker at all, whatever count it names: a marker claiming zero
125
+ * hidden rows (or a count a fold could not have) is still a box that is not showing the whole
126
+ * text, and it must never be taken for an ordinary unsent box and submitted unverified.
127
+ */
128
+ export function foldMarked(data, lines) {
129
+ return foldFrame(data, lines) !== null;
130
+ }
131
+ /** `lines` is already the window: the last 20 lines, each keeping any ANSI styling. Matching
132
+ * runs on each line's plain form, trimmed at the end; the styled form is kept for the one
133
+ * question styling can answer, whether the composer's input line is all dim. */
134
+ function plainLines(lines) {
135
+ return lines.map((line) => stripSgr(line).trimEnd());
136
+ }
137
+ const SCREEN_KINDS = new Set([
138
+ 'idle',
139
+ 'working',
140
+ 'unsent',
141
+ 'permission',
142
+ 'trust',
143
+ 'question',
144
+ 'unknown',
145
+ ]);
146
+ function getProfileFn(profile, key) {
147
+ if (!profile || (typeof profile !== 'object' && typeof profile !== 'function'))
148
+ return undefined;
149
+ try {
150
+ const fn = profile[key];
151
+ if (fn === undefined || fn === null)
152
+ return undefined;
153
+ if (typeof fn !== 'function')
154
+ return 'fail';
155
+ return fn;
156
+ }
157
+ catch {
158
+ return 'fail';
159
+ }
160
+ }
161
+ function evalPredicate(predicate, lines) {
162
+ if (predicate === 'fail')
163
+ return 'fail';
164
+ if (typeof predicate !== 'function')
165
+ return 'fail';
166
+ try {
167
+ const res = predicate(lines);
168
+ if (res && (typeof res === 'object' || typeof res === 'function') && typeof res.catch === 'function') {
169
+ try {
170
+ res.catch(() => { });
171
+ }
172
+ catch { }
173
+ return 'fail';
174
+ }
175
+ if (res === true)
176
+ return 'match';
177
+ if (res === false)
178
+ return 'miss';
179
+ return 'fail';
180
+ }
181
+ catch {
182
+ return 'fail';
183
+ }
184
+ }
185
+ function callComposer(composer, lines) {
186
+ if (composer === 'fail')
187
+ return { kind: 'unknown' };
188
+ if (typeof composer !== 'function')
189
+ return { kind: 'unknown' };
190
+ try {
191
+ const reading = composer(lines);
192
+ if (reading && (typeof reading === 'object' || typeof reading === 'function') && typeof reading.catch === 'function') {
193
+ try {
194
+ reading.catch(() => { });
195
+ }
196
+ catch { }
197
+ return { kind: 'unknown' };
198
+ }
199
+ if (!reading || typeof reading !== 'object')
200
+ return { kind: 'unknown' };
201
+ let kind;
202
+ try {
203
+ kind = reading.kind;
204
+ }
205
+ catch {
206
+ return { kind: 'unknown' };
207
+ }
208
+ if (typeof kind !== 'string' || !SCREEN_KINDS.has(kind)) {
209
+ return { kind: 'unknown' };
210
+ }
211
+ return {
212
+ kind: kind,
213
+ from: 0,
214
+ input: -1,
215
+ };
216
+ }
217
+ catch {
218
+ return { kind: 'unknown' };
219
+ }
220
+ }
42
221
  /** `lines` is already the window: the last 20 lines, each trimmed at the end. */
43
222
  export function classifyLines(data, lines, clock) {
44
- const now = clock?.now ?? Date.now;
45
- const budget = clock?.budgetMs ?? BUDGET_MS;
46
- const start = now();
47
- const tick = () => now() - start > budget;
48
- const order = [
49
- ['unknown', data.unknown],
50
- ['trust', data.trust],
51
- ['permission', data.permission],
52
- ['question', data.question],
53
- ['working', data.working],
54
- ];
55
- for (const [kind, stage] of order) {
56
- if (!stage)
57
- continue;
58
- for (const rule of stage.rules) {
59
- const hit = ruleMatches(data, lines, rule, tick);
60
- if (hit === 'stop')
223
+ try {
224
+ const now = clock?.now ?? Date.now;
225
+ const budget = clock?.budgetMs ?? BUDGET_MS;
226
+ const start = now();
227
+ const tick = () => now() - start > budget;
228
+ const plain = plainLines(lines);
229
+ // Rule (a): Every hatch predicate is monotone toward caution: hatch OR data, for working,
230
+ // every dialog (trust, permission, question) and unknown. The data stage of a profile always runs;
231
+ // a hatch predicate can only add a match. A hatch can only add caution, never remove it.
232
+ const cautionStages = [
233
+ ['unknown', data.unknown],
234
+ ['trust', data.trust],
235
+ ['permission', data.permission],
236
+ ['question', data.question],
237
+ ['working', data.working],
238
+ ];
239
+ for (const [kind, stage] of cautionStages) {
240
+ if (tick())
61
241
  return { kind: 'unknown' };
62
- if (hit)
63
- return { kind };
242
+ const fn = getProfileFn(data.profile, kind);
243
+ if (fn !== undefined) {
244
+ const outcome = evalPredicate(fn, plain);
245
+ if (outcome === 'match')
246
+ return { kind };
247
+ if (outcome === 'fail')
248
+ return { kind: 'unknown' };
249
+ }
250
+ if (stage) {
251
+ for (const rule of stage.rules) {
252
+ const hit = ruleMatches(data, plain, rule, tick);
253
+ if (hit === 'stop')
254
+ return { kind: 'unknown' };
255
+ if (hit)
256
+ return { kind };
257
+ }
258
+ }
64
259
  }
65
- }
66
- const composed = compose(data, lines, tick);
67
- if (composed.kind === 'stop' || composed.kind === 'unknown')
68
- return { kind: 'unknown' };
69
- if (composed.kind !== 'idle' && composed.kind !== 'unsent')
260
+ // Composer stage: comes from hatch OR from data (refused if both at load).
261
+ const composerFn = getProfileFn(data.profile, 'composer');
262
+ if (composerFn !== undefined) {
263
+ const composed = callComposer(composerFn, plain);
264
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
265
+ return { kind: 'unknown' };
266
+ const marked = floorHits(plain, 0, -1, data.chrome, tick);
267
+ if (marked === 'stop' || marked)
268
+ return { kind: 'unknown' };
269
+ return { kind: composed.kind };
270
+ }
271
+ const composed = compose(data, plain, lines, tick);
272
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
273
+ return { kind: 'unknown' };
274
+ if (composed.kind !== 'idle' && composed.kind !== 'unsent')
275
+ return { kind: composed.kind };
276
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
277
+ if (marked === 'stop' || marked)
278
+ return { kind: 'unknown' };
70
279
  return { kind: composed.kind };
71
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
72
- if (marked === 'stop' || marked)
280
+ }
281
+ catch {
73
282
  return { kind: 'unknown' };
74
- return { kind: composed.kind };
283
+ }
75
284
  }
76
285
  /**
77
- * The composer alone, for a turn that is still running. The unknown stage is
78
- * read first, as in the full classification, and the floor still applies.
286
+ * The composer alone, for a turn that is still running. The dialog stages and
287
+ * floor must not be overridden by composer readings returning idle or unsent.
79
288
  */
80
289
  export function composeLines(data, lines, clock) {
81
- const now = clock?.now ?? Date.now;
82
- const budget = clock?.budgetMs ?? BUDGET_MS;
83
- const start = now();
84
- const tick = () => now() - start > budget;
85
- if (data.unknown) {
86
- for (const rule of data.unknown.rules) {
87
- const hit = ruleMatches(data, lines, rule, tick);
88
- if (hit === 'stop')
290
+ try {
291
+ const now = clock?.now ?? Date.now;
292
+ const budget = clock?.budgetMs ?? BUDGET_MS;
293
+ const start = now();
294
+ const tick = () => now() - start > budget;
295
+ const plain = plainLines(lines);
296
+ const composerFn = getProfileFn(data.profile, 'composer');
297
+ if (composerFn !== undefined) {
298
+ // Profile has a HATCH composer (no data composer).
299
+ // Check if any caution stage (data or hatch) matches:
300
+ const stages = [
301
+ ['unknown', data.unknown],
302
+ ['trust', data.trust],
303
+ ['permission', data.permission],
304
+ ['question', data.question],
305
+ ['working', data.working],
306
+ ];
307
+ for (const [kind, stage] of stages) {
308
+ if (tick())
309
+ return { kind: 'unknown' };
310
+ const fn = getProfileFn(data.profile, kind);
311
+ if (fn !== undefined) {
312
+ const outcome = evalPredicate(fn, plain);
313
+ if (outcome === 'match' || outcome === 'fail')
314
+ return { kind: 'unknown' };
315
+ }
316
+ if (stage) {
317
+ for (const rule of stage.rules) {
318
+ const hit = ruleMatches(data, plain, rule, tick);
319
+ if (hit === 'stop')
320
+ return { kind: 'unknown' };
321
+ if (hit)
322
+ return { kind: 'unknown' };
323
+ }
324
+ }
325
+ }
326
+ const composed = callComposer(composerFn, plain);
327
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
89
328
  return { kind: 'unknown' };
90
- if (hit)
329
+ const marked = floorHits(plain, 0, -1, data.chrome, tick);
330
+ if (marked === 'stop' || marked)
91
331
  return { kind: 'unknown' };
332
+ return { kind: composed.kind };
92
333
  }
93
- }
94
- const composed = compose(data, lines, tick);
95
- if (composed.kind === 'stop' || composed.kind === 'unknown')
96
- return { kind: 'unknown' };
97
- if (composed.kind !== 'idle' && composed.kind !== 'unsent')
334
+ // Profile has a DATA composer.
335
+ // Check hatch dialog predicates:
336
+ const dialogKinds = ['unknown', 'trust', 'permission', 'question'];
337
+ for (const kind of dialogKinds) {
338
+ if (tick())
339
+ return { kind: 'unknown' };
340
+ const fn = getProfileFn(data.profile, kind);
341
+ if (fn !== undefined) {
342
+ const outcome = evalPredicate(fn, plain);
343
+ if (outcome === 'match' || outcome === 'fail')
344
+ return { kind: 'unknown' };
345
+ }
346
+ }
347
+ // Also if hatch working returns 'fail', fail safe:
348
+ const workingFn = getProfileFn(data.profile, 'working');
349
+ if (workingFn !== undefined) {
350
+ const outcome = evalPredicate(workingFn, plain);
351
+ if (outcome === 'fail')
352
+ return { kind: 'unknown' };
353
+ }
354
+ if (data.unknown) {
355
+ for (const rule of data.unknown.rules) {
356
+ const hit = ruleMatches(data, plain, rule, tick);
357
+ if (hit === 'stop')
358
+ return { kind: 'unknown' };
359
+ if (hit)
360
+ return { kind: 'unknown' };
361
+ }
362
+ }
363
+ const composed = compose(data, plain, lines, tick);
364
+ if (composed.kind === 'stop' || composed.kind === 'unknown')
365
+ return { kind: 'unknown' };
366
+ if (composed.kind !== 'idle' && composed.kind !== 'unsent')
367
+ return { kind: composed.kind };
368
+ const marked = floorHits(plain, composed.from, composed.input, data.chrome, tick);
369
+ if (marked === 'stop' || marked)
370
+ return { kind: 'unknown' };
98
371
  return { kind: composed.kind };
99
- const marked = floorHits(lines, composed.from, composed.input, data.chrome, tick);
100
- if (marked === 'stop' || marked)
372
+ }
373
+ catch {
101
374
  return { kind: 'unknown' };
102
- return { kind: composed.kind };
375
+ }
376
+ }
377
+ /**
378
+ * The input box a composer draws for its current text, or null when the composer does not read
379
+ * `unsent`: an idle box holds nothing, and every other shape is not the box. The rows are the
380
+ * ones the mode's own frame delimits — between the prompt row and the box's closing line; the
381
+ * `status-then-one` fallback (the captured completion frame) delimits nothing, so its box is
382
+ * its one input row, the popup rows below it being its filter's to exclude. The caller compares
383
+ * the box to the text it typed; nothing here decides that.
384
+ */
385
+ export function composerBox(data, lines) {
386
+ if (!data.composer || data.profile?.composer)
387
+ return null;
388
+ const plain = plainLines(lines);
389
+ const hit = compose(data, plain, lines, () => false);
390
+ if (hit.kind === 'stop' || hit.kind === 'unknown')
391
+ return null;
392
+ if (hit.kind !== 'unsent')
393
+ return null;
394
+ const input = plain[hit.input] ?? '';
395
+ const found = data.composer.prompt.exec(input);
396
+ if (!found)
397
+ return null;
398
+ let rest = input.slice(found[0].length);
399
+ const suffix = 'stripSuffix' in data.composer ? data.composer.stripSuffix : undefined;
400
+ if (suffix)
401
+ rest = rest.replace(suffix, '');
402
+ const indent = found[0].length + rest.length - rest.trimStart().length;
403
+ const rows = hit.rows ?? [];
404
+ // The pane draws empty rows of its own under the text, inside the box's frame — the drop
405
+ // before the status line, or before the closing rule. The captures name them per profile
406
+ // (`frame_rows`), and only those are stripped: they are the box's frame, not its content.
407
+ // Every row after them stays, so a box that shows an empty row the typed text does not have
408
+ // is not the typed text, and the caller refuses it.
409
+ let frame = data.composer.frameRows;
410
+ while (frame > 0 && rows.length > 0 && rows[rows.length - 1] === '') {
411
+ rows.pop();
412
+ frame -= 1;
413
+ }
414
+ return { first: rest.trimStart(), indent, rows, wrap: 'wrap' in data.composer ? data.composer.wrap : undefined };
103
415
  }
104
416
  function ruleMatches(data, lines, rule, tick) {
417
+ const where = rule.onFooter ? [footerLine(data, lines)] : lines;
105
418
  if (rule.any) {
106
- const hit = anyLine(lines, rule.any, tick);
419
+ const hit = anyLine(where, rule.any, tick);
107
420
  if (hit === 'stop')
108
421
  return 'stop';
109
422
  if (!hit)
@@ -111,7 +424,7 @@ function ruleMatches(data, lines, rule, tick) {
111
424
  }
112
425
  if (rule.all) {
113
426
  for (const pattern of rule.all) {
114
- const hit = lineSomewhere(lines, pattern, tick);
427
+ const hit = lineSomewhere(where, pattern, tick);
115
428
  if (hit === 'stop')
116
429
  return 'stop';
117
430
  if (!hit)
@@ -122,12 +435,20 @@ function ruleMatches(data, lines, rule, tick) {
122
435
  if (footerLine(data, lines) !== rule.footer)
123
436
  return false;
124
437
  }
438
+ if (rule.withoutRule) {
439
+ if (tick())
440
+ return 'stop';
441
+ if (findLast(lines, (line) => RULE_LINE.test(line)) >= 0)
442
+ return false;
443
+ }
125
444
  if (rule.belowLastRule) {
126
445
  if (tick())
127
446
  return 'stop';
128
- // No rule line counts as below one: today's check is `index > lastIndex`, and
129
- // lastIndex is -1 when the window has no rule. A later rule still hides a quoted dialog.
447
+ // A pattern sits below a composer rule. A window with no rule line is not
448
+ // below one: a dialog that fills the pane says so with `without_rule`.
130
449
  const ruleAt = findLast(lines, (line) => RULE_LINE.test(line));
450
+ if (ruleAt < 0)
451
+ return false;
131
452
  const at = findLast(lines, (line) => rule.belowLastRule?.test(line) ?? false);
132
453
  if (at < 0 || at <= ruleAt)
133
454
  return false;
@@ -201,41 +522,123 @@ function matches(line, pattern, tick) {
201
522
  }
202
523
  return true;
203
524
  }
204
- function compose(data, lines, tick) {
525
+ /** `plain` is the window's plain form; `styled` the window itself, for the input line's dim question. */
526
+ function compose(data, plain, styled, tick) {
205
527
  const composer = data.composer;
206
528
  if (composer.mode === 'box-to-rule')
207
- return boxToRule(lines, composer, tick);
529
+ return boxToRule(plain, styled, composer, tick);
208
530
  if (composer.mode === 'status-last')
209
- return statusLast(lines, composer, tick, false);
531
+ return statusLast(plain, styled, composer, tick, false);
210
532
  if (composer.mode === 'status-then-one')
211
- return statusThenOne(lines, composer, tick);
212
- return twoRules(lines, composer, tick);
533
+ return statusThenOne(plain, styled, composer, tick);
534
+ return twoRules(plain, styled, composer, tick);
213
535
  }
214
- function boxToRule(lines, composer, tick) {
215
- let input = -1;
536
+ /** The rule a line carries, or null: the profile's rule pattern anchored to the line's own
537
+ * first column, as the captures draw the box's rules. */
538
+ function ruleRun(composer, line) {
539
+ const hit = composer.rule.exec(line);
540
+ return hit === null ? null : hit[0];
541
+ }
542
+ function boxToRule(lines, styled, composer, tick) {
543
+ // The frame first, as the captures draw it (unsent-typed-ansi.txt): a rule is an unbroken run
544
+ // from the pane's first column — the profile's rule pattern is anchored to it — and the box's
545
+ // closing rule is the window's last rule row; nothing rule-shaped sits under the box's
546
+ // footers, and a rule-looking row the box holds is drawn at the content column: content,
547
+ // never the frame.
548
+ let close = -1;
216
549
  for (let i = lines.length - 1; i >= 0; i--) {
217
550
  if (tick())
218
551
  return { kind: 'stop' };
219
- if (composer.prompt.test(lines[i] ?? '')) {
220
- input = i;
552
+ if (ruleRun(composer, lines[i] ?? '') !== null) {
553
+ close = i;
221
554
  break;
222
555
  }
223
556
  }
224
- if (input < 0)
557
+ if (close < 0)
225
558
  return { kind: 'unknown' };
226
- const from = boundaryBefore(lines, input, composer.rule);
227
- for (let i = input + 1; i < lines.length; i++) {
559
+ // The capture draws only the box's own footers under the closing rule. A prompt-looking row
560
+ // there is a row the frame does not draw — leftover-box.txt shows a second, leftover prompt
561
+ // under the box's status — so this pane is not the one box the caller can compare against
562
+ // typed text, and the read fails closed.
563
+ for (let i = close + 1; i < lines.length; i++) {
228
564
  if (tick())
229
565
  return { kind: 'stop' };
230
- if (composer.rule.test(lines[i] ?? ''))
566
+ if (composer.prompt.test(lines[i] ?? ''))
567
+ return { kind: 'unknown' };
568
+ }
569
+ let open = -1;
570
+ for (let i = close - 1; i >= 0; i--) {
571
+ if (tick())
572
+ return { kind: 'stop' };
573
+ if (ruleRun(composer, lines[i] ?? '') !== null) {
574
+ open = i;
231
575
  break;
576
+ }
577
+ }
578
+ let input;
579
+ let from;
580
+ if (open >= 0) {
581
+ // The two rules of one box are drawn at one width (53 columns in unsent-typed-ansi.txt).
582
+ if (ruleRun(composer, lines[close] ?? '') !== ruleRun(composer, lines[open] ?? ''))
583
+ return { kind: 'unknown' };
584
+ // The input row is the box's first row under its opening frame, and it carries the prompt
585
+ // at the capture's own column. A row there that does not is not this box's input — the read
586
+ // fails closed rather than take some later row whose content begins with a prompt glyph,
587
+ // which would drop the rows above it from the box the caller compares.
588
+ input = open + 1;
589
+ if (input >= close || !composer.prompt.test(lines[input] ?? ''))
590
+ return { kind: 'unknown' };
591
+ from = open;
592
+ }
593
+ else {
594
+ // The opening rule has scrolled out of the window. The frame is then the ordered footers
595
+ // under the closing rule — every pattern matched in sequence, equal count, as the capture
596
+ // draws them — and the input row is the lowest row above the closing rule carrying the
597
+ // prompt at that column: content is drawn at the text's column, so a row at the prompt's
598
+ // own can only be the box's first. Any non-blank row above it is a row the frame does not
599
+ // explain, and the read fails closed.
600
+ let footer = false;
601
+ const rawFooters = [];
602
+ for (let j = close + 1; j < lines.length; j++) {
603
+ if (tick())
604
+ return { kind: 'stop' };
605
+ const raw = lines[j] ?? '';
606
+ if (raw.trim())
607
+ rawFooters.push(raw);
608
+ }
609
+ if (composer.footers.length > 0 && rawFooters.length === composer.footers.length) {
610
+ footer = composer.footers.every((pattern, idx) => pattern.test(rawFooters[idx] ?? ''));
611
+ }
612
+ if (!footer)
613
+ return { kind: 'unknown' };
614
+ input = -1;
615
+ for (let i = close - 1; i >= 0; i--) {
616
+ if (tick())
617
+ return { kind: 'stop' };
618
+ if (composer.prompt.test(lines[i] ?? '')) {
619
+ input = i;
620
+ break;
621
+ }
622
+ }
623
+ if (input < 0)
624
+ return { kind: 'unknown' };
625
+ for (let i = 0; i < input; i++) {
626
+ if (tick())
627
+ return { kind: 'stop' };
628
+ if ((lines[i] ?? '').trim())
629
+ return { kind: 'unknown' };
630
+ }
631
+ from = input;
632
+ }
633
+ const rows = lines.slice(input + 1, close);
634
+ for (let i = input + 1; i < close; i++) {
232
635
  if ((lines[i] ?? '').trim())
233
- return { kind: 'unsent', from, input };
636
+ return { kind: 'unsent', from, input, rows };
234
637
  }
235
638
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
236
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from, input };
639
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from, input, rows };
237
640
  }
238
- function statusLast(lines, composer, tick, allowOneTrailing) {
641
+ function statusLast(lines, styled, composer, tick, allowOneTrailing) {
239
642
  const status = statusIndex(composer, lines, allowOneTrailing, tick);
240
643
  if (status === 'stop')
241
644
  return { kind: 'stop' };
@@ -246,13 +649,14 @@ function statusLast(lines, composer, tick, allowOneTrailing) {
246
649
  input--;
247
650
  if (input < 0)
248
651
  return { kind: 'unknown' };
652
+ const rows = lines.slice(input + 1, status);
249
653
  for (let i = input + 1; i < status; i++)
250
654
  if ((lines[i] ?? '').trim())
251
- return { kind: 'unsent', from: input, input };
252
- return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer.placeholders) ? 'idle' : 'unsent', from: input, input };
655
+ return { kind: 'unsent', from: input, input, rows };
656
+ return { kind: placeholder(stripTyped(lines[input] ?? '', composer), composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: input, input, rows };
253
657
  }
254
- function statusThenOne(lines, composer, tick) {
255
- const found = statusLast(lines, composer, tick, true);
658
+ function statusThenOne(lines, styled, composer, tick) {
659
+ const found = statusLast(lines, styled, composer, tick, true);
256
660
  if (found.kind !== 'unknown')
257
661
  return found;
258
662
  // No status line: the fallback rules decide. Anything they don't name is unknown.
@@ -279,26 +683,38 @@ function statusThenOne(lines, composer, tick) {
279
683
  }
280
684
  return { kind: 'unknown' };
281
685
  }
282
- function twoRules(lines, composer, tick) {
686
+ function twoRules(lines, styled, composer, tick) {
687
+ // The frame first: the box's bottom rule is the window's last rule row, and its top rule is
688
+ // the nearest rule row above it — the captures draw exactly two rules around the box, both
689
+ // unbroken runs from the pane's first column (unsent.txt draws them at rows 7 and 12). A
690
+ // rule-looking row the box holds is drawn at the content column and is content, never frame.
283
691
  let bottom = -1;
284
692
  let top = -1;
285
- let input = -1;
286
693
  for (let i = lines.length - 1; i >= 0; i--) {
287
694
  if (tick())
288
695
  return { kind: 'stop' };
289
- const line = lines[i] ?? '';
290
- if (composer.rule.test(line)) {
696
+ if (composer.rule.test(lines[i] ?? '')) {
291
697
  if (bottom === -1)
292
698
  bottom = i;
293
- else if (top === -1 && input !== -1) {
699
+ else {
294
700
  top = i;
295
701
  break;
296
702
  }
297
703
  }
298
- else if (bottom !== -1 && input === -1 && composer.prompt.test(line))
299
- input = i;
300
704
  }
301
- if (top < 0 || bottom < 0 || input <= top || input >= bottom)
705
+ if (top < 0 || bottom < 0)
706
+ return { kind: 'unknown' };
707
+ // The two rules of one box are drawn at one width (53 columns in unsent.txt, 54 in
708
+ // folded-rules.txt). A window whose rules differ is not that frame — the box the caller
709
+ // compares against typed text cannot be established — and the read fails closed.
710
+ if (ruleRun(composer, lines[bottom] ?? '') !== ruleRun(composer, lines[top] ?? ''))
711
+ return { kind: 'unknown' };
712
+ // The input row is the box's first row under its opening frame, and it carries the prompt at
713
+ // the capture's own column. A row there that does not — a folded box's marker row
714
+ // (folded-rules.txt) — is not this box's input, and the read fails closed rather than take
715
+ // some later row whose content begins with a prompt glyph.
716
+ const input = top + 1;
717
+ if (input >= bottom || !composer.prompt.test(lines[input] ?? ''))
302
718
  return { kind: 'unknown' };
303
719
  let footer = false;
304
720
  for (let i = bottom + 1; i < lines.length; i++) {
@@ -309,23 +725,28 @@ function twoRules(lines, composer, tick) {
309
725
  }
310
726
  if (!footer)
311
727
  return { kind: 'unknown' };
312
- if (lines.slice(input + 1, bottom).some((line) => line.trim()))
313
- return { kind: 'unsent', from: top, input };
728
+ const rows = lines.slice(input + 1, bottom);
729
+ if (rows.some((line) => line.trim()))
730
+ return { kind: 'unsent', from: top, input, rows };
314
731
  const typed = (lines[input] ?? '').replace(composer.prompt, '').trim();
315
- return { kind: placeholder(typed, composer.placeholders) ? 'idle' : 'unsent', from: top, input };
316
- }
317
- function boundaryBefore(lines, input, rule) {
318
- for (let i = input - 1; i >= 0; i--)
319
- if (rule.test(lines[i] ?? ''))
320
- return i;
321
- return input;
732
+ return { kind: placeholder(typed, composer, lines[input] ?? '', styled[input]) ? 'idle' : 'unsent', from: top, input, rows };
322
733
  }
323
734
  function stripTyped(line, composer) {
324
735
  const stripped = composer.stripSuffix ? line.replace(composer.prompt, '').replace(composer.stripSuffix, '') : line.replace(composer.prompt, '');
325
736
  return stripped.trim();
326
737
  }
327
- function placeholder(typed, list) {
328
- return list.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
738
+ // A placeholder, not text. When the composer says its suggestions render dim and the input
739
+ // line carries any styling at all, the styling decides alone: all faint past the prompt is a
740
+ // greyed suggestion, anything else is text — the list is not asked, so no suggestion's
741
+ // styling can lend its words to typed characters after it. A line without styling (a plain
742
+ // read, an old herdr) leaves the list to decide, as does a composer without the style.
743
+ function placeholder(typed, composer, plainLine, styledLine) {
744
+ if (composer.placeholderStyle === 'dim' && styledLine !== undefined && hasSgr(styledLine)) {
745
+ const hit = composer.prompt.exec(plainLine);
746
+ const skip = hit === null ? 0 : plainLine.slice(0, hit.index + hit[0].length).replace(/\s/g, '').length;
747
+ return allDimAfter(styledLine, skip);
748
+ }
749
+ return composer.placeholders.some((entry) => ('equals' in entry ? typed === entry.equals : typed.startsWith(entry.prefix)));
329
750
  }
330
751
  function floorHits(lines, from, input, chrome, tick) {
331
752
  for (let i = Math.max(0, from); i < lines.length; i++) {