dsh-ssh-tui 0.7.1 → 0.7.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 (63) hide show
  1. package/README.en.md +76 -7
  2. package/README.md +50 -7
  3. package/lib/approval-cache.js +12 -9
  4. package/lib/approval-cache.js.map +1 -1
  5. package/lib/color-depth.js +10 -4
  6. package/lib/color-depth.js.map +1 -1
  7. package/lib/diag.js +41 -17
  8. package/lib/diag.js.map +1 -1
  9. package/lib/dialogs.js.map +1 -1
  10. package/lib/display-sock.js +68 -10
  11. package/lib/display-sock.js.map +1 -1
  12. package/lib/footer.js +35 -7
  13. package/lib/footer.js.map +1 -1
  14. package/lib/gateway-protocol.js +202 -0
  15. package/lib/gateway-protocol.js.map +1 -0
  16. package/lib/i18n/en.js +61 -41
  17. package/lib/i18n/en.js.map +1 -1
  18. package/lib/i18n/zh.js +61 -41
  19. package/lib/i18n/zh.js.map +1 -1
  20. package/lib/index.js +99 -23
  21. package/lib/index.js.map +1 -1
  22. package/lib/job-label.js +99 -17
  23. package/lib/job-label.js.map +1 -1
  24. package/lib/plan.js +269 -17
  25. package/lib/plan.js.map +1 -1
  26. package/lib/platform.js +88 -0
  27. package/lib/platform.js.map +1 -0
  28. package/lib/provider-catalog.js +3 -0
  29. package/lib/provider-catalog.js.map +1 -1
  30. package/lib/route-memory.js +8 -1
  31. package/lib/route-memory.js.map +1 -1
  32. package/lib/session-list.js +18 -1
  33. package/lib/session-list.js.map +1 -1
  34. package/lib/session-lock.js +75 -23
  35. package/lib/session-lock.js.map +1 -1
  36. package/lib/session-route.js +331 -0
  37. package/lib/session-route.js.map +1 -0
  38. package/lib/subagent-model.js +79 -1
  39. package/lib/subagent-model.js.map +1 -1
  40. package/lib/term-text.js +7 -4
  41. package/lib/term-text.js.map +1 -1
  42. package/lib/tool-present.js +46 -14
  43. package/lib/tool-present.js.map +1 -1
  44. package/lib/tui.js +1789 -275
  45. package/lib/tui.js.map +1 -1
  46. package/lib/types/color-depth.d.ts +3 -3
  47. package/lib/types/dialogs.d.ts +4 -0
  48. package/lib/types/display-sock.d.ts +52 -1
  49. package/lib/types/footer.d.ts +18 -4
  50. package/lib/types/gateway-protocol.d.ts +88 -0
  51. package/lib/types/job-label.d.ts +38 -7
  52. package/lib/types/plan.d.ts +94 -4
  53. package/lib/types/platform.d.ts +50 -0
  54. package/lib/types/route-memory.d.ts +17 -22
  55. package/lib/types/session-list.d.ts +5 -0
  56. package/lib/types/session-lock.d.ts +9 -0
  57. package/lib/types/session-route.d.ts +191 -0
  58. package/lib/types/subagent-model.d.ts +53 -0
  59. package/lib/types/term-text.d.ts +3 -2
  60. package/lib/types/tool-present.d.ts +2 -17
  61. package/lib/types/transcript-types.d.ts +44 -1
  62. package/lib/types/tui.d.ts +292 -2
  63. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Per-session provider/model memory.
