dsh-ssh-tui 0.7.4 → 0.8.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 (74) hide show
  1. package/README.en.md +37 -0
  2. package/README.md +436 -572
  3. package/docs/display-mode.md +122 -0
  4. package/docs/remote-ops.md +104 -0
  5. package/docs/terminals.md +53 -0
  6. package/lib/attach.js +4 -4
  7. package/lib/attach.js.map +1 -1
  8. package/lib/auth-failure.js +128 -0
  9. package/lib/auth-failure.js.map +1 -0
  10. package/lib/commands.js +3 -0
  11. package/lib/commands.js.map +1 -1
  12. package/lib/dialogs.js +43 -0
  13. package/lib/dialogs.js.map +1 -1
  14. package/lib/display-mode.js +147 -0
  15. package/lib/display-mode.js.map +1 -0
  16. package/lib/display-sock.js +361 -21
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/footer.js +6 -9
  19. package/lib/footer.js.map +1 -1
  20. package/lib/glyph-measure.js +92 -0
  21. package/lib/glyph-measure.js.map +1 -0
  22. package/lib/i18n/en.js +27 -2
  23. package/lib/i18n/en.js.map +1 -1
  24. package/lib/i18n/zh.js +27 -2
  25. package/lib/i18n/zh.js.map +1 -1
  26. package/lib/index.js +74 -5
  27. package/lib/index.js.map +1 -1
  28. package/lib/paint.js +22 -9
  29. package/lib/paint.js.map +1 -1
  30. package/lib/picker.js +14 -13
  31. package/lib/picker.js.map +1 -1
  32. package/lib/plan.js +11 -11
  33. package/lib/plan.js.map +1 -1
  34. package/lib/platform.js +96 -0
  35. package/lib/platform.js.map +1 -1
  36. package/lib/session-blank.js +81 -0
  37. package/lib/session-blank.js.map +1 -0
  38. package/lib/session-list.js +102 -81
  39. package/lib/session-list.js.map +1 -1
  40. package/lib/startup.js +7 -0
  41. package/lib/startup.js.map +1 -1
  42. package/lib/subagent-model.js +8 -7
  43. package/lib/subagent-model.js.map +1 -1
  44. package/lib/term-text.js +296 -28
  45. package/lib/term-text.js.map +1 -1
  46. package/lib/terminal-input.js +132 -10
  47. package/lib/terminal-input.js.map +1 -1
  48. package/lib/theme.js +318 -0
  49. package/lib/theme.js.map +1 -0
  50. package/lib/tool-present.js +11 -9
  51. package/lib/tool-present.js.map +1 -1
  52. package/lib/tui.js +535 -37
  53. package/lib/tui.js.map +1 -1
  54. package/lib/types/attach.d.ts +6 -2
  55. package/lib/types/auth-failure.d.ts +78 -0
  56. package/lib/types/commands.d.ts +9 -0
  57. package/lib/types/dialogs.d.ts +36 -0
  58. package/lib/types/display-mode.d.ts +99 -0
  59. package/lib/types/display-sock.d.ts +75 -0
  60. package/lib/types/footer.d.ts +1 -1
  61. package/lib/types/glyph-measure.d.ts +41 -0
  62. package/lib/types/index.d.ts +20 -0
  63. package/lib/types/plan.d.ts +5 -2
  64. package/lib/types/platform.d.ts +83 -0
  65. package/lib/types/session-blank.d.ts +51 -0
  66. package/lib/types/session-list.d.ts +34 -0
  67. package/lib/types/startup.d.ts +6 -0
  68. package/lib/types/subagent-model.d.ts +7 -6
  69. package/lib/types/term-text.d.ts +55 -23
  70. package/lib/types/terminal-input.d.ts +37 -0
  71. package/lib/types/theme.d.ts +109 -0
  72. package/lib/types/tool-present.d.ts +2 -2
  73. package/lib/types/tui.d.ts +121 -1
  74. package/package.json +92 -93
