dsh-ssh-tui 0.8.3 → 0.8.4-rc.2

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.
@@ -30,7 +30,11 @@ export interface DoctorCheck {
30
30
  export interface DoctorRouting {
31
31
  provider?: string;
32
32
  model?: string;
33
- /** Every provider id the llm-pi-ai section configures. */
33
+ /**
34
+ * Every route exposed by the live adapter directory or configured under
35
+ * `llm-pi-ai.providers`. Presence is registration/configuration evidence,
36
+ * not a network probe.
37
+ */
34
38
  routes: readonly string[];
35
39
  /** Model ids the selected provider's route lists. */
36
40
  routeModels: readonly string[];
@@ -50,6 +54,14 @@ export interface DoctorFacts {
50
54
  roster: boolean;
51
55
  /** `codeRuntime` — what the PTC preset mounts. */
52
56
  codeRuntime: boolean;
57
+ /**
58
+ * `tools.get('ask_user_question')` — the actual registration, not the row
59
+ * text. Absent on snapshots taken before this field existed, where the
60
+ * textual check remains the fallback.
61
+ */
62
+ askUserTool?: boolean;
63
+ /** `tools.get('present')` — the other half of the 0.1.7 agent plane. */
64
+ presentTool?: boolean;
53
65
  };
54
66
  /** The profile patch. `readable` is false when it does not exist yet. */
55
67
  patch: {
@@ -9,9 +9,10 @@
9
9
  * here picks the API that is actually present so one build runs on either
10
10
  * host line.
11
11
  */
12
+ import type { Agent } from '@deepseek-ai/dsh-agent';
12
13
  import type { Context } from '@deepseek-ai/cordis';
13
14
  import type { ContextFormed } from '@deepseek-ai/dsh-llm';
14
- import type { SessionEvent } from '@deepseek-ai/dsh-session';
15
+ import type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session';
15
16
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
16
17
  import type z from '@deepseek-ai/schemastery';
17
18
  /**
@@ -294,3 +295,21 @@ export declare function streamChunkOf(frame: unknown, fallback?: {
294
295
  export declare function listenHostEvent(ctx: {
295
296
  on: (event: never, handler: never) => unknown;
296
297
  }, event: string, handler: (...args: unknown[]) => unknown): () => void;
298
+ /**
299
+ * Wind down the selected direct children of one parent, under both names the
300
+ * subagent service has shipped.
301
+ *
302
+ * 0.2.1-rc.2 renamed `drainContinuableChildren(parent, childIds)` to
303
+ * `drainChildren(parent, childIds)` — same contract (release the selected
304
+ * resident direct children of one exact live parent, leaving its other children
305
+ * admitted). Both 0.2.0 rcs and 0.2.1-rc.2 are declared, so the call site asks
306
+ * for whichever spelling the host has instead of pinning one and failing to
307
+ * compile on the other. A composition without the service at all resolves: the
308
+ * caller has already handled "no service", and this keeps the helper total.
309
+ *
310
+ * @param subagents - the host's subagent service.
311
+ * @param parent - the exact live parent whose children are being released.
312
+ * @param childIds - durable direct-child ids to release when resident.
313
+ * @returns once every selected activation released its execution handle.
314
+ */
315
+ export declare function drainChildSubagents(subagents: Context['subagents'], parent: Agent, childIds: readonly SessionId[]): Promise<void>;
@@ -83,15 +83,6 @@ export interface FooterStripInput {
83
83
  long: string;
84
84
  short: string;
85
85
  };
86
- /**
87
- * The Host-local echo: the last thing this Host confirmed to the reader
88
- * (`/theme` switched, a preset saved, a copy landed).
89
- *
90
- * Not history (AD-13) and not telemetry: it is an acknowledgement, so it is the
91
- * lowest-priority chip on the row — the first thing a narrow terminal gives up, and
92
- * never at the cost of the link, the activity, the quota or the context reading.
93
- */
94
- echo?: string;
95
86
  /** Whether the terminal must be painted with ASCII glyphs. */
96
87
  ascii?: boolean;
97
88
  /** Colour depth the accents are downgraded for. */
@@ -0,0 +1,198 @@
1
+ /**
2
+ * How long a read that could still grow into a sequence is held.
3
+ *
4
+ * The window is what makes a lone `ESC` (the reader pressed Escape) different from
5
+ * the first byte of a sequence the terminal is still sending. W-1 moved the timer,
6
+ * not the number: changing it changes how Escape feels, and that is a separate
7
+ * decision with its own evidence.
8
+ */
9
+ export declare const ESCAPE_HOLD_MS = 60;
10
+ /**
11
+ * One framed unit of input.
12
+ *
13
+ * The vocabulary is deliberately small — four kinds carry text, one carries the
14
+ * released Escape, one carries a device reply — because the Reader's job is to say
15
+ * *what arrived*, not what it means. `key` and `burst` reach the same routing today;
16
+ * they are distinguished because the Router (W-2) has to decide "one key" versus
17
+ * "several keys in one read" (the read-coalescing gaps G-1/G-2 live exactly there).
18
+ */
19
+ export type InputFrame =
20
+ /** One complete terminal unit: a control byte, a full CSI sequence, or a lone ESC. */
21
+ {
22
+ kind: 'key';
23
+ sequence: string;
24
+ }
25
+ /** A read of ordinary typing. It may carry several keys — W-3 splits it. */
26
+ | {
27
+ kind: 'burst';
28
+ text: string;
29
+ }
30
+ /** Text to be typed exactly as it is (a sequence tail, an Alt remainder, a release). */
31
+ | {
32
+ kind: 'literal';
33
+ text: string;
34
+ }
35
+ /** The payload between paste markers. */
36
+ | {
37
+ kind: 'paste';
38
+ text: string;
39
+ }
40
+ /** The held read turned out to be the Escape key itself. */
41
+ | {
42
+ kind: 'escape';
43
+ }
44
+ /** A device reply (cursor position) — input to the prober, never to an owner. */
45
+ | {
46
+ kind: 'reply';
47
+ sequence: string;
48
+ };
49
+ export interface InputReaderOptions {
50
+ /** A read that survived decoding and reply filtering. */
51
+ onRead: (text: string) => void;
52
+ /** A frame the Reader produced on its own: paste traffic, or a released hold. */
53
+ onFrame: (frame: InputFrame) => void;
54
+ /**
55
+ * How long a stalled paste may hold the reader before it is abandoned.
56
+ *
57
+ * A seam for tests only — the default is the release value. A test that waited the
58
+ * real two seconds kept a whole file's process alive alongside the others and was
59
+ * enough to unbalance a timer-sensitive paint test on a Windows runner.
60
+ */
61
+ pasteMarkerHoldMs?: number;
62
+ }
63
+ /**
64
+ * How long a paste may sit with half a marker before the reader gives up on it.
65
+ *
66
+ * This is a watchdog, not a key hold: a paste is a stream, and its close marker may
67
+ * arrive in a second chunk after a slow terminal, a ConPTY or a loaded runner — on
68
+ * Windows CI the gap was over 200 ms, and expiring that early typed the marker's tail
69
+ * into the draft and left the session inside the paste. So the window is generous.
70
+ * When it does expire the reader does not just release the tail as payload, it
71
+ * **leaves the paste**: a paste that never closed must not keep the keyboard as its
72
+ * payload for the rest of the session.
73
+ */
74
+ export declare const PASTE_MARKER_HOLD_MS = 2000;
75
+ export declare class InputReader {
76
+ private readonly options;
77
+ private readonly decoder;
78
+ private readonly guard;
79
+ /** A read that may still grow (escape assembly). */
80
+ private held;
81
+ private holdTimer;
82
+ /** Inside `ESC[200~ … ESC[201~`. */
83
+ private inPaste;
84
+ /**
85
+ * The tail of a read that ended *inside* a paste marker (`…ESC[20`), waiting for
86
+ * its other half.
87
+ */
88
+ private pasteTail;
89
+ private pasteTailTimer;
90
+ constructor(options: InputReaderOptions);
91
+ /** One read of bytes: decode it, drop device replies, hand the rest on. */
92
+ push(chunk: Buffer): void;
93
+ /** The read with any held prefix in front of it. The hold window is over. */
94
+ assemble(text: string): string;
95
+ /**
96
+ * The frames of one read, whichever kind it is.
97
+ *
98
+ * A read is what the terminal happened to send, not what the reader pressed: a
99
+ * fast typist, a slow link or ConPTY can put several keys in one read. This is the
100
+ * one place that decides where one unit ends and the next begins; everything above
101
+ * it (Router, owners) sees complete units only, and nothing above it re-parses the
102
+ * bytes (AD-20 §4.4, §6).
103
+ */
104
+ read(text: string): InputFrame[];
105
+ /**
106
+ * Frame one paste read, holding back a marker it cut in half.
107
+ *
108
+ * A terminal writes bytes, not reads: `ESC[201~` can arrive as `…ESC[20` and `1~`.
109
+ * Framed naively, the first read swallows `ESC[20` as payload and the second one
110
+ * carries no marker at all — so `inPaste` never clears and **every later key is
111
+ * pasted text**: Esc stops closing a dialog, `/exit` stops exiting. Found by
112
+ * `scripts/tui-paste-probe.mjs` on CI, where the pty chunked the write exactly
113
+ * there. The fix is the same rule the escape ladder already uses — a read that
114
+ * could still grow is held, and released as payload if its other half never comes.
115
+ */
116
+ private framePaste;
117
+ private armPasteTail;
118
+ private cancelPasteTail;
119
+ /**
120
+ * One non-paste read, split into complete units.
121
+ *
122
+ * The rules, and why each one exists:
123
+ *
124
+ * - **A complete CSI/SS3 sequence is one unit.** `ESC [ 5 ~ ESC [ 5 ~` is two page
125
+ * keys, not one unnameable read (G-1). A sequence the read cuts short stays one
126
+ * piece: `hold()` has already claimed it if it could still grow, and a trailing
127
+ * `ESC [` must reach the hold again rather than be split into `ESC` + `[`.
128
+ * - **An Alt chord is one unit.** `ESC` plus exactly one printable character is
129
+ * Alt+that key, so `ESC 1 x` is the chord Alt+1 *and then* `x` — never the text
130
+ * `1x` (G-3), and never a chord that swallows the character behind it.
131
+ * - **A control byte is one unit, CR/LF pair as one Enter.** `\x10\x10` is two
132
+ * presses of a rebound key (G-2/G-8), and Windows' CRLF is one key.
133
+ * - **A run of ordinary characters is one unit.** Splitting typing per character
134
+ * would make a pasted `yes` answer a confirm dialog character by character,
135
+ * which is the boundary W-0 pinned (`yes-typed-at-dialog`). Keymap lookup on a
136
+ * whole run is still exactly what a run means: text.
137
+ * - **A burst the paste heuristic claims stays whole.** A console without
138
+ * bracketed paste sends a paste as ordinary bytes with line breaks in them;
139
+ * that read has to reach the paste path intact (`looksLikePasteBurst`).
140
+ */
141
+ units(text: string): InputFrame[];
142
+ /**
143
+ * Whether a read is paste traffic: inside a paste, or carrying a marker.
144
+ *
145
+ * Asked before anything else, exactly as the inline check it replaces was: while
146
+ * inside a paste, CR/LF are literal characters rather than submit.
147
+ */
148
+ isPasteTraffic(combined: string): boolean;
149
+ /**
150
+ * The frames of one paste read, in order: payload inside the markers as `paste`,
151
+ * everything outside them as `literal` (which is what the inline version typed
152
+ * directly, without a keymap lookup).
153
+ *
154
+ * Only complete markers are handled here: `framePaste` holds back a read that ends
155
+ * inside one, because a marker whose two halves land in two reads would otherwise
156
+ * leave the reader inside the paste for the rest of the session.
157
+ */
158
+ frames(combined: string): InputFrame[];
159
+ /**
160
+ * Keep a read that could still grow into a sequence.
161
+ * @returns true when it was held (the caller stops there), false when the read is
162
+ * complete as it stands.
163
+ */
164
+ hold(combined: string): boolean;
165
+ /**
166
+ * Whether one read is a paste from a console that cannot say so.
167
+ *
168
+ * A burst carrying more than one line break is not a human pressing Enter twice —
169
+ * they cannot press it twice inside one read. This is the fallback's one rule, and
170
+ * it lives here so every caller applies the same one.
171
+ */
172
+ static looksLikePasteBurst(text: string): boolean;
173
+ /** Whether a complete read is a device reply rather than input. */
174
+ isDeviceReply(text: string): boolean;
175
+ /** Classify one complete read by shape. */
176
+ classify(text: string): InputFrame;
177
+ /**
178
+ * Hand over a held reply tail now.
179
+ *
180
+ * The guard releases on its own window; this is for a caller that already knows
181
+ * the tail is stale — `scripts/bench-b2.mjs` drives keystrokes faster than the
182
+ * hold, and a measurement that waited for the window would be measuring the wait.
183
+ */
184
+ releaseReplyTail(): void;
185
+ /** Drop the holds and the reply filter (display detached, teardown). */
186
+ stop(): void;
187
+ private cancelHold;
188
+ /**
189
+ * What a held read turned out to be.
190
+ *
191
+ * Verbatim from the inline timer it replaces: a lone `ESC` is the Escape key, an
192
+ * `ESC O` without its final byte is Alt+O, an escape sequence that never completed
193
+ * is consumed silently (its `ESC` must not read as a cancel), and anything else is
194
+ * typed. The last branch is unreachable for the shapes `isEscapePrefix` accepts —
195
+ * it is kept because "unreachable today" is not a reason to change a release.
196
+ */
197
+ private releaseHold;
198
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The InputRouter: one unit in, exactly one owner out.
3
+ *
4
+ * W-1 taught the TUI *what arrived* (`InputFrame`). This module answers the other
5
+ * question — *who takes it* — and it is the only place that may. The four owners
6
+ * (Screen, Surface, Composer, Workspace) each have an adapter in `tui.ts`; an
7
+ * adapter may read its own state, but it may not decide cross-owner ownership
8
+ * again, and it may not hand a unit on to the next owner when it does not use it
9
+ * (AD-20 §4.3, §4.5 F-1/F-6).
10
+ *
11
+ * The decision is a pure function of `(unit kind, unit, ownership snapshot)`: no
12
+ * app state is read or written, nothing is painted, no transcript row is pushed,
13
+ * and the snapshot is a plain object the caller keeps and overwrites, so a
14
+ * keystroke costs no allocation (W-2 is a hot-path migration).
15
+ *
16
+ * The rules are the ones `tui.ts` already applied inline, moved here verbatim —
17
+ * including one asymmetry that is **recorded debt, not a decision**:
18
+ *
19
+ * 1. `Ctrl+L` is handled by the Screen when a Screen is up and by the Composer
20
+ * otherwise; the repaint has two implementations and W-2 does not merge them
21
+ * (AD-20 §6: only a test that proves both owners repaint identically may).
22
+ * W-3b.2 owns that one.
23
+ *
24
+ * W-3b.1 closed the other asymmetry this module used to record:
25
+ * `SURFACE_COMPOSER_KEYS` sent `Home`/`End`/`Delete`/kitty-`Ctrl+Shift+C` to the
26
+ * composer while a Surface was up. The Surface owns them now, like every other unit,
27
+ * and its adapter decides what they mean — the field it borrowed, or nothing.
28
+ *
29
+ * @module dsh-ssh-tui/input-router
30
+ */
31
+ /** Who takes a unit. Exactly one of these, per unit, decided here. */
32
+ export type Owner = 'screen' | 'surface' | 'composer' | 'workspace';
33
+ /**
34
+ * The unit classes the ladder dispatches.
35
+ *
36
+ * `text` and `control` are the composer's own shapes, `sequence` is a complete
37
+ * CSI/SS3 unit, `paste` is a payload with its markers already off, and `escape` is
38
+ * the released lone Escape. `burst` frames never reach this module as a whole: the
39
+ * ladder types them character by character, so every character is routed on its own
40
+ * (which is also what lets a Surface answer `y` and the composer take the `es`).
41
+ */
42
+ export type UnitKind = 'text' | 'control' | 'sequence' | 'paste' | 'escape';
43
+ /** The read-only ownership state a decision may look at. */
44
+ export interface OwnershipSnapshot {
45
+ /** The Screen that is up, if any (its kind, for reports). */
46
+ screenKind: string | undefined;
47
+ /** The Surface (dialog) that is up, if any. */
48
+ surfaceKind: string | undefined;
49
+ /**
50
+ * …and whether that Surface owns a borrowed text field.
51
+ *
52
+ * Part of the contract's vocabulary, and no rule branches on it since W-3b.1:
53
+ * whether a Surface borrowed a field decides what its *adapter* does with an
54
+ * editing key, not who owns the key.
55
+ */
56
+ surfaceHasField: boolean;
57
+ /** The composer's field is empty — the focus ring's precondition. */
58
+ composerEmpty: boolean;
59
+ /** The transcript has collapsible cards — the focus ring's other precondition. */
60
+ workspaceCards: boolean;
61
+ }
62
+ export declare class InputRouter {
63
+ /** The one cross-owner decision. One unit in, one owner out. */
64
+ route(kind: UnitKind, unit: string, view: OwnershipSnapshot): Owner;
65
+ /**
66
+ * With nothing layered over the workspace, the Composer owns text and its editing
67
+ * keys, and the Workspace owns the transcript: the mouse, the page keys, and the
68
+ * focus ring — which an arrow reaches only while the field is empty and there is a
69
+ * card to land on (otherwise the same arrow browses history, and history is the
70
+ * composer's).
71
+ */
72
+ private composerOrWorkspace;
73
+ }
@@ -133,7 +133,14 @@ export declare function analyzePatch(text: string): PatchAnalysis;
133
133
  * @returns the later occurrences, in file order.
134
134
  */
135
135
  export declare function duplicatePatchRows(rows: readonly PatchRowRef[]): PatchRowRef[];
136
- /** Whether a patch already declares one roster row, by id or by module name. */
136
+ /**
137
+ * Whether a patch already addresses one roster row, by id or by module name.
138
+ *
139
+ * Every kind counts: an override or a disable targets the row just as an insert
140
+ * does. A repair must leave an intentional `- id: present` / `disabled: true`
141
+ * alone instead of mounting the same id a second time; the later entry would
142
+ * either collide with it or silently undo the user's choice.
143
+ */
137
144
  export declare function patchNamesRow(rows: readonly PatchRowRef[], row: RosterRow): boolean;
138
145
  /** The roster rows a patch does not declare yet. */
139
146
  export declare function missingRosterRows(rows: readonly PatchRowRef[], candidates?: readonly RosterRow[]): RosterRow[];
@@ -159,6 +166,8 @@ export interface PatchRepair {
159
166
  * @param missing - the roster rows to mount; defaults to the 0.1.5 set.
160
167
  * @param generation - which host line the profile boots on.
161
168
  * @returns the new text, or `undefined` when nothing has to change.
169
+ * @throws when `existing` is not valid YAML; appending to a broken patch would
170
+ * make it longer without making it bootable.
162
171
  */
163
172
  export declare function planRosterRepair(existing: string, missing?: readonly RosterRow[], generation?: HostGeneration): PatchRepair | undefined;
164
173
  /**
@@ -175,26 +184,32 @@ export declare function planDuplicateRepair(existing: string): PatchRepair | und
175
184
  /**
176
185
  * Mount the roster in one profile, unless it is already composed.
177
186
  *
178
- * Idempotent: a profile whose patch already names the roster row (one bundling
179
- * `@deepseek-ai/dsh-web-app`, for example) is left untouched. The write is a
180
- * plain whole-file replace because the file is small and read once at boot; a
187
+ * Idempotent: a profile whose patch already addresses the roster row (one
188
+ * bundling `@deepseek-ai/dsh-web-app`, for example) is left untouched. An
189
+ * existing file is backed up before the small whole-file replace; a
181
190
  * half-written patch would only be seen by the next launch.
182
191
  * @param home - the harness home carrying `profiles/`.
183
192
  * @param profile - the profile to patch.
184
193
  * @param missing - the rows to mount; defaults to the 0.1.5 set.
185
194
  * @param generation - which host line the profile boots on.
195
+ * @param stamp - filename-safe backups stamp, injectable for tests.
186
196
  * @returns `present` when the row already exists, else `written`.
197
+ * @throws when the existing patch cannot be read, is not valid YAML, or cannot
198
+ * be backed up; a file that cannot be preserved is not overwritten.
187
199
  */
188
- export declare function ensureRosterRows(home: string, profile: string, missing?: readonly RosterRow[], generation?: HostGeneration): Promise<'present' | 'written'>;
200
+ export declare function ensureRosterRows(home: string, profile: string, missing?: readonly RosterRow[], generation?: HostGeneration, stamp?: string): Promise<'present' | 'written'>;
189
201
  /**
190
202
  * Write a repaired patch next to a copy of the previous content.
191
203
  *
192
- * `/doctor` changes a file the user owns, so the old text is kept beside it
193
- * before anything is replaced. A file that does not exist yet needs no backup.
204
+ * `/doctor` and the automatic repair change a file the user owns, so the old
205
+ * text is kept beside it before anything is replaced. A source that vanished
206
+ * between the read and the copy needs no backup; any other backup failure
207
+ * aborts before the write, leaving the original untouched.
194
208
  * @param path - the patch file to replace.
195
209
  * @param text - the new content.
196
210
  * @param stamp - filename-safe timestamp for the backup, injectable for tests.
197
211
  * @returns the backup path, or `undefined` when there was nothing to back up.
212
+ * @throws when the backup cannot be written; the destination is not touched.
198
213
  */
199
214
  export declare function writePatchWithBackup(path: string, text: string, stamp?: string): Promise<string | undefined>;
200
215
  /** The patch text the roster block would produce on its own, for callers that report it. */
@@ -37,10 +37,24 @@ export declare function stripCursorReplies(text: string): {
37
37
  * typed. A read that ends in the middle of a possible reply is held back (see
38
38
  * `flush()`) so `ESC [ 1` + `7;1R` cannot become visible digits either.
39
39
  */
40
+ export interface TerminalInputFilterOptions {
41
+ /**
42
+ * Whether a read is bracketed-paste traffic.
43
+ *
44
+ * The filter asks instead of sniffing the markers itself: paste framing has one
45
+ * owner (the Reader), and a marker literal here would be a second one. It matters
46
+ * because a paste's close marker ends in `ESC[20` — the shape of a half-arrived
47
+ * cursor reply — so holding that tail cuts the marker in half and the Reader stays
48
+ * inside the paste for the rest of the session.
49
+ */
50
+ isPasteTraffic?: (text: string) => boolean;
51
+ }
40
52
  export declare class TerminalInputFilter {
53
+ private readonly options;
41
54
  private held;
42
55
  /** The most recent cursor reply this filter swallowed, if any. */
43
56
  lastReply: string | undefined;
57
+ constructor(options?: TerminalInputFilterOptions);
44
58
  push(text: string): {
45
59
  forward: string;
46
60
  replies: number;
@@ -60,7 +74,7 @@ export declare class TerminalInputGuard {
60
74
  private readonly holdMs;
61
75
  private readonly filter;
62
76
  private timer;
63
- constructor(emit: (text: string) => void, holdMs?: number);
77
+ constructor(emit: (text: string) => void, holdMs?: number, options?: TerminalInputFilterOptions);
64
78
  /** Feed one decoded read; anything that is not a reply reaches `emit`. */
65
79
  push(text: string): void;
66
80
  /** Hand a half-arrived sequence over (its window passed; it is typing). */