3
+ *
4
+ * The parent route and the subagent route used to be one global choice: `/model`
5
+ * and `/submodel` wrote a default that every later session inherited. Once a
6
+ * child could run on a *different* supplier that stopped being enough — resuming
7
+ * an old conversation silently re-ran it on whatever the last conversation used,
8
+ * and there was no way to see which supplier had paid for the earlier one.
9
+ *
10
+ * Each session's effective route is recorded here, next to the TUI's other
11
+ * per-session state under `$DSH_HOME`, and applied when that session starts
12
+ * again. A new session still starts from the global default; the record only
13
+ * ever overrides it for the session it belongs to, and never for another one.
14
+ *
15
+ * The record is a cache of a decision, not a source of truth: every field is
16
+ * parsed defensively, and a route whose provider no longer exists is still
17
+ * applied (the launcher reports the failure the same way it would for a CLI
18
+ * flag).
19
+ *
20
+ * @module dsh-ssh-tui/session-route
21
+ */
22
+ import { type SubagentSelectionRef } from './subagent-model.js';
23
+ /** Subagent route as this session ran it; no provider means "follows parent". */
24
+ export interface SessionSubagentRoute {
25
+ provider?: string;
26
+ model: string;
27
+ reasoningEffort?: string;
28
+ }
29
+ /** One session's route: what `/model`, `/provider`, `/submodel` settled on. */
30
+ export interface SessionRoute {
31
+ provider: string;
32
+ model: string;
33
+ reasoningEffort?: string;
34
+ subagent?: SessionSubagentRoute;
35
+ /** Epoch ms of the last write; the oldest records are pruned first. */
36
+ updatedAt: number;
37
+ }
38
+ /**
39
+ * How many sessions keep a record. The file is a convenience cache, and a
40
+ * hundred-odd conversations is more history than a resume picker offers.
41
+ */
42
+ export declare const SESSION_ROUTE_LIMIT = 200;
43
+ export declare function sessionRoutePath(dshHome?: string): string;
44
+ /** Parse one record; anything that cannot name a route is dropped. */
45
+ export declare function parseSessionRoute(raw: unknown): SessionRoute | undefined;
46
+ /** Parse the whole file; a file from another version reads as empty. */
47
+ export declare function parseSessionRoutes(raw: unknown): Map<string, SessionRoute>;
48
+ export declare function loadSessionRoutes(path?: string): Promise<Map<string, SessionRoute>>;
49
+ /** The record for one session, or undefined when it never had one. */
50
+ export declare function loadSessionRoute(sessionId: string, path?: string): Promise<SessionRoute | undefined>;
51
+ /** Keep the newest `limit` records, so the file cannot grow without bound. */
52
+ export declare function pruneSessionRoutes(entries: ReadonlyMap<string, SessionRoute>, limit?: number): Map<string, SessionRoute>;
53
+ export declare function saveSessionRoutes(path: string, entries: ReadonlyMap<string, SessionRoute>): Promise<boolean>;
54
+ /**
55
+ * Record one session's route, leaving every other session alone.
56
+ *
57
+ * Read-modify-write on purpose: two Hosts (two SSH windows) can be open at
58
+ * once, and neither may drop the other's entry.
59
+ */
60
+ export declare function saveSessionRoute(sessionId: string, route: Omit<SessionRoute, 'updatedAt'>, path?: string): Promise<boolean>;
61
+ /** Whether two records describe the same route (the timestamp is ignored). */
62
+ export declare function sameSessionRoute(left: SessionRoute | undefined, right: Omit<SessionRoute, 'updatedAt'> | undefined): boolean;
63
+ /**
64
+ * Shape a live parent route plus the subagent selection into a record.
65
+ *
66
+ * Shared by the TUI (which reports what its refs hold) and the launcher (which
67
+ * writes it), so the two cannot disagree about what a session was running on.
68
+ * An empty provider or model yields nothing: a record that cannot name a route
69
+ * would come back as a lie.
70
+ */
71
+ export declare function sessionRouteInput(input: {
72
+ provider: string;
73
+ model: string;
74
+ reasoningEffort?: string;
75
+ subagent?: {
76
+ provider?: string;
77
+ model: string;
78
+ reasoningEffort?: string;
79
+ };
80
+ }): Omit<SessionRoute, 'updatedAt'> | undefined;
81
+ /**
82
+ * Providers the harness ships with. They are routable in any install even when
83
+ * the adapter list is still warming up, so a record naming one is never treated
84
+ * as stale on that basis alone.
85
+ */
86
+ export declare const BUILTIN_ROUTABLE_PROVIDERS: readonly ["deepseek-official", "xai", "opencode-go", "opencode"];
87
+ /**
88
+ * Whether this install can still route to a provider.
89
+ *
90
+ * Deliberately permissive: the cost of wrongly *keeping* a record is an error the
91
+ * harness already reports clearly (an unknown route, the same one a launch flag
92
+ * would hit), while wrongly *dropping* one silently loses the session's route —
93
+ * which is the whole feature. So a provider counts as routable when it is a
94
+ * built-in, appears in the adapter list, has a configured `llm-pi-ai` profile, or
95
+ * is listed by the adapter's own model catalog.
96
+ */
97
+ export declare function providerIsRoutable(input: {
98
+ provider: string;
99
+ /** Ids from `llm.listProviders()`. */
100
+ listed?: readonly string[];
101
+ /** Ids with an `llm-pi-ai.providers.<id>` section. */
102
+ configured?: readonly string[];
103
+ }): boolean;
104
+ /** What a resumed session's record is worth in this install. */
105
+ export interface RouteAvailabilityPlan {
106
+ /** The parent route, when the provider it names can still be routed to. */
107
+ route?: SessionRoute;
108
+ /** The subagent route, when its own pin can be routed to as well. */
109
+ subagent?: SessionSubagentRoute;
110
+ /** Recorded parent provider that is gone; the caller says so on screen. */
111
+ droppedProvider?: string;
112
+ /** Recorded subagent pin that is gone; the app-level setting takes over. */
113
+ droppedSubagentProvider?: string;
114
+ }
115
+ /**
116
+ * Decide what of a recorded route still applies.
117
+ *
118
+ * A provider that no longer exists takes its whole record with it: the session
119
+ * was configured around that supplier (its model, its effort, and the children
120
+ * it pinned), so the honest answer is the app-level default for all of it rather
121
+ * than a half-restored route. A pinned *child* provider that is gone is narrower:
122
+ * the parent route is kept and the children fall back to the app-level subagent
123
+ * setting. An inherited child route has no pin to check — it follows the parent,
124
+ * which was just found routable.
125
+ */
126
+ export declare function routeAvailabilityPlan(input: {
127
+ recorded?: SessionRoute;
128
+ routable: (provider: string) => boolean;
129
+ }): RouteAvailabilityPlan;
130
+ /**
131
+ * Apply a resumed session's record to the live selection.
132
+ *
133
+ * Returns what the record was worth (see `routeAvailabilityPlan`) so the
134
+ * launcher can say which route the session came back on — and, when a supplier
135
+ * has been removed since, which one it fell back from. A new session is left
136
+ * alone, and so is a resumed one that never had a record: both start from the
137
+ * app-level defaults, which is the behaviour before any of this existed.
138
+ *
139
+ * The subagent route is applied in memory only: it belongs to the conversation
140
+ * being resumed, and writing it back to the settings would make one session's
141
+ * pin the default for every later session.
142
+ */
143
+ export declare function restoreSessionRoute(input: {
144
+ sessionId: string;
145
+ resume: boolean;
146
+ subagentSelection: SubagentSelectionRef;
147
+ path?: string;
148
+ /** Availability test; omitted means "everything is routable". */
149
+ routable?: (provider: string) => boolean;
150
+ }): Promise<RouteAvailabilityPlan>;
151
+ /** One remembered route as the launch waterfall sees it. */
152
+ export interface LaunchRouteSource {
153
+ provider: string;
154
+ model: string;
155
+ reasoningEffort?: string;
156
+ }
157
+ export interface LaunchRouteInput {
158
+ /** In-process change from `/model` or `/setup` earlier in this process. */
159
+ live?: LaunchRouteSource;
160
+ /** Launch flags (`--provider` / `--model`). */
161
+ cli?: {
162
+ provider?: string;
163
+ model?: string;
164
+ };
165
+ /** This session's own record, when the launch resumes it. */
166
+ session?: SessionRoute;
167
+ /** Global default (the settings file, or `agent-default-model`). */
168
+ saved?: LaunchRouteSource;
169
+ /** Last model per provider, used only when no user default section exists. */
170
+ remembered?: LaunchRouteSource;
171
+ }
172
+ export interface LaunchRoute {
173
+ provider: string;
174
+ model: string;
175
+ reasoningEffort?: string;
176
+ }
177
+ /**
178
+ * The route a launch should run on, in one place.
179
+ *
180
+ * Precedence: an in-process change, then launch flags, then the session's own
181
+ * record, then the global default, then the last remembered route. A resumed
182
+ * conversation therefore comes back on the supplier it was actually using, while
183
+ * a new one still starts from the default — and an explicit flag still wins,
184
+ * because that is this launch's ask.
185
+ *
186
+ * The reasoning effort travels with the route it was chosen for: it is taken
187
+ * from whichever source supplied the provider/model, and only when the flags did
188
+ * not move the route away from it. A CLI that changes either half must not
189
+ * inherit the saved effort of a different model.
190
+ */
191
+ export declare function resolveLaunchRoute(input: LaunchRouteInput): LaunchRoute;
@@ -24,6 +24,33 @@ export declare const DEFAULT_XAI_SUBAGENT_MODEL = "grok-4.5";
24
24
  * with flash-like ids ranked first.
