dsh-ssh-tui 0.8.0 → 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 +34 -0
  2. package/README.md +435 -577
  3. package/docs/display-mode.md +122 -0
  4. package/docs/remote-ops.md +51 -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 +36 -0
  9. package/lib/auth-failure.js.map +1 -1
  10. package/lib/commands.js +2 -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 +18 -2
  23. package/lib/i18n/en.js.map +1 -1
  24. package/lib/i18n/zh.js +18 -2
  25. package/lib/i18n/zh.js.map +1 -1
  26. package/lib/index.js +73 -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 +420 -38
  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 +30 -0
  56. package/lib/types/commands.d.ts +6 -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 +13 -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 +98 -1
  74. package/package.json +68 -66
@@ -44,13 +44,14 @@ export declare function canonicalProviderId(provider: string | undefined): strin
44
44
  */
45
45
  export declare function subagentProviderDiffers(parent: string | undefined, child: string | undefined): boolean;
46
46
  /**
47
- * Identity SGR for a subagent chip title. Same provider as the parent stays
48
- * the violet used since the courtesy-name work; a different provider uses
49
- * cyan so the foreign route is visible without a second color layer.
47
+ * Which identity role a subagent chip title wears.
48
+ *
49
+ * The colours themselves live in `theme.ts`: the same provider as the parent
50
+ * gets the identity role, a different provider gets the foreign one, and the
51
+ * palette decides what those look like (under `mono` they become an attribute,
52
+ * because the distinction has to survive a terminal without colour).
50
53
  */
51
- export declare const SUBAGENT_IDENTITY_SGR = "38;5;141";
52
- export declare const SUBAGENT_FOREIGN_SGR = "38;5;80";
53
- export declare function subagentIdentitySgr(foreign: boolean): typeof SUBAGENT_IDENTITY_SGR | typeof SUBAGENT_FOREIGN_SGR;
54
+ export declare function subagentIdentityRole(foreign: boolean): 'subagent-self' | 'subagent-foreign';
54
55
  /**
55
56
  * True when the stored subagent model still belongs to the parent provider
56
57
  * family. An explicit leftover DeepSeek flash id after switching to xAI is
@@ -53,25 +53,65 @@ export declare function resetAsciiChrome(): void;
53
53
  */
54
54
  export declare function mapAsciiChrome(text: string): string;
55
55
  /**
56
- * Terminal cell width for one string.
56
+ * Resolve the ambiguous-glyph policy for this process.
57
57
  *
58
- * Match glibc wcwidth / typical UTF-8 SSH terminals: CJK ideographs and
59
- * fullwidth forms occupy two cells; East-Asian Ambiguous box-drawing and
60
- * ornaments (`─`, `●`, `·`, `▸`, `❯`, Braille spinners) occupy one. Counting
61
- * those ambiguous glyphs as two made `repeatToWidth('─', cols)` paint a
62
- * half-width rule and parked the input cursor half a cell past the text.
58
+ * Cheap enough to call once per line (it compares one string) and far too
59
+ * expensive to call per character, which is the distinction that matters.
60
+ * @param env - the environment to read.
61
+ * @param onTerminal - whether the output is a terminal at all.
62
+ * @returns whether the glyphs are two cells wide, and whether the second cell is
63
+ * ours to reserve.
64
+ */
65
+ export declare function ambiguousPolicy(env?: NodeJS.ProcessEnv, onTerminal?: boolean): {
66
+ wide: boolean;
67
+ reserve: boolean;
68
+ };
69
+ /**
70
+ * Record what the terminal's own answer said about these glyphs.
63
71
  *
64
- * Emoji-bearing symbols are the exception: a terminal with an emoji font draws
65
- * a symbol the monospace font lacks with a colour glyph that is wider than the
66
- * cell it advances, so they are budgeted two cells. {@link pinEmojiCells} makes
67
- * the terminal actually spend both: VS15 asks for the narrow text form, and a
68
- * reserving space clears the second cell when the run does not already end in
69
- * one. Budgeting two cells without clearing the second is what left `✖`
70
- * overlapping the `|` beside it while every `▶` row came up a cell short.
72
+ * Called by the launcher after measuring; also the escape hatch for a caller
73
+ * that knows better than the locale (a relay that has already measured for its
74
+ * own accounting, or a test).
75
+ * @param wide - true when the terminal advances two cells, false for one, or
76
+ * undefined to fall back to the locale.
77
+ */
78
+ export declare function setAmbiguousWidthMeasured(wide: boolean | undefined): void;
79
+ /**
80
+ * Force the reserve behaviour, for a caller that measured the same terminal for
81
+ * its own accounting (a relay) or a test.
82
+ * @param reserve - whether to spend a space after each ambiguous glyph.
83
+ */
84
+ export declare function setAmbiguousWidthReserve(reserve: boolean): void;
85
+ /** Whether the second cell is currently reserved with a space. */
86
+ export declare function ambiguousWidthReserved(): boolean;
87
+ /**
88
+ * Cells one character costs in this TUI's layout.
71
89
  *
72
- * Overflow into the input box is handled by clipping/padding painted rows to
73
- * the measured column count, not by inflating glyph width.
90
+ * The reserve case still costs two: the glyph advances one cell and the reserving
91
+ * space takes the next, so the layout must budget both or every row holding one
92
+ * comes up short. Non-ambiguous characters are unchanged.
93
+ * @param cp - the code point.
94
+ * @returns 0, 1 or 2 cells.
74
95
  */
