dsh-ssh-tui 0.6.4 → 0.7.0-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.
Files changed (79) hide show
  1. package/README.en.md +14 -0
  2. package/README.md +14 -0
  3. package/docs/remote-ops.md +133 -0
  4. package/docs/screenshots/compare.png +0 -0
  5. package/docs/screenshots/headless.png +0 -0
  6. package/docs/screenshots/workspace.png +0 -0
  7. package/lib/approval-cache.js +158 -0
  8. package/lib/approval-cache.js.map +1 -0
  9. package/lib/attach.js +66 -7
  10. package/lib/attach.js.map +1 -1
  11. package/lib/auto-approval.js +13 -0
  12. package/lib/auto-approval.js.map +1 -1
  13. package/lib/color-depth.js +192 -0
  14. package/lib/color-depth.js.map +1 -0
  15. package/lib/commands.js +5 -0
  16. package/lib/commands.js.map +1 -1
  17. package/lib/copy-text.js +1 -0
  18. package/lib/copy-text.js.map +1 -1
  19. package/lib/dialogs.js +81 -8
  20. package/lib/dialogs.js.map +1 -1
  21. package/lib/display-sock.js +24 -8
  22. package/lib/display-sock.js.map +1 -1
  23. package/lib/doctor.js +380 -0
  24. package/lib/doctor.js.map +1 -0
  25. package/lib/footer.js +76 -6
  26. package/lib/footer.js.map +1 -1
  27. package/lib/i18n/en.js +136 -3
  28. package/lib/i18n/en.js.map +1 -1
  29. package/lib/i18n/zh.js +137 -4
  30. package/lib/i18n/zh.js.map +1 -1
  31. package/lib/index.js +8 -1
  32. package/lib/index.js.map +1 -1
  33. package/lib/keymap.js +172 -0
  34. package/lib/keymap.js.map +1 -0
  35. package/lib/line-diff.js +224 -0
  36. package/lib/line-diff.js.map +1 -0
  37. package/lib/line-mode.js +86 -0
  38. package/lib/line-mode.js.map +1 -0
  39. package/lib/paint.js +158 -26
  40. package/lib/paint.js.map +1 -1
  41. package/lib/plan.js +51 -8
  42. package/lib/plan.js.map +1 -1
  43. package/lib/preset-authoring.js +110 -0
  44. package/lib/preset-authoring.js.map +1 -0
  45. package/lib/preset-picker.js +105 -0
  46. package/lib/preset-picker.js.map +1 -0
  47. package/lib/preset-rows.js +288 -34
  48. package/lib/preset-rows.js.map +1 -1
  49. package/lib/selection.js +154 -0
  50. package/lib/selection.js.map +1 -0
  51. package/lib/term-text.js +106 -1
  52. package/lib/term-text.js.map +1 -1
  53. package/lib/tool-present.js +157 -21
  54. package/lib/tool-present.js.map +1 -1
  55. package/lib/tui.js +1428 -97
  56. package/lib/tui.js.map +1 -1
  57. package/lib/types/approval-cache.d.ts +125 -0
  58. package/lib/types/attach.d.ts +29 -0
  59. package/lib/types/auto-approval.d.ts +11 -0
  60. package/lib/types/color-depth.d.ts +50 -0
  61. package/lib/types/commands.d.ts +9 -0
  62. package/lib/types/dialogs.d.ts +36 -5
  63. package/lib/types/display-sock.d.ts +6 -0
  64. package/lib/types/doctor.d.ts +118 -0
  65. package/lib/types/footer.d.ts +35 -0
  66. package/lib/types/keymap.d.ts +68 -0
  67. package/lib/types/line-diff.d.ts +107 -0
  68. package/lib/types/line-mode.d.ts +36 -0
  69. package/lib/types/paint.d.ts +89 -4
  70. package/lib/types/plan.d.ts +7 -0
  71. package/lib/types/preset-authoring.d.ts +135 -0
  72. package/lib/types/preset-picker.d.ts +64 -0
  73. package/lib/types/preset-rows.d.ts +113 -9
  74. package/lib/types/selection.d.ts +81 -0
  75. package/lib/types/term-text.d.ts +29 -0
  76. package/lib/types/tool-present.d.ts +53 -3
  77. package/lib/types/transcript-types.d.ts +12 -2
  78. package/lib/types/tui.d.ts +231 -2
  79. package/package.json +33 -32
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Line-level diffing for the tool card, and the word-level emphasis inside a
3
+ * changed pair.
4
+ *
5
+ * The card used to print every old line as `-` and every new line as `+`: a
6
+ * one-line change in a hundred-line file produced two hundred rows, which is
7
+ * unreadable and hides the change it is trying to show. This module keeps the
8
+ * classic shape — common lines as context, changes as `-`/`+` pairs, unchanged
9
+ * stretches collapsed to a marker — and reports which characters inside a
10
+ * replaced pair actually changed so the renderer can emphasise them.
11
+ * @module dsh-ssh-tui/line-diff
12
+ */
13
+ /** One line of the diff. */
14
+ export interface DiffLine {
15
+ kind: 'same' | 'add' | 'del';
16
+ text: string;
17
+ /** 0-based index in the old text, for `same` and `del`. */
18
+ oldIndex?: number;
19
+ /** 0-based index in the new text, for `same` and `add`. */
20
+ newIndex?: number;
21
+ }
22
+ /** A run of lines with its context, and how many unchanged lines were skipped. */
23
+ export interface DiffHunk {
24
+ /** Unchanged lines collapsed before this run; 0 for the first. */
25
+ omittedBefore: number;
26
+ lines: DiffLine[];
27
+ }
28
+ /** Two columns plus a gutter need this much room to be worth it. */
29
+ export declare const SIDE_BY_SIDE_MIN_WIDTH = 100;
30
+ /** The gutter between the two columns. */
31
+ export declare const SIDE_BY_SIDE_GUTTER = " \u2502 ";
32
+ /**
33
+ * Whether a diff should be shown side by side at this terminal width.
34
+ *
35
+ * Below the threshold the columns would be narrower than the code they hold,
36
+ * which turns every changed line into a wrapped mess — stacked reads better.
37
+ * @param width - the terminal width in cells.
38
+ * @returns the layout to use.
39
+ */
40
+ export declare function diffLayout(width: number): 'side-by-side' | 'stacked';
41
+ /** One row of a side-by-side rendering. */
42
+ export interface SideBySideRow {
43
+ /** Marker plus text for the left column: `- old`, ` context`, or empty. */
44
+ left: string;
45
+ /** Marker plus text for the right column: `+ new`, ` context`, or empty. */
46
+ right: string;
47
+ /** Emphasis inside `left`, offsets into it. */
48
+ leftSpans: DiffSpan[];
49
+ /** Emphasis inside `right`, offsets into it. */
50
+ rightSpans: DiffSpan[];
51
+ /** Whether both columns carry the same line (context) rather than a change. */
52
+ context: boolean;
53
+ }
54
+ /**
55
+ * Pair a line diff into two columns.
56
+ *
57
+ * A removed line immediately followed by an added one is a replacement and
58
+ * shares a row, with the changed characters emphasised on each side; a lone
59
+ * removal or addition keeps its own row with the other column empty. Context
60
+ * rows carry the text on both sides so the eye can follow either column.
61
+ * @param lines - a diff, as {@link diffLines} returns it.
62
+ * @returns the rows, in order.
63
+ */
64
+ export declare function sideBySideRows(lines: readonly DiffLine[]): SideBySideRow[];
65
+ /** One emphasized range inside a line, in UTF-16 offsets. */
66
+ export interface DiffSpan {
67
+ start: number;
68
+ end: number;
69
+ }
70
+ /** Split text into lines, treating an empty string as no lines. */
71
+ export declare function splitLines(text: string): string[];
72
+ /**
73
+ * Diff two texts line by line.
74
+ *
75
+ * Equal lines are `same`, lines only in the old text are `del`, only in the new
76
+ * are `add` — in the order a reader expects. A pair this module cannot afford
77
+ * (see {@link MAX_DIFF_LINES}) degrades to "all removed, then all added", which
78
+ * is what the card rendered before but is at least bounded.
79
+ * @param oldText - the previous content; empty for a new file.
80
+ * @param newText - the new content.
81
+ * @returns the diff, oldest first.
82
+ */
83
+ export declare function diffLines(oldText: string, newText: string): DiffLine[];
84
+ /**
85
+ * Group a diff into runs of change with `context` unchanged lines around them,
86
+ * reporting how many unchanged lines were left out between runs.
87
+ * @param lines - a diff, as {@link diffLines} returns it.
88
+ * @param context - unchanged lines to keep on each side of a change.
89
+ * @returns the runs, in order; a diff with no changes yields no runs.
90
+ */
91
+ export declare function diffHunks(lines: readonly DiffLine[], context?: number): DiffHunk[];
92
+ /**
93
+ * The characters that differ between two lines: the common prefix and suffix
94
+ * are trimmed away, and what is left is emphasised.
95
+ *
96
+ * Whole-line emphasis was the old behaviour and it reads as noise on a line that
97
+ * changed one identifier. Trimming is greedy and therefore approximate — it does
98
+ * not try to align moved words — but it is exact about the boundaries it does
99
+ * report: everything outside the spans really is identical.
100
+ * @param oldText - the removed line.
101
+ * @param newText - the added line.
102
+ * @returns one span list per side, possibly empty when they are equal.
103
+ */
104
+ export declare function wordDiffSpans(oldText: string, newText: string): {
105
+ old: DiffSpan[];
106
+ new: DiffSpan[];
107
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Line mode: one plain line per event, for screen readers, log capture, and
3
+ * terminals where a full-screen repaint is the wrong shape.
4
+ *
5
+ * The TUI normally paints a framed screen: absolute cursor addressing, an
6
+ * alternate screen buffer, only the rows that changed, and a spinner that
7
+ * redraws in place. None of that survives a pipe, a screen reader, or a
8
+ * recording — the same event can appear twice, out of order, or not at all.
9
+ * This module turns a transcript row into the lines a log would carry, and the
10
+ * caller appends them once, in order, and never repaints.
11
+ *
12
+ * Colour is deliberately absent: the caller may style a line, but the text has
13
+ * to stand on its own because that is the point of the mode.
14
+ * @module dsh-ssh-tui/line-mode
15
+ */
16
+ import type { Row } from './transcript-types.js';
17
+ /** Whether the environment asks for line mode. `1/true/on/yes` all count. */
18
+ export declare function lineModeEnabled(env?: NodeJS.ProcessEnv): boolean;
19
+ /**
20
+ * The lines one row contributes to the log.
21
+ *
22
+ * A row with embedded newlines contributes several lines — the alternative is
23
+ * joining them, which corrupts a report's structure for the reader this mode
24
+ * exists for. Rows that only make sense on a screen (the logo, a transient
25
+ * reasoning buffer) contribute nothing rather than noise.
26
+ * @param row - the transcript row that was just pushed.
27
+ * @returns the lines to append, in order; empty when the row is screen-only.
28
+ */
29
+ export declare function lineModeLines(row: Row): string[];
30
+ /**
31
+ * What one row's lines look like once appended.
32
+ *
33
+ * A trailing newline per line, and nothing else: no carriage returns (they
34
+ * would overwrite the line just written on a terminal) and no escape sequences.
35
+ */
36
+ export declare function appendRow(lines: readonly string[]): string;
@@ -43,10 +43,34 @@ export type LinkQuality = 'local' | 'good' | 'ok' | 'slow' | 'poor' | 'unknown';
43
43
  export declare function linkQualityOf(kind: PaintLinkKind, rttMs: number | undefined): LinkQuality;
44
44
  /** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */
45
45
  export declare function linkSignalPips(quality: LinkQuality): number;
46
+ /**
47
+ * How many bytes one frame may carry before the rest waits for the next tick.
48
+ *
49
+ * A 100x30 full repaint costs about 3.7 kB, which on a 400ms link is a third of
50
+ * a second of serialization the user waits through before anything moves. The
51
+ * cadence already follows the measured RTT; the budget bounds what each of
52
+ * those ticks may spend, so a resize or a burst of streaming output degrades
53
+ * into a few ordered frames instead of one long freeze and a queue behind it.
54
+ */
55
+ export declare const FRAME_BYTE_BUDGETS: Record<LinkQuality, number>;
56
+ /** The per-frame byte budget for a link quality. */
57
+ export declare function frameByteBudget(quality: LinkQuality): number;
58
+ /**
59
+ * How many body lines an expanded card may show before it points at the
60
+ * overlay, by link quality.
61
+ *
62
+ * A measured slow link is the only case that truncates: there, every body line
63
+ * is bytes the user waits for, and the overlay is one key away (`Enter`). Fast
64
+ * and unmeasured links keep the behavior 0.3.9 settled on — an expanded body
65
+ * shows in full, or opens in the overlay when it cannot fit the workspace — so
66
+ * nothing changes for a local terminal or a link whose RTT could not be probed.
67
+ */
68
+ export declare const TOOL_BODY_LINES_BY_QUALITY: Record<LinkQuality, number>;
69
+ /** The body-line cap for a link quality; `Infinity` means "show it all". */
70
+ export declare function toolBodyLineLimit(quality: LinkQuality): number;
46
71
  /** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */
47
72
  export declare function formatLinkQualityChip(kind: PaintLinkKind, intervalMs: number, rttMs: number | undefined, probed: boolean, color?: boolean): string;
48
- /** One incremental paint as a single stdout write (one SSH packet when corked). */
49
- export declare function composePaintOutput(options: {
73
+ export interface PaintOptions {
50
74
  width: number;
51
75
  height: number;
52
76
  paintRows: readonly string[];
@@ -59,7 +83,60 @@ export declare function composePaintOutput(options: {
59
83
  cursorColumn: number;
60
84
  /** Keep the cursor hidden (session picker). Default shows it on the input. */
61
85
  hideCursor?: boolean;
62
- }): string;
86
+ }
87
+ export interface PaintFrame {
88
+ /** The single stdout write for this frame. */
89
+ output: string;
90
+ /** Row indices actually written, ascending. Only these are now up to date. */
91
+ painted: number[];
92
+ /** Rows that needed a paint but did not fit the byte budget. */
93
+ deferred: number[];
94
+ /** Highest deferred row, to pass back as `from` on the next frame. */
95
+ resume: number | undefined;
96
+ /** Bytes this frame carried, for the ledger `/diag` and the tests read. */
97
+ bytes: number;
98
+ }
99
+ /**
100
+ * Which dirty rows to paint first when a byte budget applies.
101
+ *
102
+ * Rows at or below `from` were deferred by the previous frame and go first so
103
+ * they cannot starve; everything above is the tail the user is looking at,
104
+ * painted newest-row-first so the input area is current even when the budget
105
+ * runs out. Without a `from`, the order is simply tail-first.
106
+ * @param dirty - row indices that need a paint, ascending.
107
+ * @param from - highest row the previous frame deferred, if any.
108
+ * @returns indices in paint order.
109
+ */
110
+ export declare function paintOrder(dirty: readonly number[], from?: number): number[];
111
+ /**
112
+ * Compose one incremental paint, optionally bounded by a byte budget.
113
+ *
114
+ * Every row is addressed absolutely, so the paint order is free: with a budget
115
+ * the frame spends it on the tail first and reports what it could not reach, so
116
+ * the caller keeps those rows dirty instead of forgetting them.
117
+ * @param options - the frame to paint, plus the budget and resume point.
118
+ * @returns the write, what it covered, and what is left for the next tick.
119
+ */
120
+ export declare function composePaintFrame(options: PaintOptions & {
121
+ maxBytes?: number;
122
+ from?: number;
123
+ }): PaintFrame;
124
+ /**
125
+ * The snapshot for the next frame: only the rows a frame painted become clean.
126
+ *
127
+ * A deferred row has to keep its previous content in the snapshot, or the next
128
+ * frame would compare it against itself, call it unchanged, and never paint it:
129
+ * the row would simply stay wrong. The array also keeps its old length while
130
+ * anything is owed, so the "row count changed" clear keeps firing until the
131
+ * frame is complete.
132
+ * @param previous - the snapshot the frame painted against.
133
+ * @param current - the rows the frame wanted to show.
134
+ * @param painted - indices the frame actually wrote.
135
+ * @returns the snapshot to hand to the next frame.
136
+ */
137
+ export declare function advancePaintedRows(previous: readonly string[], current: readonly string[], painted: readonly number[]): string[];
138
+ /** One incremental paint as a single stdout write (one SSH packet when corked). */
139
+ export declare function composePaintOutput(options: PaintOptions): string;
63
140
  /** Whether `text` could still grow into a recognized escape sequence. */
64
141
  export declare function isEscapePrefix(text: string): boolean;
65
142
  /** Parse a Device Status Report cursor reply (`CSI row;col R`). */
@@ -92,5 +169,13 @@ export declare function findCursorPositionReply(text: string): {
92
169
  export declare function probeTerminalRttMs(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream, timeoutMs?: number, ssh?: boolean): Promise<number | undefined>;
93
170
  /** Sliding window of `windowSize` items that keeps `cursor` visible. */
94
171
  export declare function pickerWindowStart(cursor: number, total: number, windowSize?: number): number;
95
- /** Immediate first-frame chrome so a 2–3s Host boot is not a blank TTY. */
172
+ /**
173
+ * Immediate first-frame chrome so a 2–3s Host boot is not a blank TTY.
174
+ *
175
+ * Line mode gets nothing: a splash enters the alternate screen and paints over
176
+ * itself, which is exactly what that mode exists to avoid — the log would open
177
+ * with screen control instead of the session's first event.
178
+ * @param message - what the launcher is waiting for.
179
+ * @param color - whether colour is allowed.
180
+ */
96
181
  export declare function writeBootSplash(message: string, color?: boolean): void;
@@ -62,6 +62,13 @@ export declare function planDockNote(plan: {
62
62
  /** Compact per-status counts matching the web plan strip. */
63
63
  export declare function todoProgressLabel(todos: readonly PlanTodoItem[]): string;
64
64
  export declare function todoItemKind(status: PlanTodoItem['status']): DisplayKind;
65
+ /**
66
+ * A Braille bar for the plan's completion, in the same family as the context
67
+ * ring: `⣿` filled, `⣀` empty, and the eight partial cells between them.
68
+ * @param todos - the list as the model last wrote it.
69
+ * @param cells - bar width in cells.
70
+ */
71
+ export declare function todoProgressBar(todos: readonly PlanTodoItem[], cells?: number): string;
65
72
  export declare function planMarkdownFromArgs(value: unknown): string | undefined;
66
73
  /** First markdown heading of an exit_plan_mode plan body. */
67
74
  export declare function planTitleFromMarkdown(markdown: string): string | undefined;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `/preset`: the terminal's authoring surface for agent presets.
3
+ *
4
+ * A preset is a directory — an id, a composition file, and optional display
5
+ * metadata — and the web client reaches the same four operations the plugin
6
+ * exposes to it: list, read, copy, delete. This module mirrors that surface and
7
+ * adds display-name/description edits, which are the one authoring write the
8
+ * upstream service does not offer; it stays pure so every refusal is testable
9
+ * without a host, and `tui.ts` performs the two calls that touch the disk.
10
+ *
11
+ * The rules are the upstream ones, re-stated here so a command refuses before
12
+ * it calls out:
13
+ *
14
+ * - only `user` presets are writable; a shipped preset is refused by the
15
+ * service as well (`agent-preset/read-only`);
16
+ * - a copy never overwrites: the destination id must be free;
17
+ * - ids match `/^[a-z0-9][a-z0-9-]*$/`;
18
+ * - deleting the preset the session is running is refused here, even though the
19
+ * service allows it — the running session would keep its composition while
20
+ * every later launch falls back to the default.
21
+ * @module dsh-ssh-tui/preset-authoring
22
+ */
23
+ import { type AgentPreset, type PresetMetadata } from '@deepseek-ai/dsh-agent-presets';
24
+ /** The id shape discovery accepts; a directory that fails it is skipped. */
25
+ export declare const PRESET_ID_PATTERN: RegExp;
26
+ /** Whether an id would be discovered at all. */
27
+ export declare function validatePresetId(id: string): boolean;
28
+ /**
29
+ * The directory a preset owns.
30
+ *
31
+ * `AgentPreset.path` is the composition file the preset publishes, not the
32
+ * directory, so anything that writes beside it (display metadata) starts here.
33
+ * @param preset - a discovered preset.
34
+ * @returns the absolute preset directory.
35
+ */
36
+ export declare function presetDirectory(preset: AgentPreset): string;
37
+ export type PresetRefusalCode = 'invalid-id' | 'exists' | 'not-found' | 'read-only' | 'system' | 'running' | 'empty-metadata';
38
+ export interface PresetRefusal {
39
+ error: PresetRefusalCode;
40
+ /** The id the refusal is about, when it names one. */
41
+ id?: string;
42
+ /** The name that collided, for `exists`. */
43
+ name?: string;
44
+ }
45
+ export interface PresetCopyPlan {
46
+ kind: 'copy';
47
+ from: string;
48
+ id: string;
49
+ name?: string;
50
+ }
51
+ export interface PresetMetadataPlan {
52
+ kind: 'metadata';
53
+ id: string;
54
+ directory: string;
55
+ /** The `preset.yml` document to write, already rendered by upstream. */
56
+ text: string;
57
+ }
58
+ export interface PresetDeletePlan {
59
+ kind: 'delete';
60
+ id: string;
61
+ }
62
+ export type PresetPlan = PresetCopyPlan | PresetMetadataPlan | PresetDeletePlan;
63
+ export declare function isRefusal(value: PresetPlan | PresetRefusal): value is PresetRefusal;
64
+ /** One composition row as `/preset show` renders it. */
65
+ export interface PresetRowView {
66
+ entryId: string | null;
67
+ moduleName: string;
68
+ enabled: boolean | 'conditional';
69
+ condition?: string;
70
+ }
71
+ /** One preset's composition as `/preset show` renders it. */
72
+ export interface PresetCompositionView {
73
+ id: string;
74
+ name?: string;
75
+ isDefault: boolean;
76
+ broken?: string;
77
+ rows: readonly PresetRowView[];
78
+ }
79
+ /**
80
+ * The authoring subset of the service, feature-detected rather than assumed:
81
+ * the plugin still supports 0.1.2-rc.1, whose `agentPresets` may predate
82
+ * `copy`, `remove`, `read`, and `compositionInventory`. A missing member turns
83
+ * into a clear message instead of a crash.
84
+ */
85
+ export interface PresetAuthoringApi {
86
+ readonly authorable?: boolean;
87
+ copy?(from: string, id: string, name?: string): Promise<void>;
88
+ remove?(id: string): Promise<void>;
89
+ read?(id: string): Promise<string>;
90
+ compositionInventory?(): Promise<readonly PresetCompositionView[]>;
91
+ }
92
+ /**
93
+ * Plan a copy of an existing preset under a new id.
94
+ *
95
+ * Copy is the only way to create one (upstream has no create or rename), and it
96
+ * carries the whole directory — composition, display metadata, and any scripts
97
+ * a preset ships beside them.
98
+ * @param input - the roster, whether authoring is possible, and the request.
99
+ * @returns the call to make, or why it must be refused.
100
+ */
101
+ export declare function planCopy(input: {
102
+ presets: readonly AgentPreset[];
103
+ authorable: boolean;
104
+ from: string;
105
+ id: string;
106
+ name?: string | undefined;
107
+ }): PresetCopyPlan | PresetRefusal;
108
+ /**
109
+ * Plan a display-metadata edit (name, description, or both).
110
+ *
111
+ * The current document is merged, so editing one field never drops the other,
112
+ * and `order` survives. A shipped preset cannot be edited: its directory is
113
+ * read-only by definition.
114
+ * @param input - the preset, its current metadata, and the fields to change.
115
+ * @returns the file to write, or why it must be refused.
116
+ */
117
+ export declare function planMetadata(input: {
118
+ preset: AgentPreset;
119
+ current: PresetMetadata;
120
+ patch: {
121
+ name?: string | undefined;
122
+ description?: string | undefined;
123
+ };
124
+ }): PresetMetadataPlan | PresetRefusal;
125
+ /**
126
+ * Plan a deletion.
127
+ * @param input - the preset, whether authoring is possible, and which preset
128
+ * this session is running.
129
+ * @returns the call to make, or why it must be refused.
130
+ */
131
+ export declare function planDelete(input: {
132
+ preset: AgentPreset;
133
+ authorable: boolean;
134
+ current: boolean;
135
+ }): PresetDeletePlan | PresetRefusal;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The `/mode` picker's list: which presets to show, in what order, and what to
3
+ * say about each one.
4
+ *
5
+ * The dialog is a flat list of options, so the grouping a browser surface can
6
+ * draw is carried in three cheap ways: the groups are ordered (shipped first,
7
+ * then authored locally), every description names its group, and a preset that
8
+ * declares a position shows it. Nothing here touches the filesystem or the
9
+ * service — the caller passes what `list()` returned.
10
+ * @module dsh-ssh-tui/preset-picker
11
+ */
12
+ import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets';
13
+ /** One option as the question dialog wants it, plus the id it stands for. */
14
+ export interface PresetPickerOption {
15
+ id: string;
16
+ label: string;
17
+ description: string;
18
+ /** Why this preset cannot run, carried so the dialog can refuse it on pick. */
19
+ broken?: string;
20
+ /**
21
+ * Every name this preset answers to: the id, and the name it published even
22
+ * when the display label resolves elsewhere. `/mode <name>` matched a
23
+ * published name before the list was grouped, and dropping that would break
24
+ * a script that uses one.
25
+ */
26
+ aliases: string[];
27
+ }
28
+ /** The shipped presets, then the locally authored ones. */
29
+ export interface PresetPickerGroup {
30
+ trust: 'system' | 'user';
31
+ title: string;
32
+ options: PresetPickerOption[];
33
+ }
34
+ /**
35
+ * Within a group: a declared position first, in ascending order, then the rest
36
+ * by id. `order` is the preset's own statement about where it belongs, so a
37
+ * deployment's curated sequence survives discovery.
38
+ */
39
+ export declare function comparePresets(left: AgentPreset, right: AgentPreset): number;
40
+ /**
41
+ * What one preset's line says underneath its name: where it sits, what it is
42
+ * for, and why it cannot run — in that order, because a broken preset's reason
43
+ * is the only thing the reader needs.
44
+ */
45
+ export declare function presetOptionDescription(preset: AgentPreset, currentId: string): string;
46
+ /** One option's label: the name the roster publishes, never empty. */
47
+ export declare function presetOptionLabel(preset: AgentPreset): string;
48
+ /**
49
+ * Group and order the roster for the picker.
50
+ * @param presets - what the service listed.
51
+ * @param currentId - the preset in effect, marked in each description.
52
+ * @returns shipped presets first, then authored ones; empty groups are omitted.
53
+ */
54
+ export declare function groupPresets(presets: readonly AgentPreset[], currentId: string): PresetPickerGroup[];
55
+ /** The same list, flattened the way the dialog takes it. */
56
+ export declare function flattenGroups(groups: readonly PresetPickerGroup[]): PresetPickerOption[];
57
+ /** Whether a preset answers to the name a caller typed. */
58
+ export declare function optionMatches(option: PresetPickerOption, query: string): boolean;
59
+ /**
60
+ * Narrow the roster to what a typed query matches: the id, the published name,
61
+ * and the description, case-insensitively. An empty query keeps everything, so
62
+ * the picker opens on the full roster.
63
+ */
64
+ export declare function filterPresets(presets: readonly AgentPreset[], query: string): AgentPreset[];
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The profile patch that mounts the agent-preset roster `/mode` needs.
2
+ * The profile patch that mounts the agent-preset roster `/mode` needs, plus the
3
+ * repair planner `/doctor` uses to fix a profile patch.
3
4
  *
4
5
  * A terminal profile built on `dsh-base` composes no preset roster (only the
5
6
  * Web bundle does), and this plugin's own bundle patch may not mount one: DSH
@@ -12,27 +13,116 @@
12
13
  * The roster is not cosmetic: without it `/mode` cannot switch, and the tools
13
14
  * the shipped presets own (`ask_user_question`, `present`, PTC's presentation)
14
15
  * are absent from the agent's catalog.
16
+ *
17
+ * Everything in this module is either a pure text function or a file write that
18
+ * takes a backup first: `/doctor` may repair a user's profile patch, and that
19
+ * must never lose the previous content or touch a row the user wrote.
15
20
  * @module dsh-ssh-tui/preset-rows
16
21
  */
22
+ /** One host row the roster block mounts. */
23
+ export interface RosterRow {
24
+ id: string;
25
+ name: string;
26
+ /** Lines below `config:`, written one level deeper than `config:`. */
27
+ config?: readonly string[];
28
+ }
29
+ /**
30
+ * The rows the terminal profile has to mount itself: the roster `/mode` lists,
31
+ * the TypeScript runtime the PTC preset needs, and the host-owned subagent
32
+ * delegation setting.
33
+ */
34
+ export declare const ROSTER_ROWS: readonly RosterRow[];
35
+ /** The comment header the block introduces itself with, in both writers. */
36
+ export declare const ROSTER_PATCH_HEADER = "# dsh-ssh-tui /mode: the agent-preset roster (standard / minimal / PTC /\n# cordis, plus every preset under $DSH_HOME/.agent-presets) and the two host\n# services the shipped presets need. dsh-base composes no roster in a terminal\n# profile, and a third-party bundle patch may not mount an @deepseek-ai row, so\n# the profile's user layer owns them.\n";
37
+ /** One top-level `- insert:` entry mounting exactly these rows. */
38
+ export declare function rosterInsertEntry(rows: readonly RosterRow[]): string;
17
39
  /**
18
40
  * The exact profile patch block that mounts the roster and the two host
19
41
  * services the shipped presets need. `scripts/ensure-profile-rows.sh` carries
20
42
  * the same text; a test compares the two so they cannot drift.
21
43
  */
22
- export declare const ROSTER_PATCH_BLOCK = "# dsh-ssh-tui /mode: the agent-preset roster (standard / minimal / PTC /\n# cordis, plus every preset under $DSH_HOME/.agent-presets) and the two host\n# services the shipped presets need. dsh-base composes no roster in a terminal\n# profile, and a third-party bundle patch may not mount an @deepseek-ai row, so\n# the profile's user layer owns them.\n- insert:\n - id: agent-presets\n name: '@deepseek-ai/dsh-agent-presets'\n config:\n default: standard\n\n - id: code-runtime\n name: '@deepseek-ai/dsh-code-runtime-worker-thread'\n\n - id: subagent-model-selection-settings\n name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'\n";
44
+ export declare const ROSTER_PATCH_BLOCK: string;
23
45
  /** The profile patch file the roster block belongs in. */
24
46
  export declare function rosterPatchPath(home: string, profile: string): string;
47
+ /** One row a patch file declares, with enough position to point at it. */
48
+ export interface PatchRowRef {
49
+ /** Insert id, or the target of an override/disable entry. */
50
+ id: string;
51
+ /** Module name for an insert that mounts one. */
52
+ name?: string;
53
+ kind: 'insert' | 'override' | 'disable' | 'other';
54
+ /** 0-based top-level entry index. */
55
+ entry: number;
56
+ /** 0-based index inside the entry's insert list (inserts only). */
57
+ item?: number;
58
+ /** 1-based line the id is written on, when the text can be scanned. */
59
+ line: number;
60
+ }
61
+ export interface PatchAnalysis {
62
+ /** The YAML parses into a top-level array of patch entries. */
63
+ parseable: boolean;
64
+ /** What the loader would reject, when it does not parse. */
65
+ error?: string;
66
+ rows: PatchRowRef[];
67
+ }
68
+ /**
69
+ * Read one patch file into the rows it declares.
70
+ *
71
+ * Unparseable YAML is reported instead of thrown: `/doctor` has to *describe*
72
+ * a broken patch, which is exactly when the loader would refuse to boot.
73
+ * @param text - the patch file's content.
74
+ * @returns the rows, and whether the text parsed at all.
75
+ */
76
+ export declare function analyzePatch(text: string): PatchAnalysis;
77
+ /**
78
+ * Insert rows that appear more than once, keeping the first occurrence.
79
+ *
80
+ * A row is duplicated when an earlier insert declares the same id, or mounts
81
+ * the same module name — the two ways a second mount reaches the loader and
82
+ * fails with "service … has been registered". Overrides and disables are never
83
+ * duplicates of an insert: they target a row instead of mounting it.
84
+ * @param rows - every row the patch declares.
85
+ * @returns the later occurrences, in file order.
86
+ */
87
+ export declare function duplicatePatchRows(rows: readonly PatchRowRef[]): PatchRowRef[];
88
+ /** Whether a patch already declares one roster row, by id or by module name. */
89
+ export declare function patchNamesRow(rows: readonly PatchRowRef[], row: RosterRow): boolean;
90
+ /** The roster rows a patch does not declare yet. */
91
+ export declare function missingRosterRows(rows: readonly PatchRowRef[]): RosterRow[];
92
+ export interface PatchRepair {
93
+ text: string;
94
+ /** Roster rows this repair adds, by id. */
95
+ added: string[];
96
+ /** Duplicate rows this repair removes, by id, with the line they sat on. */
97
+ removed: Array<{
98
+ id: string;
99
+ line: number;
100
+ }>;
101
+ }
25
102
  /**
26
- * The patch text with the roster block appended, or `undefined` when the file
27
- * already names the roster row.
103
+ * The patch text with the given roster rows mounted, or `undefined` when the
104
+ * file already declares every one of them.
28
105
  *
29
- * A file that is exactly `[]` (the profile template) is replaced, so the result
30
- * stays a valid top-level patch list; anything else keeps its content and the
31
- * block is appended after a blank line.
106
+ * A row is matched by id or by module name, so a profile that composes the
107
+ * roster through its own bundle is left alone. A file that is exactly `[]`
108
+ * (the profile template) is replaced; anything else keeps its content and gains
109
+ * a new `- insert:` entry after a blank line.
32
110
  * @param existing - the patch file's current text.
111
+ * @param missing - the roster rows to mount; defaults to all of them.
33
112
  * @returns the new text, or `undefined` when nothing has to change.
34
113
  */
35
- export declare function rosterPatchText(existing: string): string | undefined;
114
+ export declare function planRosterRepair(existing: string, missing?: readonly RosterRow[]): PatchRepair | undefined;
115
+ /**
116
+ * The patch text with every repeated insert removed, or `undefined` when the
117
+ * file has no duplicate.
118
+ *
119
+ * Only the later occurrence is cut, and only its own item when the enclosing
120
+ * `- insert:` entry mounts other rows too. Comments directly above a removed
121
+ * entry go with it; anything the user wrote around it stays.
122
+ * @param existing - the patch file's current text.
123
+ * @returns the new text and what was removed, or `undefined` when nothing is.
124
+ */
125
+ export declare function planDuplicateRepair(existing: string): PatchRepair | undefined;
36
126
  /**
37
127
  * Mount the roster in one profile, unless it is already composed.
38
128
  *
@@ -42,6 +132,20 @@ export declare function rosterPatchText(existing: string): string | undefined;
42
132
  * half-written patch would only be seen by the next launch.
43
133
  * @param home - the harness home carrying `profiles/`.
44
134
  * @param profile - the profile to patch.
135
+ * @param missing - the rows to mount; defaults to the whole roster.
45
136
  * @returns `present` when the row already exists, else `written`.
46
137
  */
47
- export declare function ensureRosterRows(home: string, profile: string): Promise<'present' | 'written'>;
138
+ export declare function ensureRosterRows(home: string, profile: string, missing?: readonly RosterRow[]): Promise<'present' | 'written'>;
139
+ /**
140
+ * Write a repaired patch next to a copy of the previous content.
141
+ *
142
+ * `/doctor` changes a file the user owns, so the old text is kept beside it
143
+ * before anything is replaced. A file that does not exist yet needs no backup.
144
+ * @param path - the patch file to replace.
145
+ * @param text - the new content.
146
+ * @param stamp - filename-safe timestamp for the backup, injectable for tests.
147
+ * @returns the backup path, or `undefined` when there was nothing to back up.
148
+ */
149
+ export declare function writePatchWithBackup(path: string, text: string, stamp?: string): Promise<string | undefined>;
150
+ /** The patch text the roster block would produce on its own, for callers that report it. */
151
+ export declare function rosterPatchText(existing: string): string | undefined;