25
25
  */
26
26
  export declare function defaultSubagentModelForProvider(provider: string, listed?: readonly string[], parentModel?: string): string;
27
+ /**
28
+ * Canonical id of the supplier behind a route.
29
+ *
30
+ * The settings file can name the same supplier two ways — `xai` against
31
+ * `grok`, `deepseek-official` against `deepseek` — and a `/submodel` pin that
32
+ * spells the parent's own provider differently is still the parent's provider.
33
+ * Only those aliases collapse: an id the TUI cannot vouch for (`deepseek-eu`,
34
+ * `grok-mirror`, a relay) stays itself, because two routes it cannot prove
35
+ * share a bill are two providers.
36
+ */
37
+ export declare function canonicalProviderId(provider: string | undefined): string;
38
+ /**
39
+ * True when `child` is a different supplier from `parent`.
40
+ *
41
+ * This is the one condition that earns a subagent an accent colour: a child
42
+ * that follows the parent — or that was pinned back onto the parent's own
43
+ * provider — is on the parent's route and must read as such.
44
+ */
45
+ export declare function subagentProviderDiffers(parent: string | undefined, child: string | undefined): boolean;
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.
50
+ */
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;
27
54
  /**
28
55
  * True when the stored subagent model still belongs to the parent provider
29
56
  * family. An explicit leftover DeepSeek flash id after switching to xAI is
@@ -75,7 +102,33 @@ export interface SubagentSelection {
75
102
  /** Mutable selection handle shared by the settings watcher and the TUI. */