96
+ export declare function ambiguousCellCost(cp: number): number;
97
+ /** What the last measurement decided, for diagnostics and tests. */
98
+ export declare function ambiguousWidthMeasured(): boolean | undefined;
99
+ /**
100
+ * Whether ambiguous glyphs in {@link AMBIGUOUS_WIDE_RANGES} are drawn two cells
101
+ * wide here.
102
+ *
103
+ * `DSH_TUI_AMBIGUOUS_WIDTH=1|2` answers outright. Otherwise the locale decides,
104
+ * and only when there really is a terminal: a zh/ja/ko locale means the terminal
105
+ * is very likely using a CJK font, where these glyphs are full width. The UI
106
+ * language is deliberately *not* consulted — a Chinese reader on a Western
107
+ * terminal has narrow glyphs, and typing in Chinese does not change the font
108
+ * metrics.
109
+ * @param env - the environment to read (tests pass their own).
110
+ * @param onTerminal - whether the output is a terminal at all; a pipe or a test
111
+ * harness has no font metrics, so there the narrow default applies.
112
+ * @returns true when those glyphs should be budgeted two cells.
113
+ */
114
+ export declare function ambiguousWidthIsTwo(env?: NodeJS.ProcessEnv, onTerminal?: boolean): boolean;
75
115
  export declare function displayWidth(text: string): number;
76
116
  /**
77
117
  * Make a painted row spend the two cells {@link displayWidth} budgets for every
@@ -201,14 +241,6 @@ export interface InputView {
201
241
  cursorOffset: number;
202
242
  folded: boolean;
203
243
  }
204
- /**
205
- * Fold a long input into one terminal row around the cursor.
206
- *
207
- * Newlines from a paste are display-only: they do not occupy cells, so a
208
- * naive `displayWidth(input)` under-counts a multi-line paste and parks the
209
- * caret in the middle of later text. Fold the *current line* (between the
210
- * surrounding newlines) and keep `\n` out of the visible slice.
211
- */
212
244
  export declare function foldInputView(input: string, cursor: number, maxWidth: number): InputView;
213
245
  /**
214
246
  * Map a character index in the input text to its visual (row, col) after the
@@ -28,6 +28,7 @@ export declare const RTT_OUTLIER_RATIO = 0.25;
28
28
  export declare function stripCursorReplies(text: string): {
29
29
  text: string;
30
30
  replies: number;
31
+ last?: string;
31
32
  };
32
33
  /**
33
34
  * Drop cursor replies from a byte stream that is otherwise user input.
@@ -38,6 +39,8 @@ export declare function stripCursorReplies(text: string): {
38
39
  */