@@ -88,7 +88,9 @@ export interface AttacherDeps {
88
88
  /** Stop capturing and return the typing ('' when unsupported). */
89
89
  endCapture?(): string;
90
90
  inspectLiveHost(sessionId: string): Promise<LiveHost | undefined>;
91
- spawnHost(sessionId: string): SpawnedDisplayHost;
91
+ spawnHost(sessionId: string, options?: {
92
+ ambiguousWide?: boolean;
93
+ }): SpawnedDisplayHost;
92
94
  waitForDisplaySock(spawned: SpawnedDisplayHost): Promise<void>;
93
95
  /**
94
96
  * One already-formatted status line for stderr. `transient` marks a line the
@@ -136,7 +138,9 @@ export interface Attacher {
136
138
  seed?: string;
137
139
  }): Promise<void>;
138
140
  /** Attach to a live Host on this session, or spawn a fresh one and attach. */
139
- attachOrSpawn(sessionId: string, recover?: boolean): Promise<void>;
141
+ attachOrSpawn(sessionId: string, recover?: boolean, options?: {
142
+ ambiguousWide?: boolean;
143
+ }): Promise<void>;
140
144
  /** Recoveries recorded in the current burst window (tests). */
141
145
  readonly recoveries: number;
142
146
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Two failure classes that look alike in the transcript and need different fixes.
3
+ *
4
+ * 1. **Authentication** — `code: "AUTH"`, and the message says which side said
5
+ * no (below).
6
+ * 2. **Reasoning replay** — a 400 from a thinking-mode gateway that wants the
7
+ * previous turn's reasoning passed back; the harness drops it when it rebuilds
8
+ * a long multi-turn history. Reported upstream (deepseek-harness #1780, #231),
9
+ * so the advice is a workaround, not a fix.
10
+ *
11
+ * Where an authentication failure came from.
12
+ *
13
+ * A turn can die with `code: "AUTH"` for two very different reasons, and the
14
+ * difference decides what the reader should do:
15
+ *
16
+ * - **provider**: the request left the machine and the provider (or something
17
+ * between it and the model) rejected it. The local credential is present, so
18
+ * changing it is the wrong move — the usual causes are a transient upstream
19
+ * failure, a plan/quota limit, or a model the account may not call. Retrying
20
+ * is reasonable.
21
+ * - **local**: nothing usable is configured on this machine, so every request
22
+ * would fail the same way. Retrying changes nothing; the key has to be set.
23
+ *
24
+ * The host reports both as `AUTH`, and the only place the difference survives is
25
+ * the message body — which is why this is a text classifier and why it is kept
26
+ * pure: the caller resolves the actual credential and decides what to say.
27
+ *
28
+ * Observed bodies (2026-09-29, command-code):
29
+ * provider → `OpenAI API error (403): {"message":"Authentication failed.
30
+ * Please check your credentials.","type":"permission_error"}`
31
+ * provider → `403 {"type":"error","error":{"type":"permission_error",
32
+ * "message":"MODEL_NOT_IN_PLAN: …"}}`
33
+ * local → `dsh-llm: no API key for provider "x" (set X_API_KEY)`
34
+ * A *missing* credential on that gateway answered with a 401 and its own
35
+ * envelope (`{"success":false,"error":{"code":"UNAUTHORIZED"}}`), which is why
36
+ * an HTTP status alone cannot classify this: 401 and 403 both appear on the
37
+ * provider side.
38
+ *
39
+ * @module dsh-ssh-tui/auth-failure
40
+ */
41
+ /** Which side rejected the request. */
42
+ export type AuthFailureOrigin = 'provider' | 'local';
43
+ export interface AuthFailure {
44
+ origin: AuthFailureOrigin;
45
+ /** The HTTP status, when the message carried one. */
46
+ status?: number;
47
+ }
48
+ /** The HTTP status inside a host-formatted failure, if there is one. */
49
+ export declare function statusOf(message: string): number | undefined;
50
+ /**
51
+ * Classify one failure message.
52
+ * @param message - the failure text the host attached to the turn.
53
+ * @returns the origin (and status when known), or undefined when the message
54
+ * says nothing about authentication — an unrelated failure keeps its own row
55
+ * and gets no advice.
56
+ */
57
+ export declare function classifyAuthFailure(message: string): AuthFailure | undefined;
58
+ /**
59
+ * A thinking-mode gateway refusing a follow-up because the reasoning is missing.
60
+ *
61
+ * Seen on the 0.2.0-rc line with the command-code route and `effort: max`
62
+ * (five times in one long session, always on a *continuation*, never on the
63
+ * first request of a turn):
64
+ *
65
+ * {"type":"invalid_request_error","code":"invalid_request_error",
66
+ * "message":"The `reasoning_text` in the thinking mode must be passed back
67
+ * to the API."}
68
+ *
69
+ * Both a live probe and the upstream reports agree on the shape: the client is
70
+ * expected to send the previous assistant turn's reasoning items back, and the
71
+ * harness does not do that once a session grows long. Nothing the reader can do
72
+ * fixes the client, but two workarounds exist — no thinking on that route, or
73
+ * the same gateway's Anthropic-flavoured route, which carries signed thinking
74
+ * blocks the harness does replay.
75
+ * @param message - the failure text the host attached to the turn.
76
+ * @returns true when this is that failure.
77
+ */
78
+ export declare function isReasoningReplayFailure(message: string): boolean;
@@ -39,6 +39,15 @@ export declare const LOCAL_COMMANDS: readonly [{
39
39
  }, {
40
40
  readonly name: "disconnect";
41
41
  readonly key: "cmd.disconnect";
42
+ }, {
43
+ readonly name: "retryauth";
44
+ readonly key: "cmd.retryauth";
45
+ }, {
46
+ readonly name: "theme";
47
+ readonly key: "cmd.theme";
48
+ }, {
49
+ readonly name: "cleanup";
50
+ readonly key: "cmd.cleanup";
42
51
  }, {
43
52
  readonly name: "notify";
44
53
  readonly key: "cmd.notify";
@@ -16,6 +16,15 @@ export interface ConfirmDialog {
16
16
  resolve(value: 'y' | 'n' | 'cancel'): void;
17
17
  }
18
18
  export interface QuestionDialog {
19
+ /**
20
+ * Called whenever the highlighted option moves.
21
+ *
22
+ * The theme picker uses it to paint the candidate palette immediately, so the
23
+ * choice is a preview rather than a list of names — the reader sees the
24
+ * transcript in the palette before committing to it. Nothing else sets it, and
25
+ * a dialog without it behaves exactly as before.
26
+ */
27
+ onCursor?: (cursor: number) => void;
19
28
  kind: 'questions';
20
29
  question: AskUserQuestionItem;
21
30
  index: number;
@@ -103,6 +112,33 @@ export declare function applyQuestionFilter(dialog: QuestionDialog): void;
103
112
  export declare function clearQuestionFilter(dialog: QuestionDialog): void;
104
113
  /** Move the question highlight, clamped to its options. Returns false if not a question dialog. */
105
114
  export declare function moveQuestionCursor(dialog: QuestionDialog, delta: number): boolean;
115
+ /**
116
+ * Which options a fresh question dialog starts with.
117
+ *
118
+ * The rule that caused a real mis-answer: a **multi-select** list used to inherit
119
+ * the single-select default of "the first option is already chosen", so a reader
120
+ * who moved the highlight to the row they wanted and pressed Enter answered with
121
+ * the first row instead — the `●` never left it. A multi-select starts empty
122
+ * unless the caller asks for a specific preselection; `questionSubmit` then falls
123
+ * back to the highlighted row, which is what pressing Enter means.
124
+ * @param question - the question being asked.
125
+ * @param preselected - an index the caller wants chosen up front, if any.
126
+ * @returns the indexes to select initially.
127
+ */
128
+ export declare function initialQuestionSelection(question: AskUserQuestionItem, preselected?: number): number[];
129
+ /**
130
+ * The circle drawn beside one option.
131
+ *
132
+ * `●` marks **what Enter will submit**, which is why it sits on the highlighted
133
+ * row while nothing is ticked — the mark follows the cursor, so what the reader
134
+ * sees is what the answer will be. A ticked row in a multi-select list carries
135
+ * `✓` instead: several rows can be chosen at once, and the circle cannot be in
136
+ * two places without lying about one of them.
137
+ * @param dialog - the open question dialog.
138
+ * @param index - the option index being drawn.
139
+ * @returns the marker for that row.
140
+ */
141
+ export declare function questionOptionMarker(dialog: QuestionDialog, index: number): '●' | '○' | '✓';
106
142
  /** Select option `index`: toggles in a multi-select list, replaces otherwise. */
107
143
  export declare function selectQuestionOption(dialog: QuestionDialog, index: number): void;
108
144
  /** Handle a hotkey: select its option and report whether it applied. */
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where the TUI's bytes go when the parent is not a terminal.
3
+ *
4
+ * The plugin paints through a display relay, and a relay normally *is* a
5
+ * terminal: it writes ANSI to stdout, listens for `resize`, and puts stdin in raw
6
+ * mode. A GUI, a browser terminal or a test harness has none of those, but it can
7
+ * provide the same three things over pipes — bytes in, bytes out, and a size it
8
+ * reports as a protocol frame. This module is the switch that says "the parent is
9
+ * such a relay, do not require a TTY".
10
+ *
11
+ * Two entries, one mechanism and one sugar (as chosen when the mode was
12
+ * designed):
13
+ *
14
+ * - `DSH_TUI_DISPLAY=stdio` in the environment — the mechanism. Setting it *is*
15
+ * the parent's statement that it is a relay, so it is also what the no-TTY
16
+ * guard consults.
17
+ * - `--display stdio` on the command line — the sugar, declared in the profile's
18
+ * own grammar so it shows up in `--help` and a typo is an error rather than a
19
+ * silently inert plugin.
20
+ *
21
+ * @module dsh-ssh-tui/display-mode
22
+ */
23
+ /** The environment variable that selects the display mode. */
24
+ export declare const DISPLAY_MODE_ENV = "DSH_TUI_DISPLAY";
25
+ /**
26
+ * `tty` is the default: paint to the process's own terminal. `stdio` means the
27
+ * parent speaks the display protocol on stdin/stdout.
28
+ */
29
+ export type DisplayMode = 'tty' | 'stdio';
30
+ /** A mode request, and what to say when the value was not one of the two. */
31
+ export interface DisplayRequest {
32
+ mode?: DisplayMode;
33
+ /** The unrecognized value, when one was given. */
34
+ invalid?: string;
35
+ }
36
+ /**
37
+ * Parse one mode word.
38
+ * @param value - a value from the environment or a flag.
39
+ * @returns the mode, or undefined for anything else (the caller reports it).
40
+ */
41
+ export declare function parseDisplayMode(value: string | undefined): DisplayMode | undefined;
42
+ /**
43
+ * The `--display <mode>` value in an argument vector.
44
+ *
45
+ * Accepts both spellings a person may type (`--display stdio`,
46
+ * `--display=stdio`) and only ever reads the pair it owns — everything else is
47
+ * left where it is, because the same vector is parsed by the profile's own
48
+ * grammar and by the host.
49
+ * @param argv - an argument vector (defaults to this process's).
50
+ * @returns the value found, if any.
51
+ */
52
+ export declare function displayModeFromArgv(argv?: readonly string[]): string | undefined;
53
+ /**
54
+ * Resolve the mode request from the environment and the command line.
55
+ *
56
+ * The environment wins when both are present: it is the mechanism, and a parent
57
+ * that sets it is describing the channel it is about to speak on, whereas a flag
58
+ * is a convenience the reader may have left in a wrapper script.
59
+ * @param env - the environment to read.
60
+ * @param argv - the argument vector to read.
61
+ * @returns the requested mode, plus the offending value when one was useless.
62
+ */
63
+ export declare function requestedDisplayMode(env?: NodeJS.ProcessEnv, argv?: readonly string[]): DisplayRequest;
64
+ /**
65
+ * A window-size report: `CSI 8 ; rows ; cols t`.
66
+ *
67
+ * This is the sequence a terminal sends when it is asked for its size, and it is
68
+ * also how a pipe parent says "the panel is now this big". A pipe has no
69
+ * `resize` event and no SIGWINCH, so without this the embedder's only way to
70
+ * resize the TUI would be to restart it.
71
+ */
72
+ export declare const WINDOW_SIZE_REPORT: RegExp;
73
+ /** A size a parent declared: columns first, the way the rest of the code wants it. */
74
+ export interface ParentResize {
75
+ columns: number;
76
+ rows: number;
77
+ }
78
+ /**
79
+ * Split a parent's input into keystrokes and size reports.
80
+ *
81
+ * A report can arrive split across two reads (`ESC [ 8 ; 2` then `4 ; 80 t`),
82
+ * and forwarding half of it as typing would send escape bytes to the model's
83
+ * prompt. The tail is held until the next read, exactly like the cursor-reply
84
+ * filter holds a half-arrived reply — with the same deadline that filter gets:
85
+ * a lone `ESC` matches the prefix, and the byte after it may never come, so a
86
+ * caller that forwards typing **must** release a {@link pending} run when its
87
+ * window passes (`flush()`). Waiting for the next read instead swallows the
88
+ * user's Escape key, and delivers it glued to whatever key follows as an Alt
89
+ * chord. `TerminalInputGuard` is the same filter with that timer attached.
90
+ * @returns a filter for one input stream.
91
+ */
92
+ export declare function createParentResizeFilter(): {
93
+ push(text: string): {
94
+ forward: string;
95
+ sizes: ParentResize[];
96
+ };
97
+ flush(): string;
98
+ readonly pending: boolean;
99
+ };
@@ -13,6 +13,15 @@ export declare const FRAME_PROBE_REPLY = 9;
13
13
  export declare const FRAME_QUERY = 10;
14
14
  /** Host → prober: the answer to {@link FRAME_QUERY}. */
15
15
  export declare const FRAME_QUERY_REPLY = 11;
16
+ /**
17
+ * Relay → host: what this terminal does with the ambiguous glyphs.
18
+ *
19
+ * Payload: one byte, bit 0 = the terminal advances two cells, bit 1 = the second
20
+ * cell has to be reserved by us with a space. Sent right after HELLO, because the
21
+ * Host decides every width in the session and it is the relay — not the Host —
22
+ * that owns the terminal to measure on.
23
+ */
24
+ export declare const FRAME_METRICS = 12;
16
25
  /**
17
26
  * What a relay's own terminal round trip said.
18
27
  *
@@ -105,6 +114,24 @@ export declare function sessionBootstrapPidPath(sessionId: string, dshHome?: str
105
114
  /** Pre-digest Host stderr log next to the 0.7.1 socket, when that name differs. */
106
115
  export declare function legacySessionErrPath(sessionId: string, dshHome?: string, platform?: NodeJS.Platform): string | undefined;
107
116
  export declare function encodeFrame(type: number, payload?: Buffer): Buffer;
117
+ /**
118
+ * The size a relay should report.
119
+ *
120
+ * A terminal knows its own size; a parent that speaks the protocol on pipes is
121
+ * not a terminal, so it declares one through `COLUMNS`/`LINES` — the convention
122
+ * every shell and TUI already shares — and corrects it later with a resize frame
123
+ * if the window changes. Without either, the historical 80×24 stands.
124
+ * @param stdout - the stream that may be a terminal.
125
+ * @param env - the environment to read.
126
+ * @returns the columns and rows to send as the first resize.
127
+ */
128
+ export declare function relayTerminalSize(stdout?: {
129
+ columns?: number;
130
+ rows?: number;
131
+ }, env?: NodeJS.ProcessEnv): {
132
+ columns: number;
133
+ rows: number;
134
+ };
108
135
  export declare function encodeResize(columns: number, rows: number): Buffer;
109
136
  export declare function decodeResize(payload: Buffer): {
110
137
  columns: number;
@@ -115,6 +142,16 @@ export declare function decodeRtt(payload: Buffer): number | undefined;
115
142
  export declare function encodeProbe(): Buffer;
116
143
  export declare function encodeProbeReply(verdict: TerminalVerdict): Buffer;
117
144
  export declare function decodeProbeReply(payload: Buffer): TerminalVerdict;
145
+ /** Encode {@link FRAME_METRICS}. */
146
+ export declare function encodeMetrics(metrics: {
147
+ wide: boolean;
148
+ reserve: boolean;
149
+ }): Buffer;
150
+ /** Decode {@link FRAME_METRICS}; undefined when the payload is not one byte. */
151
+ export declare function decodeMetrics(payload: Buffer): {
152
+ wide: boolean;
153
+ reserve: boolean;
154
+ } | undefined;
118
155
  export declare function encodeQuery(): Buffer;
119
156
  export declare function encodeQueryReply(verdict: AttachmentVerdict): Buffer;
120
157
  export declare function decodeQueryReply(payload: Buffer): AttachmentVerdict;
@@ -130,6 +167,11 @@ export interface DisplayHostHandlers {
130
167
  onStdin(bytes: Buffer): void;
131
168
  onResize(columns: number, rows: number): void;
132
169
  onRtt?(rttMs: number | undefined): void;
170
+ /** The relay measured this terminal's ambiguous glyphs; adopt the verdict. */
171
+ onMetrics?(metrics: {
172
+ wide: boolean;
173
+ reserve: boolean;
174
+ }): void;
133
175
  onDetach(info?: {
134
176
  replaced?: boolean;
135
177
  }): void;
@@ -183,6 +225,18 @@ export declare class DisplayHost {
183
225
  private settleProbe;
184
226
  sendStdout(bytes: Buffer | string): boolean;
185
227
  sendGoodbye(): void;
228
+ /**
229
+ * Say goodbye and let the frame leave before the socket is reaped.
230
+ *
231
+ * The frame is what tells the launcher to exit instead of reading the close as
232
+ * a crash and re-attaching (`attach.ts`) — the replacement path flushes
233
+ * `FRAME_REPLACED` for exactly the same reason. A write only *queues* on libuv
234
+ * until the peer drains it, and reaping the socket cancels what is still
235
+ * queued: on a busy link, with a starting session still painting into that
236
+ * socket, the goodbye was dropped along with those frames and the launcher
237
+ * re-attached over the user's own `/exit` instead of exiting.
238
+ */
239
+ private farewellAndReap;
186
240
  close(): Promise<void>;
187
241
  }
188
242
  /**
@@ -274,8 +328,29 @@ export declare function quietTerminalInput(stdin?: NodeJS.ReadStream): number;
274
328
  * written into a link that may already be dead.
275
329
  */
276
330
  export declare function restoreTerminalInput(stdin?: NodeJS.ReadStream): void;
331
+ /**
332
+ * The environment a detached Host is started with.
333
+ *
334
+ * Notable entries: the marker that tells the child it *is* the Host, and the
335
+ * ambiguous-width decision. The Host paints, but its own stdout is the relay
336
+ * socket, so it cannot look at a TTY to know how wide the window's terminal
337
+ * draws `①` or `—`; this process has that terminal, so it decides and hands the
338
+ * answer over. An explicit value in the environment still wins.
339
+ * @param env - the parent environment (a test passes its own).
340
+ * @param onTerminal - whether *this* process owns a terminal; it is the evidence
341
+ * the width table is chosen from, and a test harness is not a terminal.
342
+ * @returns the child environment.
343
+ */
344
+ export declare function hostChildEnv(env?: NodeJS.ProcessEnv, onTerminal?: boolean, measuredAmbiguousWide?: boolean): NodeJS.ProcessEnv;
277
345
  /** Test seam for {@link spawnDetachedHost}; production passes nothing. */
278
346
  export interface SpawnHostOptions {
347
+ /**
348
+ * What the launcher measured for the ambiguous glyphs, when it measured.
349
+ *
350
+ * The Host paints every frame with a socket for stdout, so it cannot ask the
351
+ * terminal anything; the answer travels in its environment instead.
352
+ */
353
+ ambiguousWide?: boolean;
279
354
  /**
280
355
  * Start the Host through this command instead of resolving the real one, or
281
356
  * `null` to spawn it directly even where a bootstrap exists.
@@ -181,7 +181,7 @@ export declare function subagentRouteLabel(model: string, provider?: string, eff
181
181
  * The mute (`90`) is reopened after the chip so the rest of the identity row
182
182
  * stays dim.
183
183
  */
184
- export declare function paintFooterSubagentChip(line: string, chip: string, foreign: boolean, muteSgr?: string, depth?: ColorDepth): string;
184
+ export declare function paintFooterSubagentChip(line: string, chip: string, foreign: boolean, muteSgr?: string, depth?: ColorDepth, identitySgr?: string): string;
185
185
  export declare function footerSubagentForeign(input: Pick<FooterStatusInput, 'provider' | 'subProvider'>): boolean;
186
186
  /**
187
187
  * The model as the footer shows it: the model's own name, without a
@@ -0,0 +1,41 @@
1
+ import { TerminalInputPump } from './terminal-input.js';
2
+ /**
3
+ * The glyphs the probe prints.
4
+ *
5
+ * Drawn from the families {@link AMBIGUOUS_WIDE_RANGES} covers — an enclosed
6
+ * digit, a dash, an ellipsis, a curly quote, a middle dot, a bullet, a roman
7
+ * numeral. Mixing families is deliberate: a font may cover `①` from a fallback
8
+ * face while treating `—` as typographic punctuation, and a single-glyph probe
9
+ * would then decide the whole table from one sample. A terminal that treats the
10
+ * ambiguous block as full width spends two cells on all seven.
11
+ */
12
+ export declare const GLYPH_PROBE_TEXT = "\u2460\u2014\u2026\u201C\u201D\u00B7\u2022\u2160";
13
+ export interface GlyphWidthMeasurement {
14
+ /** True when the terminal spent two cells per probe glyph. */
15
+ wide: boolean;
16
+ /** Cells the terminal reported for the whole probe string. */
17
+ cells: number;
18
+ }
19
+ /**
20
+ * Measure the ambiguous-glyph width on a real terminal.
21
+ *
22
+ * Returns `undefined` — never a guess — when there is no terminal, when it does
23
+ * not answer, or when the answer matches neither expectation (a font with mixed
24
+ * metrics: the caller then keeps its locale-based default rather than inventing a
25
+ * table from one sample).
26
+ *
27
+ * The round trip goes through {@link TerminalInputPump.askPosition}, which
28
+ * swallows the reply. That is not an implementation detail: a cursor reply that
29
+ * nobody consumes is echoed on screen as literal `^[[1;5R` text, and a
30
+ * hand-rolled version of this probe did exactly that at boot.
31
+ * @param options - the streams (or a pump already running on them), the probe
32
+ * text, and the reply budget.
33
+ * @returns what the terminal said, or undefined.
34
+ */
35
+ export declare function measureAmbiguousGlyphWidth(options?: {
36
+ stdin?: NodeJS.ReadStream;
37
+ stdout?: NodeJS.WriteStream;
38
+ probe?: string;
39
+ timeoutMs?: number;
40
+ pump?: TerminalInputPump;
41
+ }): Promise<GlyphWidthMeasurement | undefined>;
@@ -36,6 +36,8 @@ export interface Config {
36
36
  */
37
37
  /** UI language (`/language`); zh unless the environment says otherwise. */
38
38
  language?: string;
39
+ /** Palette (`/theme`): default | catppuccin | gruvbox | mono. */
40
+ theme?: string;
39
41
  /** Newest plugin version whose update notice was dismissed. */
40
42
  skipUpdate?: string;
41
43
  /** Workspace pane layout (`/view`). */
@@ -46,10 +48,26 @@ export interface Config {
46
48
  autoApproval?: string;
47
49
  /** Milliseconds a leftover finished Host waits before exiting; 0 = never. */
48
50
  idleExit?: number;
51
+ /**
52
+ * Retry once when the *provider* rejects a request with 401/403 while a local
53
+ * credential is configured (see `auth-failure.ts`). Off by default: the retry
54
+ * re-sends the whole context, which in a long session is expensive.
55
+ */
56
+ retryProviderAuth?: boolean;
57
+ /**
58
+ * Fail loudly instead of staying inert when there is no terminal.
59
+ *
60
+ * The default (`false`) logs one line and mounts nothing — that is what keeps
61
+ * this plugin harmless in a GUI host with no console (the desktop app) and in
62
+ * pipes. Set it when a missing terminal *is* an error for the caller: a script
63
+ * asserting that a terminal profile really starts, for one.
64
+ */
65
+ requireTerminal?: boolean;
49
66
  }
50
67
  /** Every field above, as schemastery resolves them (all optional). */
51
68
  interface ConfigFields {
52
69
  sessionId?: string;
70
+ requireTerminal?: boolean;
53
71
  showReasoning?: boolean;
54
72
  maxToolOutputLines?: number;
55
73
  color?: boolean;
@@ -60,11 +78,13 @@ interface ConfigFields {
60
78
  model?: string;
61
79
  paintIntervalMs?: number;
62
80
  language?: string;
81
+ theme?: string;
63
82
  skipUpdate?: string;
64
83
  view?: string;
65
84
  disconnect?: string;
66
85
  autoApproval?: string;
67
86
  idleExit?: number;
87
+ retryProviderAuth?: boolean;
68
88
  }
69
89
  /**
70
90
  * The entry's schema. Everything a launch supplies (`config:` in
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Plan dock, todo lists, /find, prompt-injection cards, and compact errors.
3
3
  */
4
+ import { type Theme } from './theme.js';
4
5
  import { type TextSegment } from './term-text.js';
5
6
  import type { DiffDisplayLine, DisplayKind, PlanTodoItem, Row, SubagentLogEntry } from './transcript-types.js';
6
7
  export declare const MAX_SUBAGENT_LOGS = 80;
@@ -83,10 +84,10 @@ export declare function askSummary(value: unknown): string;
83
84
  export declare function firstDisplayLine(text: string): string;
84
85
  /** Collapse a child-session blob to one short chip/wait-card line. */
85
86
  export declare function clipSubagentActivity(text: string, maxChars?: number): string;
86
- /** Running / ok / aborted / error → ANSI for the status dot and status word. */
87
+ /** Running / ok / aborted / error → theme token for the status dot and word. */
87
88
  export declare function subagentStateColor(status: Extract<Row, {
88
89
  kind: 'subagent';
89
- }>['status']): '33' | '32' | '31' | '90';
90
+ }>['status'], theme?: Theme): string;
90
91
  /**
91
92
  * Rebuild a subagent chip from the parent spawn tool call that survives in the
92
93
  * session log. Live `subagent/start` is not replayed, so resume would otherwise
@@ -138,6 +139,8 @@ export declare function buildSubagentHeader(input: {
138
139
  inspectHint?: string;
139
140
  /** Different provider from the parent: paint the title cyan, not violet. */
140
141
  foreign?: boolean;
142
+ /** Active palette; callers pass the session's theme so `mono` stays monochrome. */
143
+ theme?: Theme;
141
144
  }): {
142
145
  plain: string;
143
146
  segments: TextSegment[];
@@ -235,3 +235,86 @@ export declare function restrictPathToUserSync(path: string, options?: {
235
235
  * @returns true when chrome must be drawn in ASCII.
236
236
  */
237
237
  export declare function asciiFallbackEnabled(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
238
+ /**
239
+ * What this process can serve.
240
+ *
241
+ * The plugin is a *terminal* UI, and the host it is mounted into decides whether
242
+ * there is a terminal at all:
243
+ *
244
+ * - `host-relay`: this is the detached TUI Host itself (see
245
+ * `isTuiHostProcess`); its terminal lives in whichever window attaches to the
246
+ * session, so a missing TTY here is expected and it must serve.
247
+ * - `terminal`: a real TTY on stdin and stdout.
248
+ * - `desktop`: the desktop Harness launcher (Electron-as-Node, GUI subsystem).
249
+ * It never has a console, so no terminal profile can ever run under it.
250
+ * - `no-tty`: anything else without a TTY — a pipe (`… | tee`), cron, a service.
251
+ */
252
+ export type TerminalAvailability = 'host-relay' | 'terminal' | 'desktop' | 'no-tty';
253
+ /**
254
+ * Decide what to serve, from facts the caller can state.
255
+ *
256
+ * Pure on purpose: this is the one branch that decides whether the plugin mounts
257
+ * anything at all, and every caller-visible behaviour difference (a window, a
258
+ * log line, a hard failure) hangs off it.
259
+ * @param facts - the host process marker, the two TTY answers, and whether the
260
+ * launcher is the desktop's.
261
+ * @returns the availability bucket.
262
+ */
263
+ export declare function terminalAvailability(facts: {
264
+ hostProcess: boolean;
265
+ stdinIsTty: boolean;
266
+ stdoutIsTty: boolean;
267
+ desktop: boolean;
268
+ }): TerminalAvailability;
269
+ /**
270
+ * Whether this process is the desktop app's launcher rather than a terminal Node.
271
+ *
272
+ * The desktop Harness ships a PATH shim that runs its own GUI-subsystem binary
273
+ * with `ELECTRON_RUN_AS_NODE=1` (`DeepSeek Harness.exe --expose-internals
274
+ * app.asar/dsh/.../cli.js`). A GUI-subsystem process started from a console does
275
+ * not attach to it on Windows, so `process.stdin.isTTY` / `stdout.isTTY` stay
276
+ * undefined and no terminal profile can start under it. That is a property of
277
+ * the launcher, not of the user's terminal, and the two cases deserve different
278
+ * advice — which is all this decides.
279
+ * @param facts - the runtime's own answers (`process.versions`, `execPath`, `argv`).
280
+ * @returns true when the launcher is the desktop app's Electron-as-Node shim.
281
+ */
282
+ export declare function desktopLauncher(facts: {
283
+ electron?: string | undefined;
284
+ execPath?: string | undefined;
285
+ argv?: readonly string[] | undefined;
286
+ }): boolean;
287
+ /**
288
+ * The message a terminal profile fails with when the launcher has no TTY.
289
+ *
290
+ * The desktop case is worth its own wording: the generic "must be TTYs" line
291
+ * sends a desktop user hunting for a broken terminal, while the real answer is
292
+ * "this launcher never has one — use the npm CLI in a terminal/SSH session, and
293
+ * note the desktop app does not need this plugin at all".
294
+ * @param desktop - whether {@link desktopLauncher} recognised the launcher.
295
+ * @returns the error text for the plugin to throw.
296
+ */
297
+ export declare function nonTtyErrorMessage(desktop: boolean): string;
298
+ /**
299
+ * What to log when the plugin has nowhere to draw.
300
+ *
301
+ * Deliberately not an error: on the desktop the situation is normal (a GUI app
302
+ * has no console), and a terminal profile is not a thing the user broke. The
303
+ * line has to (a) say nothing is running *from this plugin*, (b) explain why,
304
+ * (c) give the one command that fixes it, and (d) not read as a failure — the
305
+ * plugin used to throw here, which surfaced as "1 entry did not activate" and,
306
+ * on the desktop, took the app down with it.
307
+ * @param availability - `desktop` or `no-tty`.
308
+ * @returns the log line.
309
+ */
310
+ export declare function inactiveNotice(availability: 'desktop' | 'no-tty'): string;
311
+ /**
312
+ * The message for the opt-in hard failure (`requireTerminal: true`).
313
+ *
314
+ * Same two situations as {@link inactiveNotice}, but for callers who would
315
+ * rather a profile fail loudly than quietly do nothing — a script asserting that
316
+ * a terminal profile really starts, for one.
317
+ * @param desktop - whether the launcher is the desktop's.
318
+ * @returns the error text.
319
+ */
320
+ export declare function requiredTerminalError(desktop: boolean): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What makes a session "blank": a launch that never did anything.
3
+ *
4
+ * A fresh TUI start creates a session before the reader has typed anything, so
5
+ * quitting straight away leaves an artifact behind. The picker hides those, but
6
+ * they are real files, and other surfaces (the web session list) read the same
7
+ * artifacts without that filter — which is how a session that was never used
8
+ * still shows up in another profile's menu.
9
+ *
10
+ * The rule lives in its own module, with no host imports, for one reason: three
11
+ * very different callers have to agree on it — the picker (which prunes while
12
+ * listing), the TUI (which drops its own session on exit), and
13
+ * `scripts/prune-blank-sessions.mjs` (which sweeps whatever already accumulated,
14
+ * outside a running host).
15
+ *
16
+ * @module dsh-ssh-tui/session-blank
17
+ */
18
+ /** Whether one event is a message the reader typed. */
19
+ export declare function isUserMessageEvent(event: unknown): boolean;
20
+ /**
21
+ * Whether a turn was opened and never closed.
22
+ *
23
+ * A session with an open turn is mid-work: it must never be treated as empty,
24
+ * however little it looks like it has done.
25
+ * @param events - decoded session events, in order.
26
+ * @returns true when a `turn/start` has no matching `turn/end`.
27
+ */
28
+ export declare function hasUnfinishedTurn(events: readonly unknown[]): boolean;
29
+ /**
30
+ * Whether the session ever produced a reply.
31
+ *
32
+ * A failed request counts: the error text is the only thing the reader has to
33
+ * look at, and deleting it would delete their evidence.
34
+ * @param events - decoded session events, in order.
35
+ * @returns true when an assistant message or a failed turn is present.
36
+ */
37
+ export declare function sessionHasReply(events: readonly unknown[]): boolean;
38
+ /**
39
+ * Whether a session saw the reader's input, a reply, or an open turn.
40
+ * @param events - decoded session events, in order.
41
+ * @returns true when there is something worth keeping.
42
+ */
43
+ export declare function sessionHasWork(events: readonly unknown[]): boolean;
44
+ /**
45
+ * Whether a session's events describe a launch that never did anything.
46
+ * @param events - the session's decoded events.
47
+ * @returns true when nothing the reader would recognise as work happened. An
48
+ * empty event list is *not* blank: it is an unreadable or detached log, and
49
+ * guessing there once made a listing delete a live session's directory.
50
+ */
51
+ export declare function sessionEventsAreBlank(events: readonly unknown[]): boolean;