76
103
  export interface SubagentSelectionRef {
77
104
  current: SubagentSelection;
105
+ /**
106
+ * Where `current` came from.
107
+ *
108
+ * A route restored from a session's own record (`session`) outranks the
109
+ * settings watcher: the host fires `onChange` on writes to *any* section, so a
110
+ * `/model` or a `/doctor --fix` would otherwise put the app-level default back
111
+ * and silently drop the pin the conversation was resumed with. A user change
112
+ * through `/submodel` hands ownership back to the settings.
113
+ */
114
+ source?: 'settings' | 'session';
78
115
  }
116
+ /**
117
+ * Adopt a session's own subagent route, outranking the settings watcher.
118
+ * @param ref - the shared selection handle.
119
+ * @param selection - the route the session's record names.
120
+ */
121
+ export declare function adoptSessionSubagentSelection(ref: SubagentSelectionRef, selection: SubagentSelection): void;
122
+ /**
123
+ * Hand the ref back to the settings watcher.
124
+ *
125
+ * Called when a user change makes the settings the source again, and before a
126
+ * new session starts in this process — a session-scoped route must not leak into
127
+ * the next conversation. Only a readable section may replace the current value:
128
+ * resetting on a settings service that is not up yet would swap the route for
129
+ * the built-in default instead.
130
+ */
131
+ export declare function releaseSessionSubagentSelection(ref: SubagentSelectionRef, settingsValue: unknown): void;
79
132
  /** Settings schema for `$DSH_HOME/settings.yaml`. */