39
40
  export declare class TerminalInputFilter {
40
41
  private held;
42
+ /** The most recent cursor reply this filter swallowed, if any. */
43
+ lastReply: string | undefined;
41
44
  push(text: string): {
42
45
  forward: string;
43
46
  replies: number;
@@ -103,10 +106,26 @@ export declare class TerminalInputPump {
103
106
  private slowestSampleMs;
104
107
  private holdTimer;
105
108
  private listening;
109
+ /** Set by {@link quiet}: the held tail is dropped instead of forwarded. */
110
+ private dropping;
106
111
  readonly ssh: boolean;
107
112
  constructor(options: TerminalInputPumpOptions);
108
113
  start(): void;
109
114
  stop(): void;
115
+ /**
116
+ * Wait one more round trip before letting go of the terminal.
117
+ *
118
+ * A cursor reply can still be on the wire when a relay stops. The terminal
119
+ * answers a Device Status Report after the round trip, so on a slow SSH link
120
+ * that is hundreds of milliseconds after the request; restoring cooked mode
121
+ * first lets the tty echo the answer as literal `^[[25;1R` text at the user's
122
+ * prompt, and leaves it in the tty queue for the shell to read as typing.
123
+ * Staying in raw mode for one round trip swallows it instead. Typing that
124
+ * lands in this window is forwarded as usual — the window exists to drop
125
+ * answers, not keystrokes.
126
+ * @param graceMs - how long the terminal may still owe us a reply.
127
+ */
128
+ handBack(graceMs?: number): Promise<void>;
110
129
  /**
111
130
  * Ask the terminal for its cursor a few times and report the round-trip that
112
131
  * best describes the link. `undefined` means the terminal never answered in
@@ -170,5 +189,23 @@ export declare class TerminalInputPump {
170
189
  * from being attributed to the next request.
171
190
  */
172
191
  private answer;
192
+ /**
193
+ * Ask the terminal for its cursor after printing `probe`, and report where it
194
+ * says the cursor ended up.
195
+ *
196
+ * This is the measurement half of the pump: `measure()` answers "how long did
197
+ * the round trip take", this answers "how far did the cursor move". Replies are
198
+ * swallowed by the same filter that swallows them for the timing probe — which
199
+ * matters, because a reply nobody consumes is echoed on screen as literal
200
+ * `^[[1;5R` text, and that is exactly what a hand-rolled version of this did.
201
+ * @param probe - the text to print before asking (cleared afterwards).
202
+ * @param timeoutMs - how long to wait for the answer.
203
+ * @returns the coordinates the terminal reported, or undefined when it stayed
204
+ * silent.
205
+ */
206
+ askPosition(probe: string, timeoutMs?: number): Promise<{
207
+ row: number;
208
+ column: number;
209
+ } | undefined>;
173
210
  private scheduleHold;
174
211
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The TUI's colour system: semantic roles, and the palettes that fill them.
3
+ *
4
+ * Three rules shape this file, and each one came from a product that had to
5
+ * learn it the hard way:
6
+ *
7
+ * 1. **Primary text takes the terminal's own foreground.** Forcing `37` looks
8
+ * right on a dark terminal and is unreadable on a light one; Codex's own TUI
9
+ * style guide says "most of the time, just use the default foreground colour"
10
+ * and warns against unchecked custom foregrounds. So assistant text and tool
11
+ * output carry *no* colour token at all.
12
+ * 2. **A theme is a role table, not a palette.** Callers ask for `error`, not for
13
+ * red — that is what lets the monochrome theme keep bold/underline while it
14
+ * drops colour, and what lets a low-colour terminal (`color-depth.ts`)
15
+ * downgrade by hue family without any theme knowing about it.
16
+ * 3. **A theme must survive 8 colours and no colour.** Every token here is
17
+ * emitted as ordinary SGR and passes through `downgradeSgr`, so a `38;2;…`
18
+ * token on a 256- or 16-colour terminal becomes the nearest hue its palette
19
+ * has. `mono` goes further and never emits colour at all — the shape of the
20
+ * line (bold, dim, underline) is what carries meaning, which is exactly what
21
+ * Gemini CLI's first-class `no-color` theme does.
22
+ *
23
+ * @module dsh-ssh-tui/theme
24
+ */
25
+ import type { DisplayKind } from './transcript-types.js';
26
+ /**
27
+ * Roles that are not transcript rows: subagent chips, warning glyphs, links.
28
+ *
29
+ * They live here rather than as constants beside their callers for one reason: a
30
+ * colour literal anywhere outside this module is a colour the palettes cannot
31
+ * reach, and `tests/theme.test.mjs` fails over exactly that.
32
+ */
33
+ export type ExtraRole = 'subagent-self' | 'subagent-foreign' | 'warn' | 'link' | 'accent' | 'notice' | 'md-bold' | 'md-italic' | 'md-code' | 'md-link' | 'md-muted' | 'md-h1' | 'md-h2' | 'md-h3' | 'md-quote' | 'md-rule' | 'tool-running' | 'tool-ok';
34
+ /** One palette. Tokens are SGR parameter strings, without the escape. */
35
+ export interface Theme {
36
+ /** Key used by `/theme`, `DSH_TUI_THEME`, and the `theme` setting. */
37
+ name: string;
38
+ /** Order in `/theme` output; lower is closer to the original look. */
39
+ rank: number;
40
+ /** Row role → SGR parameters. A missing role falls back to `default`'s. */
41
+ tokens: Partial<Record<DisplayKind, string>>;
42
+ /** Diff emphasis uses a second, brighter variant of the same fill. */
43
+ emphasis?: Partial<Record<DisplayKind, string>>;
44
+ /** Non-row roles. */
45
+ extra?: Partial<Record<ExtraRole, string>>;
46
+ }
47
+ export declare const THEMES: readonly Theme[];
48
+ /** Every theme name, in the order `/theme` lists them. */
49
+ export declare function themeNames(): string[];
50
+ /**
51
+ * Resolve a theme name.
52
+ * @param name - a name from the environment, the settings file, or `/theme`.
53
+ * @returns the theme, or undefined when the name is unknown (the caller decides
54
+ * whether to fall back quietly — a typo in a settings file should not crash a
55
+ * session, but `/theme bogus` has to say so).
56
+ */
57
+ export declare function themeByName(name: string | undefined): Theme | undefined;
58
+ /** The theme a `DSH_TUI_THEME`/`theme` value selects, defaulting to `default`. */
59
+ export declare function resolveTheme(name: string | undefined): Theme;
60
+ /**
61
+ * Publish the active palette.
62
+ * @param name - the theme name, as resolved from the environment or settings.
63
+ * @returns the theme that is now active.
64
+ */
65
+ export declare function setActiveTheme(name: string | undefined): Theme;
66
+ /** The active palette, for renderers that have no session handle. */
67
+ export declare function activeTheme(): Theme;
68
+ /**
69
+ * The SGR parameters for one row role.
70
+ * @param theme - the active theme.
71
+ * @param kind - the display role being painted.
72
+ * @returns SGR parameters without the escape, or `''` for "leave it to the
73
+ * terminal" (which is what primary text wants).
74
+ */
75
+ export declare function themeToken(theme: Theme, kind: DisplayKind): string;
76
+ /**
77
+ * The SGR parameters for a changed word inside a diff row.
78
+ * @param theme - the active theme.
79
+ * @param kind - `diff-add` or `diff-del`.
80
+ * @returns the emphasised variant, or undefined when the theme has none.
81
+ */
82
+ export declare function themeEmphasisToken(theme: Theme, kind: DisplayKind): string | undefined;
83
+ /**
84
+ * The SGR parameters for a non-row role.
85
+ * @param theme - the active theme.
86
+ * @param role - the extra role requested.
87
+ * @returns SGR parameters without the escape.
88
+ */
89
+ export declare function themeExtraToken(theme: Theme, role: ExtraRole): string;
90
+ /** A threshold level used by the status area's gauges. */
91
+ export type ThresholdLevel = 'ok' | 'warn' | 'over';
92
+ /**
93
+ * The token for a gauge threshold: context usage, a quota window, a spend bar.
94
+ *
95
+ * One vocabulary for all of them, so a reader learns it once — under 70% is
96
+ * fine, 70–89% is worth noting, 90% and up is a problem. The numbers behind the
97
+ * levels live in the status area; the colours live here, and `mono` answers with
98
+ * attributes instead of giving up the distinction.
99
+ * @param theme - the active theme.
100
+ * @param level - which threshold the gauge is in.
101
+ * @returns SGR parameters without the escape.
102
+ */
103
+ export declare function themeThresholdToken(theme: Theme, level: ThresholdLevel): string;
104
+ /**
105
+ * Whether a theme paints colour at all.
106
+ * @param theme - the active theme.
107
+ * @returns false for `mono`, whose tokens are attributes only.
108
+ */
109
+ export declare function themeHasColour(theme: Theme): boolean;
@@ -123,8 +123,8 @@ export declare function diffContentLines(text: string): string[];
123
123
  export type { DiffDisplayLine } from './transcript-types.js';
124
124
  /** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
125
125
  export declare function capDisplayLines(lines: readonly DiffDisplayLine[], maxLines: number): DiffDisplayLine[];
126
- /** Running / ok / error → ANSI color for the status dot and status word only. */
127
- export declare function toolStateColor(status: 'running' | 'ok' | 'error' | undefined): '33' | '32' | '31';
126
+ /** Running / ok / error → theme token for the status dot and status word only. */
127
+ export declare function toolStateColor(status: 'running' | 'ok' | 'error' | undefined): string;
128
128
  export declare function toolStateLabel(status: 'running' | 'ok' | 'error' | undefined): string;
129
129
  /** Header + SGR spans: default title, dim operand, colored ●. `[ok]` is omitted — the green dot is enough. */
130
130
  export declare function buildToolHeader(input: {
@@ -125,11 +125,17 @@ export interface TuiConfig {
125
125
  /** Hangup policy while busy: pause cancels the turn; continue lets it finish detached. Idle hangup always exits. */
126
126
  disconnectPolicy?: DisconnectPolicyName;
127
127
  }
128
- /** Lifecycle handle for a mounted interactive terminal channel. */
129
128
  export interface TuiController {
130
129
  dispose(): Promise<void>;
131
130
  handleHangup(): Promise<void>;
132
131
  disconnectPolicy(): DisconnectPolicyName;
132
+ /**
133
+ * Whether the reader ever typed into this session.
134
+ *
135
+ * The launch path uses it on the way out: a fresh session nothing was typed
136
+ * into is deleted rather than left for other profiles' menus to list.
137
+ */
138
+ sessionHadUserInput(): boolean;
133
139
  }
134
140
  export type WorkspaceView = 'detailed' | 'compact';
135
141
  /**
@@ -274,11 +280,78 @@ export declare class SshTui {
274
280
  private commandSuggestions;
275
281
  private suggestionIndex;
276
282
  private focusedRow;
283
+ /**
284
+ * The active palette. Roles resolve through `theme.ts`, so switching themes is
285
+ * a repaint, not a re-render — no row holds a colour of its own.
286
+ */
287
+ private theme;
288
+ /**
289
+ * Rendered display lines per row, keyed by the row object.
290
+ *
291
+ * Every frame used to re-render the whole transcript — markdown parsing, card
292
+ * layout, wrapping and clipping for thousands of rows — to show the twenty on
293
+ * screen. Measured on a 5000-row session that was ~550 ms per frame, which is
294
+ * what "rendering feels slower the longer the session runs" was: a keystroke
295
+ * costs the same as a full repaint because both redo all of it.
296
+ *
297
+ * The cache is `WeakMap`-keyed on the row object, so a replaced row starts a new
298
+ * entry and an unreferenced one is collected. What each entry stores is a
299
+ * *fingerprint* of every input the row's rendering depends on plus the lines it
300
+ * produced; a row whose fingerprint is unchanged replays instead of re-rendering,
301
+ * which is the common case — during a turn only the streaming row changes.
302
+ */
303
+ private displayRowCache;
304
+ /** Fingerprint of the state that affects *every* row's rendering. */
305
+ private displayBaseKey;
306
+ /**
307
+ * Fingerprint of one tool burst.
308
+ *
309
+ * A burst is a *group of tool rows drawn inside the reply above them*, so the
310
+ * reply's cached lines contain cards belonging to rows the reply's own
311
+ * fingerprint never sees. Without this, the reply kept replaying its cached
312
+ * burst: a finished tool went on saying "processing" and the stale card stayed
313
+ * on screen beside the live one — the residue a reader reported twice.
314
+ */
315
+ private static burstKey;
316
+ /**
317
+ * Whether a row's rendering depends on the clock.
318
+ *
319
+ * A running card draws a spinner and an elapsed time, and a streaming row is
320
+ * being appended to: their *fields* may not change between two frames while
321
+ * their lines must. Such a row gets a tick in its cache key, so it re-renders
322
+ * every frame and becomes cacheable again once it settles. A frozen spinner was
323
+ * the alternative — and, once the same card was also drawn live elsewhere, the
324
+ * duplicate a reader actually reported.
325
+ */
326
+ private static isLiveRow;
327
+ /**
328
+ * Fingerprint of everything one row's rendering reads.
329
+ *
330
+ * Written by hand rather than hashing the object: the rendering reads a known
331
+ * set of fields, and a generic walk would cost more per frame than the render
332
+ * it is meant to avoid. Scalar fields are compared by value; nested arrays the
333
+ * rendering walks (todos, sources, intent) get a shallow signature of their own
334
+ * scalars, because those are updated in place.
335
+ *
336
+ * The list is a contract: a field the rendering starts reading must be added
337
+ * here, or a frame replays stale lines. A test mutates one field in place and
338
+ * asserts the next frame notices, which is the pattern to copy for a new field.
339
+ */
340
+ private static rowKey;
341
+ private static rowRendersEqual;
277
342
  /**
278
343
  * The text of the last prompt the user sent, kept so an opted-in retry can
279
344
  * send the same thing again after a provider-side auth failure. Cleared when
280
345
  * the retry fires, which is what bounds it to one attempt per user message.
281
346
  */
347
+ /**
348
+ * Whether this session ever saw the reader's input.
349
+ *
350
+ * A fresh launch creates a session before anything is typed; quitting straight
351
+ * away leaves an artifact the TUI hides but other profiles' menus list. On the
352
+ * way out, a session that never saw input is deleted (see `session-blank.ts`).
353
+ */
354
+ private sawUserInput;
282
355
  private lastUserText;
283
356
  /** Set on every turn/start; a retry consumes it. */
284
357
  private authRetryArmed;
@@ -1237,6 +1310,30 @@ export declare class SshTui {
1237
1310
  private explainAuthFailure;
1238
1311
  /** Whether the opt-in retry is on (`/retryauth`, the settings form, or the env). */
1239
1312
  private retryProviderAuthEnabled;
1313
+ /** Whether anything the reader typed reached this session (used on exit). */
1314
+ sessionHadUserInput(): boolean;
1315
+ /**
1316
+ * `/cleanup [--dry-run]` — delete sessions that never saw user input.
1317
+ *
1318
+ * Every fresh start creates a session, so quitting without typing leaves an
1319
+ * artifact behind. The picker hides those, but the web session list reads the
1320
+ * same files without that filter, which is how an unused session still shows
1321
+ * up in another profile's menu. This walks the whole history once and deletes
1322
+ * the blank ones (the listing prunes as it resolves, which is the same rule the
1323
+ * picker applies).
1324
+ */
1325
+ private runCleanupCommand;
1326
+ /** The theme saved in the `ssh-tui` settings section, if any. */
1327
+ private readThemeName;
1328
+ /**
1329
+ * `/theme [name]` — list the palettes, or switch to one and remember it.
1330
+ *
1331
+ * Switching repaints from the row cache, so it costs one frame rather than a
1332
+ * re-render of the transcript; `mono` exists for terminals where colour is the
1333
+ * problem rather than the answer, and it keeps every difference as an
1334
+ * attribute instead of throwing the difference away.
1335
+ */
1336
+ private runThemeCommand;
1240
1337
  /** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */
1241
1338
  private runRetryAuthCommand;
1242
1339
  private runDisconnectCommand;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-tui",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "SSH-friendly interactive terminal TUI plugin for DeepSeek Harness",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -59,6 +59,7 @@
59
59
  "docs/screenshots/workspace.png",
60
60
  "docs/screenshots/slow-link.gif",
61
61
  "LICENSE",
62
+ "docs/display-mode.md",
62
63
  "docs/terminals.md",
63
64
  "docs/windows.md"
64
65
  ],
@@ -80,7 +81,8 @@
80
81
  "0.1.5-rc.3": "incompatible",
81
82
  "0.1.7-rc.1": "compatible",
82
83
  "0.1.7-rc.2": "compatible",
83
- "0.2.0-rc.1": "compatible"
84
+ "0.2.0-rc.1": "compatible",
85
+ "0.2.0-rc.2": "compatible"
84
86
  },
85
87
  "profiles": [
86
88
  "tui"
@@ -156,75 +158,75 @@
156
158
  "@deepseek-ai/cordis-plugin-include": "1.0.9",
157
159
  "@deepseek-ai/cordis-plugin-loader": "1.0.5",
158
160
  "@deepseek-ai/cordis-plugin-timer": "1.1.6",
159
- "@deepseek-ai/dsh": "0.2.0-rc.1",
160
- "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
161
- "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.1",
162
- "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.1",
163
- "@deepseek-ai/dsh-agent-preset": "0.2.0-rc.1",
164
- "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.1",
165
- "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.1",
166
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
167
- "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
168
- "@deepseek-ai/dsh-cmdline": "0.2.0-rc.1",
169
- "@deepseek-ai/dsh-commands": "0.2.0-rc.1",
170
- "@deepseek-ai/dsh-credentials": "0.2.0-rc.1",
171
- "@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
172
- "@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
173
- "@deepseek-ai/dsh-jobs": "0.2.0-rc.1",
174
- "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
175
- "@deepseek-ai/dsh-llm-mock-server": "0.2.0-rc.1",
176
- "@deepseek-ai/dsh-sandbox": "0.2.0-rc.1",
177
- "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.1",
178
- "@deepseek-ai/dsh-scope": "0.2.0-rc.1",
179
- "@deepseek-ai/dsh-session": "0.2.0-rc.1",
180
- "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.1",
181
- "@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
182
- "@deepseek-ai/dsh-session-projection-cache": "0.2.0-rc.1",
183
- "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
184
- "@deepseek-ai/dsh-subagent": "0.2.0-rc.1",
185
- "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
186
- "@deepseek-ai/dsh-timeout": "0.2.0-rc.1",
187
- "@deepseek-ai/dsh-tools": "0.2.0-rc.1",
188
- "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
189
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
190
- "@deepseek-ai/dsh-user-questions": "0.2.0-rc.1",
161
+ "@deepseek-ai/dsh": "0.2.0-rc.2",
162
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
163
+ "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.2",
164
+ "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2",
165
+ "@deepseek-ai/dsh-agent-preset": "0.2.0-rc.2",
166
+ "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.2",
167
+ "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2",
168
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
169
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
170
+ "@deepseek-ai/dsh-cmdline": "0.2.0-rc.2",
171
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
172
+ "@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
173
+ "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
174
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
175
+ "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
176
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
177
+ "@deepseek-ai/dsh-llm-mock-server": "0.2.0-rc.2",
178
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
179
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
180
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
181
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
182
+ "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2",
183
+ "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
184
+ "@deepseek-ai/dsh-session-projection-cache": "0.2.0-rc.2",
185
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
186
+ "@deepseek-ai/dsh-subagent": "0.2.0-rc.2",
187
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
188
+ "@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
189
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
190
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
191
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
192
+ "@deepseek-ai/dsh-user-questions": "0.2.0-rc.2",
191
193
  "@types/node": "^24.0.0",
192
194
  "@xterm/headless": "^6.0.0",
193
195
  "semver": "^7.8.5",
194
196
  "typescript": "^5.9.0"
195
197
  },
196
198
  "overrides": {
197
- "@deepseek-ai/dsh": "0.2.0-rc.1",
198
- "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
199
- "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.1",
200
- "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.1",
201
- "@deepseek-ai/dsh-agent-preset": "0.2.0-rc.1",
202
- "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.1",
203
- "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.1",
204
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
205
- "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
206
- "@deepseek-ai/dsh-cmdline": "0.2.0-rc.1",
207
- "@deepseek-ai/dsh-commands": "0.2.0-rc.1",
208
- "@deepseek-ai/dsh-credentials": "0.2.0-rc.1",
209
- "@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
210
- "@deepseek-ai/dsh-invariants": "0.2.0-rc.1",
211
- "@deepseek-ai/dsh-jobs": "0.2.0-rc.1",
212
- "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
213
- "@deepseek-ai/dsh-llm-mock-server": "0.2.0-rc.1",
214
- "@deepseek-ai/dsh-sandbox": "0.2.0-rc.1",
215
- "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.1",
216
- "@deepseek-ai/dsh-scope": "0.2.0-rc.1",
217
- "@deepseek-ai/dsh-session": "0.2.0-rc.1",
218
- "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.1",
219
- "@deepseek-ai/dsh-session-projection": "0.2.0-rc.1",
220
- "@deepseek-ai/dsh-session-projection-cache": "0.2.0-rc.1",
221
- "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
222
- "@deepseek-ai/dsh-subagent": "0.2.0-rc.1",
223
- "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
224
- "@deepseek-ai/dsh-timeout": "0.2.0-rc.1",
225
- "@deepseek-ai/dsh-tools": "0.2.0-rc.1",
226
- "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
227
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
228
- "@deepseek-ai/dsh-user-questions": "0.2.0-rc.1"
199
+ "@deepseek-ai/dsh": "0.2.0-rc.2",
200
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
201
+ "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.2",
202
+ "@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2",
203
+ "@deepseek-ai/dsh-agent-preset": "0.2.0-rc.2",
204
+ "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.2",
205
+ "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2",
206
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
207
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.2",
208
+ "@deepseek-ai/dsh-cmdline": "0.2.0-rc.2",
209
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
210
+ "@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
211
+ "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
212
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
213
+ "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
214
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
215
+ "@deepseek-ai/dsh-llm-mock-server": "0.2.0-rc.2",
216
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
217
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
218
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
219
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
220
+ "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2",
221
+ "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
222
+ "@deepseek-ai/dsh-session-projection-cache": "0.2.0-rc.2",
223
+ "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
224
+ "@deepseek-ai/dsh-subagent": "0.2.0-rc.2",
225
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
226
+ "@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
227
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
228
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
229
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
230
+ "@deepseek-ai/dsh-user-questions": "0.2.0-rc.2"
229
231
  }
230
232
  }