80
133
  export declare const SUBAGENT_SETTINGS_SCHEMA: z<Schemastery.ObjectS<{
81
134
  provider: z<string, string>;
@@ -4,6 +4,7 @@
4
4
  * Isolated so the launch session picker can clip labels without loading
5
5
  * the interactive TUI class.
6
6
  */
7
+ import { type ColorDepth } from './color-depth.js';
7
8
  /**
8
9
  * Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
9
10
  * Used by the workspace wait card while the model has not streamed yet.
@@ -116,9 +117,9 @@ export declare function wrapTracked(text: string, width: number): {
116
117
  end: number;
117
118
  }[];
118
119
  /** Paint one already-wrapped output line by the segments overlapping its range. */
119
- export declare function paintSegmentedLine(line: string, start: number, end: number, segments: readonly TextSegment[]): string;
120
+ export declare function paintSegmentedLine(line: string, start: number, end: number, segments: readonly TextSegment[], depth?: ColorDepth): string;
120
121
  /** Wrap `text` and color each output line by overlapping `segments`. */
121
- export declare function wrapSegmented(text: string, width: number, segments: readonly TextSegment[]): string[];
122
+ export declare function wrapSegmented(text: string, width: number, segments: readonly TextSegment[], depth?: ColorDepth): string[];
122
123
  export declare function truncate(text: string, maxLines: number): string;
123
124
  /** One OSC-8 hyperlink span in a painted row, in display columns. */
124
125
  export interface PaintedLinkHit {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions';
5
5
  import { type TextSegment } from './term-text.js';
6
- import type { DisplayKind, Row, ToolDiffHunk } from './transcript-types.js';
6
+ import type { DiffDisplayLine, Row, ToolDiffHunk } from './transcript-types.js';
7
7
  export declare const SHELL_TOOL_NAMES: Set<string>;
8
8
  export declare const DIFF_TOOL_NAMES: Set<string>;
9
9
  export declare const JOB_TOOL_NAMES: Set<string>;
@@ -12,7 +12,6 @@ export declare function formatModelList(models: readonly string[], max?: number)
12
12
  /** Prefer the fields a human scans for; fall back to the first scalar pairs. */
13
13
  export declare function friendlyArgsSummary(name: string, args: string): string;
14
14
  export declare function countDiffLines(hunks: readonly ToolDiffHunk[] | undefined): number;
15
- /** Added / removed line counts for a diff (`oldText: null` means a new file). */
16
15
  export declare function countDiffAddDel(hunks: readonly ToolDiffHunk[] | undefined): {
17
16
  add: number;
18
17
  del: number;
@@ -121,21 +120,7 @@ export declare function presentToolCall(name: string, args: string): {
121
120
  export declare function diffMetaDiffs(meta: unknown): ToolDiffHunk[] | null;
122
121
  /** Split one diff side into content lines (trailing newline is a terminator). */
123
122
  export declare function diffContentLines(text: string): string[];
124
- /** One rendered diff body line with its display role. */
125
- export interface DiffDisplayLine {
126
- kind: DisplayKind;
127
- text: string;
128
- /**
129
- * Ranges of `text` to emphasise, in UTF-16 offsets. They travel with the line
130
- * rather than being baked in as escapes because the painter sanitises the text
131
- * it styles — an escape inserted down here would be stripped, leaving a
132
- * literal `[7m` on screen.
133
- */
134
- spans?: readonly {
135
- start: number;
136
- end: number;
137
- }[];
138
- }
123
+ export type { DiffDisplayLine } from './transcript-types.js';
139
124
  /** Cap one flat diff/body row list to `maxLines` while preserving the final line. */
140
125
  export declare function capDisplayLines(lines: readonly DiffDisplayLine[], maxLines: number): DiffDisplayLine[];
141
126
  /** Running / ok / error → ANSI color for the status dot and status word only. */
@@ -8,6 +8,10 @@ export type SubagentLogKind = 'user' | 'assistant' | 'tool' | 'result' | 'turn'
8
8
  export interface SubagentLogEntry {
9
9
  kind: SubagentLogKind;
10
10
  text: string;
11
+ /** Parent tool-call id, so a later result can settle on the same log line. */
12
+ callId?: string;
13
+ /** Result body kept beside the call line, when the caller has one. */
14
+ detail?: string;
11
15
  }
12
16
  /** One todo-list item as the plan card renders it. */
13
17
  export interface PlanTodoItem {
@@ -62,14 +66,24 @@ export type Row = {
62
66
  } | {
63
67
  kind: 'subagent';
64
68
  sessionId: string;
69
+ /** Live child id when this chip was first built from the parent spawn tool. */
70
+ childSessionId?: string;
71
+ /** Parent spawn tool-call id, so the wrapper's result can find this child. */
72
+ spawnCallId?: string;
73
+ /** Model route the child runs on. Never the subagent backend name (`spawn`). */
74
+ modelProvider?: string;
65
75
  runId: string;
66
76
  provider: string;
67
77
  local: boolean;
68
78
  label: string;
79
+ /** Parent `subagent` tool description, when the spawn call named the job. */
80
+ task?: string;
69
81
  status: 'running' | 'ok' | 'error' | 'aborted';
70
82
  startedAt: number;
71
83
  endedAt?: number;
72
84
  stopReason?: string;
85
+ /** One-line failure explanation shown on the collapsed chip. */
86
+ failHint?: string;
73
87
  lastActivity: string;
74
88
  logs: SubagentLogEntry[];
75
89
  expanded: boolean;
@@ -161,7 +175,36 @@ export type CollapsibleBlock = Extract<Row, {
161
175
  kind: 'streaming-reasoning';
162
176
  expanded: boolean;
163
177
  };
164
- export type DisplayKind = Row['kind'] | 'tool-result' | 'diff-add' | 'diff-del' | 'diff-path' | 'todo-done' | 'todo-active' | 'todo-pending' | 'todo-failed' | 'todo-skipped' | 'plan-dock';
178
+ export type DisplayKind = Row['kind'] | 'tool-result' | 'diff-add' | 'diff-del' | 'diff-path' | 'todo-done' | 'todo-active' | 'todo-pending' | 'todo-failed' | 'todo-skipped' | 'plan-dock' | 'subagent-header';
179
+ /** One rendered diff/inspect body line with its display role. */
180
+ export interface DiffDisplayLine {
181
+ kind: DisplayKind;
182
+ text: string;
183
+ /**
184
+ * Ranges of `text` to emphasise, in UTF-16 offsets. They travel with the line
185
+ * rather than being baked in as escapes because the painter sanitises the text
186
+ * it styles — an escape inserted down here would be stripped, leaving a
187
+ * literal `[7m` on screen.
188
+ */
189
+ spans?: readonly {
190
+ start: number;
191
+ end: number;
192
+ }[];
193
+ /**
194
+ * Parts of `text` that carry their own role, for a line that holds two at
195
+ * once: the columns of a side-by-side diff are a removal on the left and an
196
+ * addition on the right, and painting the row as either one would mislabel
197
+ * half of it. Offsets are UTF-16 into `text`; anything not covered keeps the
198
+ * line's own `kind`.
199
+ */
200
+ columns?: readonly DiffDisplayColumn[];
201
+ }
202
+ /** One part of a line, painted with its own display role. */
203
+ export interface DiffDisplayColumn {
204
+ start: number;
205
+ end: number;
206
+ kind: DisplayKind;
207
+ }
165
208
  /** One file's change, matching the web diff-card contract (`card: 'diff'`). */
166
209
  export interface ToolDiffHunk {
167
210
  path: string;