dsh-ssh-tui 0.5.9 → 0.6.0
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.
- package/README.en.md +28 -5
- package/README.md +76 -6
- package/lib/attach.js +156 -0
- package/lib/attach.js.map +1 -0
- package/lib/commands.js +73 -0
- package/lib/commands.js.map +1 -0
- package/lib/diag.js +292 -0
- package/lib/diag.js.map +1 -0
- package/lib/dialogs.js +82 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/display-sock.js +304 -86
- package/lib/display-sock.js.map +1 -1
- package/lib/dsh-compat.js +25 -4
- package/lib/dsh-compat.js.map +1 -1
- package/lib/i18n/en.js +55 -0
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +2 -0
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +55 -0
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +75 -73
- package/lib/index.js.map +1 -1
- package/lib/paint.js +20 -38
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +152 -37
- package/lib/picker.js.map +1 -1
- package/lib/rows.js +120 -0
- package/lib/rows.js.map +1 -0
- package/lib/session-index.js +4 -1
- package/lib/session-index.js.map +1 -1
- package/lib/session-list.js +372 -167
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +20 -9
- package/lib/session-lock.js.map +1 -1
- package/lib/stats.js +136 -0
- package/lib/stats.js.map +1 -0
- package/lib/terminal-input.js +470 -0
- package/lib/terminal-input.js.map +1 -0
- package/lib/tui.js +257 -311
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +106 -0
- package/lib/types/commands.d.ts +103 -0
- package/lib/types/diag.d.ts +78 -0
- package/lib/types/dialogs.d.ts +79 -0
- package/lib/types/display-sock.d.ts +59 -3
- package/lib/types/dsh-compat.d.ts +7 -0
- package/lib/types/i18n/index.d.ts +4 -0
- package/lib/types/index.d.ts +8 -4
- package/lib/types/paint.d.ts +6 -3
- package/lib/types/picker.d.ts +27 -7
- package/lib/types/rows.d.ts +69 -0
- package/lib/types/session-list.d.ts +43 -3
- package/lib/types/stats.d.ts +98 -0
- package/lib/types/terminal-input.d.ts +164 -0
- package/lib/types/tui.d.ts +33 -8
- package/package.json +11 -5
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's attach/reconnect state machine, kept out of the bundle entry
|
|
3
|
+
* so it can be driven with fakes.
|
|
4
|
+
*
|
|
5
|
+
* This is the code that decides whether a launcher keeps fighting for a
|
|
6
|
+
* session's display. It used to answer a bare `close` from the Host with a
|
|
7
|
+
* retry — and since the Host answers a *replacement* by closing the previous
|
|
8
|
+
* relay, two SSH windows kicked each other off the display in a loop: every
|
|
9
|
+
* lap repainted the whole screen, every lap left the TTY cooked long enough to
|
|
10
|
+
* echo the DSR replies still in flight as `^[[17;1R`, and neither window could
|
|
11
|
+
* be typed into. `replaced` is now an exit, and the burst breaker below stops
|
|
12
|
+
* any leftover launcher from an older release that would ignore it.
|
|
13
|
+
*/
|
|
14
|
+
/** Why one relay session ended. */
|
|
15
|
+
export type RelayReason = 'goodbye' | 'host-closed' | 'signal' | 'replaced';
|
|
16
|
+
/** A relay that dies this soon after connecting reached a Host that was leaving. */
|
|
17
|
+
export declare const ATTACH_RECOVERY_WINDOW_MS = 5000;
|
|
18
|
+
/** How long to let a mid-dispose Host finish before starting a fresh one. */
|
|
19
|
+
export declare const ATTACH_RECOVERY_WAIT_MS = 3000;
|
|
20
|
+
/** Automatic re-attaches allowed inside this window before we stop trying. */
|
|
21
|
+
export declare const RECOVERY_BURST_WINDOW_MS = 10000;
|
|
22
|
+
export declare const RECOVERY_BURST_LIMIT = 3;
|
|
23
|
+
/** How long to wait for a freshly spawned Host to accept on its channel. */
|
|
24
|
+
export declare const HOST_START_TIMEOUT_MS = 15000;
|
|
25
|
+
/** True when a relay error means the peer vanished rather than a real fault. */
|
|
26
|
+
export declare function attachPeerVanished(error: unknown, elapsedMs: number): boolean;
|
|
27
|
+
export interface LiveHost {
|
|
28
|
+
kind: 'attachable' | 'zombie';
|
|
29
|
+
sock: string;
|
|
30
|
+
pid: number;
|
|
31
|
+
exitWatch?: {
|
|
32
|
+
dispose(): void;
|
|
33
|
+
exited: Promise<number | null>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface SpawnedDisplayHost {
|
|
37
|
+
sock: string;
|
|
38
|
+
pid: number;
|
|
39
|
+
errFile?: string;
|
|
40
|
+
exitWatch: {
|
|
41
|
+
dispose(): void;
|
|
42
|
+
exited: Promise<number | null>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Everything the state machine touches; the bundle entry supplies the real ones. */
|
|
46
|
+
export interface AttacherDeps {
|
|
47
|
+
/**
|
|
48
|
+
* Run one relay; `seed` is typing captured while no display existed, and
|
|
49
|
+
* `announce` asks it to erase the status line before the first paint.
|
|
50
|
+
*/
|
|
51
|
+
relay(sock: string, seed: string, announce: boolean): Promise<{
|
|
52
|
+
reason: RelayReason;
|
|
53
|
+
}>;
|
|
54
|
+
/** Keep the TTY in raw mode and drop what is queued (stale replies, keys). */
|
|
55
|
+
quiet(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Start keeping what the user types. A fresh Host takes a moment to boot,
|
|
58
|
+
* and the old code left stdin flowing with nobody listening, so everything
|
|
59
|
+
* typed in that window was silently dropped.
|
|
60
|
+
*/
|
|
61
|
+
beginCapture?(): void;
|
|
62
|
+
/** Stop capturing and return the typing ('' when unsupported). */
|
|
63
|
+
endCapture?(): string;
|
|
64
|
+
inspectLiveHost(sessionId: string): Promise<LiveHost | undefined>;
|
|
65
|
+
spawnHost(sessionId: string): SpawnedDisplayHost;
|
|
66
|
+
waitForDisplaySock(spawned: SpawnedDisplayHost): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* One already-formatted status line for stderr. `transient` marks a line the
|
|
69
|
+
* relay will erase before the first paint (see `DisplayRelayOptions.announce`).
|
|
70
|
+
*/
|
|
71
|
+
report(message: string, transient?: boolean): void;
|
|
72
|
+
exit(code: number): void;
|
|
73
|
+
/** `t(...)` for the two state machine messages. */
|
|
74
|
+
messages: {
|
|
75
|
+
connecting(sessionId: string): string;
|
|
76
|
+
recovering(sessionId: string): string;
|
|
77
|
+
replaced(sessionId: string): string;
|
|
78
|
+
flapping(sessionId: string): string;
|
|
79
|
+
zombie(sessionId: string, pid: number): string;
|
|
80
|
+
};
|
|
81
|
+
locksDisabled?(): boolean;
|
|
82
|
+
now?(): number;
|
|
83
|
+
debug?: boolean;
|
|
84
|
+
/** Override the burst breaker window/limit (tests). */
|
|
85
|
+
burst?: {
|
|
86
|
+
windowMs?: number;
|
|
87
|
+
limit?: number;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export interface Attacher {
|
|
91
|
+
/**
|
|
92
|
+
* Attach to one Host channel. `announce` writes the "attaching" status line
|
|
93
|
+
* for a user waiting on their own terminal; it is off for automatic retries
|
|
94
|
+
* and takeovers, whose terminal may be a dead link that would later flush
|
|
95
|
+
* whatever was written into it.
|
|
96
|
+
*/
|
|
97
|
+
attachExisting(sessionId: string, sock: string, recover?: boolean, options?: {
|
|
98
|
+
announce?: boolean;
|
|
99
|
+
seed?: string;
|
|
100
|
+
}): Promise<void>;
|
|
101
|
+
/** Attach to a live Host on this session, or spawn a fresh one and attach. */
|
|
102
|
+
attachOrSpawn(sessionId: string, recover?: boolean): Promise<void>;
|
|
103
|
+
/** Recoveries recorded in the current burst window (tests). */
|
|
104
|
+
readonly recoveries: number;
|
|
105
|
+
}
|
|
106
|
+
export declare function createAttacher(deps: AttacherDeps): Attacher;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** This plugin's own commands, in suggestion order. */
|
|
2
|
+
export declare const LOCAL_COMMANDS: readonly [{
|
|
3
|
+
readonly name: "help";
|
|
4
|
+
readonly key: "cmd.help";
|
|
5
|
+
}, {
|
|
6
|
+
readonly name: "model";
|
|
7
|
+
readonly key: "cmd.model";
|
|
8
|
+
}, {
|
|
9
|
+
readonly name: "effort";
|
|
10
|
+
readonly key: "cmd.effort";
|
|
11
|
+
}, {
|
|
12
|
+
readonly name: "provider";
|
|
13
|
+
readonly key: "cmd.provider";
|
|
14
|
+
}, {
|
|
15
|
+
readonly name: "submodel";
|
|
16
|
+
readonly key: "cmd.submodel";
|
|
17
|
+
}, {
|
|
18
|
+
readonly name: "subeffort";
|
|
19
|
+
readonly key: "cmd.subeffort";
|
|
20
|
+
}, {
|
|
21
|
+
readonly name: "mode";
|
|
22
|
+
readonly key: "cmd.mode";
|
|
23
|
+
}, {
|
|
24
|
+
readonly name: "quit";
|
|
25
|
+
readonly key: "cmd.quit";
|
|
26
|
+
}, {
|
|
27
|
+
readonly name: "exit";
|
|
28
|
+
readonly key: "cmd.quit";
|
|
29
|
+
readonly aliasOf: "quit";
|
|
30
|
+
}, {
|
|
31
|
+
readonly name: "clear";
|
|
32
|
+
readonly key: "cmd.clear";
|
|
33
|
+
}, {
|
|
34
|
+
readonly name: "status";
|
|
35
|
+
readonly key: "cmd.status";
|
|
36
|
+
}, {
|
|
37
|
+
readonly name: "diag";
|
|
38
|
+
readonly key: "cmd.diag";
|
|
39
|
+
}, {
|
|
40
|
+
readonly name: "disconnect";
|
|
41
|
+
readonly key: "cmd.disconnect";
|
|
42
|
+
}, {
|
|
43
|
+
readonly name: "approval";
|
|
44
|
+
readonly key: "cmd.approval";
|
|
45
|
+
}, {
|
|
46
|
+
readonly name: "view";
|
|
47
|
+
readonly key: "cmd.view";
|
|
48
|
+
}, {
|
|
49
|
+
readonly name: "usage";
|
|
50
|
+
readonly key: "cmd.usage";
|
|
51
|
+
}, {
|
|
52
|
+
readonly name: "balance";
|
|
53
|
+
readonly key: "cmd.usage";
|
|
54
|
+
readonly aliasOf: "usage";
|
|
55
|
+
}, {
|
|
56
|
+
readonly name: "quota";
|
|
57
|
+
readonly key: "cmd.usage";
|
|
58
|
+
readonly aliasOf: "usage";
|
|
59
|
+
}, {
|
|
60
|
+
readonly name: "subagents";
|
|
61
|
+
readonly key: "cmd.subagents";
|
|
62
|
+
}, {
|
|
63
|
+
readonly name: "resume";
|
|
64
|
+
readonly key: "cmd.resume";
|
|
65
|
+
}, {
|
|
66
|
+
readonly name: "setup";
|
|
67
|
+
readonly key: "cmd.setup";
|
|
68
|
+
}, {
|
|
69
|
+
readonly name: "find";
|
|
70
|
+
readonly key: "cmd.find";
|
|
71
|
+
}, {
|
|
72
|
+
readonly name: "copy";
|
|
73
|
+
readonly key: "cmd.copy";
|
|
74
|
+
}, {
|
|
75
|
+
readonly name: "language";
|
|
76
|
+
readonly key: "cmd.language";
|
|
77
|
+
}, {
|
|
78
|
+
readonly name: "lang";
|
|
79
|
+
readonly key: "cmd.language";
|
|
80
|
+
readonly aliasOf: "language";
|
|
81
|
+
}, {
|
|
82
|
+
readonly name: "dialog-test";
|
|
83
|
+
readonly key: "cmd.dialog-test";
|
|
84
|
+
}];
|
|
85
|
+
export interface LocalizedCommand {
|
|
86
|
+
name: string;
|
|
87
|
+
description: string;
|
|
88
|
+
aliasOf?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface CommandSuggestion {
|
|
91
|
+
name: string;
|
|
92
|
+
description: string;
|
|
93
|
+
local: boolean;
|
|
94
|
+
}
|
|
95
|
+
export declare function commandDescription(name: string, aliasOf?: string): string;
|
|
96
|
+
export declare function localizedCommands(): LocalizedCommand[];
|
|
97
|
+
/**
|
|
98
|
+
* Suggestions for the current input: only a `/`-prefixed line suggests
|
|
99
|
+
* anything, aliases are held back until a prefix is typed, and the host's
|
|
100
|
+
* commands follow this plugin's (a local name wins the duplicate). A typed
|
|
101
|
+
* prefix ranks names that start with it above names that merely contain it.
|
|
102
|
+
*/
|
|
103
|
+
export declare function commandSuggestions(input: string, foreign: readonly CommandSuggestion[]): CommandSuggestion[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** How much of a Host stderr log the report carries. */
|
|
2
|
+
export declare const DIAG_ERR_TAIL_BYTES = 2000;
|
|
3
|
+
export interface DiagHostState {
|
|
4
|
+
/** 'none' — no lock file; 'live' — owner alive and channel reachable;
|
|
5
|
+
* 'zombie' — owner alive but the channel does not answer;
|
|
6
|
+
* 'stale' — lock exists but its owner is gone (stealable). */
|
|
7
|
+
kind: 'none' | 'live' | 'zombie' | 'stale';
|
|
8
|
+
pid?: number;
|
|
9
|
+
/** Lock state as written by the Host (`attached`/`paused`/`running-detached`). */
|
|
10
|
+
state?: string;
|
|
11
|
+
agentStatus?: string;
|
|
12
|
+
disconnectPolicy?: string;
|
|
13
|
+
tty?: string;
|
|
14
|
+
/** Owner identity check: 'verified', 'mismatch', or 'unverifiable'. */
|
|
15
|
+
identity?: 'verified' | 'mismatch' | 'unverifiable';
|
|
16
|
+
lockPath?: string;
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
export interface DiagSnapshot {
|
|
20
|
+
pluginVersion: string;
|
|
21
|
+
hostVersion: string;
|
|
22
|
+
nodeVersion: string;
|
|
23
|
+
platform: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
/** This process is the detached Host rather than the launcher. */
|
|
26
|
+
hostProcess: boolean;
|
|
27
|
+
locksDisabled: boolean;
|
|
28
|
+
dshHome: string;
|
|
29
|
+
sockPath: string;
|
|
30
|
+
/** `true` when a connect to the channel succeeded. */
|
|
31
|
+
sockReachable: boolean;
|
|
32
|
+
/** A socket *file* exists on disk (POSIX only; pipes are invisible to fs). */
|
|
33
|
+
sockFilePresent?: boolean;
|
|
34
|
+
host: DiagHostState;
|
|
35
|
+
link: {
|
|
36
|
+
kind: 'ssh' | 'local';
|
|
37
|
+
rttMs?: number;
|
|
38
|
+
probeState: 'measured' | 'unknown' | 'unprobed';
|
|
39
|
+
paintIntervalMs?: number;
|
|
40
|
+
};
|
|
41
|
+
lockHeldByThisProcess: boolean;
|
|
42
|
+
sessionLog?: {
|
|
43
|
+
format: string;
|
|
44
|
+
bytes: number;
|
|
45
|
+
modifiedAt?: number;
|
|
46
|
+
events?: number;
|
|
47
|
+
seq?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Last lines the Host wrote to its stderr log, if any. */
|
|
50
|
+
errTail?: string;
|
|
51
|
+
/** Locks found under DSH_HOME for *other* sessions (context for "who holds it"). */
|
|
52
|
+
otherLocks: Array<{
|
|
53
|
+
sessionId: string;
|
|
54
|
+
pid: number;
|
|
55
|
+
state?: string;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
/** Tail of a Host stderr log, or undefined when there is nothing to read. */
|
|
59
|
+
export declare function readErrTail(sessionId: string, dshHome?: string): Promise<string | undefined>;
|
|
60
|
+
/**
|
|
61
|
+
* Why `--resume` would behave the way it does, as an ordered chain of verdicts.
|
|
62
|
+
* The first entry is the actionable one; the rest is supporting context.
|
|
63
|
+
*/
|
|
64
|
+
export declare function diagVerdicts(snapshot: DiagSnapshot): string[];
|
|
65
|
+
/** The whole report as transcript lines. Pure. */
|
|
66
|
+
export declare function formatDiag(snapshot: DiagSnapshot): string[];
|
|
67
|
+
/** Gather everything the report needs. Every probe is best-effort. */
|
|
68
|
+
export declare function collectDiag(options: {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
pluginVersion: string;
|
|
71
|
+
hostVersion: string;
|
|
72
|
+
hostProcess: boolean;
|
|
73
|
+
link: DiagSnapshot['link'];
|
|
74
|
+
paintIntervalMs?: number;
|
|
75
|
+
dshHome?: string;
|
|
76
|
+
}): Promise<DiagSnapshot>;
|
|
77
|
+
/** Directory holding the per-session index, for callers that report it. */
|
|
78
|
+
export declare function diagIndexPath(dshHome?: string): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog state rules: the dialog shapes, and what one key does to a question
|
|
3
|
+
* list, a confirm prompt or the inspect overlay.
|
|
4
|
+
*
|
|
5
|
+
* `tui.ts` owns the rendering, the promise plumbing and the agent side; these
|
|
6
|
+
* functions decide cursor moves, selection, and whether Enter means "answer",
|
|
7
|
+
* "cancel" or "send the free-text box". Keeping them here makes the rules
|
|
8
|
+
* testable without a terminal.
|
|
9
|
+
*/
|
|
10
|
+
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions';
|
|
11
|
+
import type { DiffDisplayLine } from './tool-present.js';
|
|
12
|
+
export interface ConfirmDialog {
|
|
13
|
+
kind: 'confirm';
|
|
14
|
+
prompt: string;
|
|
15
|
+
hint: string;
|
|
16
|
+
resolve(value: 'y' | 'n' | 'cancel'): void;
|
|
17
|
+
}
|
|
18
|
+
export interface QuestionDialog {
|
|
19
|
+
kind: 'questions';
|
|
20
|
+
question: AskUserQuestionItem;
|
|
21
|
+
index: number;
|
|
22
|
+
total: number;
|
|
23
|
+
selected: Set<number>;
|
|
24
|
+
cursor: number;
|
|
25
|
+
resolve(selection: {
|
|
26
|
+
selected: string[];
|
|
27
|
+
custom?: string;
|
|
28
|
+
}): void;
|
|
29
|
+
reject(error: unknown): void;
|
|
30
|
+
}
|
|
31
|
+
export interface OnboardingDialog {
|
|
32
|
+
kind: 'onboarding';
|
|
33
|
+
}
|
|
34
|
+
export interface InspectDialog {
|
|
35
|
+
kind: 'inspect';
|
|
36
|
+
title: string;
|
|
37
|
+
lines: DiffDisplayLine[];
|
|
38
|
+
offset: number;
|
|
39
|
+
}
|
|
40
|
+
export type Dialog = ConfirmDialog | QuestionDialog | OnboardingDialog | InspectDialog;
|
|
41
|
+
export interface DialogAnswer {
|
|
42
|
+
selected: string[];
|
|
43
|
+
custom?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Digits then letters: the hotkeys painted next to question options. */
|
|
46
|
+
export declare const QUESTION_OPTION_KEYS = "123456789abcdefghijklmnopqrstuvwxyz";
|
|
47
|
+
/** Options a dialog can answer with; only a question list has any. */
|
|
48
|
+
export declare function optionsLength(dialog: Dialog): number;
|
|
49
|
+
/**
|
|
50
|
+
* The option index a hotkey selects, or undefined when the key is not one of
|
|
51
|
+
* the painted keys or names no option.
|
|
52
|
+
*/
|
|
53
|
+
export declare function questionOptionIndex(key: string, optionCount: number): number | undefined;
|
|
54
|
+
/** Move the question highlight, clamped to its options. Returns false if not a question dialog. */
|
|
55
|
+
export declare function moveQuestionCursor(dialog: QuestionDialog, delta: number): boolean;
|
|
56
|
+
/** Select option `index`: toggles in a multi-select list, replaces otherwise. */
|
|
57
|
+
export declare function selectQuestionOption(dialog: QuestionDialog, index: number): void;
|
|
58
|
+
/** Handle a hotkey: select its option and report whether it applied. */
|
|
59
|
+
export declare function selectQuestionOptionByKey(dialog: QuestionDialog, key: string): boolean;
|
|
60
|
+
export type QuestionSubmit = {
|
|
61
|
+
kind: 'resolve';
|
|
62
|
+
selected: string[];
|
|
63
|
+
custom?: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'reject';
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'none';
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* What Enter means for a question dialog. A single-select list with nothing
|
|
71
|
+
* highlighted cancels (the user must not accidentally answer with the first
|
|
72
|
+
* option); a multi-select list may answer with an empty selection. With no
|
|
73
|
+
* options at all, the typed text is the answer.
|
|
74
|
+
*/
|
|
75
|
+
export declare function questionSubmit(dialog: QuestionDialog, input: string): QuestionSubmit;
|
|
76
|
+
/** y/n/ctrl-c/esc answers for a confirm prompt. */
|
|
77
|
+
export declare function confirmAnswer(text: string): 'y' | 'n' | 'cancel' | undefined;
|
|
78
|
+
/** The inspect overlay closes on any of these, and ignores the rest. */
|
|
79
|
+
export declare function inspectClosesOn(text: string): boolean;
|
|
@@ -4,6 +4,7 @@ export declare const FRAME_RESIZE = 3;
|
|
|
4
4
|
export declare const FRAME_HELLO = 4;
|
|
5
5
|
export declare const FRAME_GOODBYE = 5;
|
|
6
6
|
export declare const FRAME_RTT = 6;
|
|
7
|
+
export declare const FRAME_REPLACED = 7;
|
|
7
8
|
/**
|
|
8
9
|
* Drop launcher SIGTERM/SIGINT/SIGHUP so closing SSH cannot dispose the tree
|
|
9
10
|
* before hangup handling. Leaving the session with setsid() is best-effort:
|
|
@@ -78,11 +79,17 @@ export interface DisplayHostHandlers {
|
|
|
78
79
|
export declare class DisplayHost {
|
|
79
80
|
readonly path: string;
|
|
80
81
|
private readonly handlers;
|
|
82
|
+
/** Test seam: how long a silent connection may wait for its HELLO. */
|
|
83
|
+
private readonly options;
|
|
81
84
|
private server;
|
|
82
85
|
private socket;
|
|
83
86
|
private reader;
|
|
84
87
|
attached: boolean;
|
|
85
|
-
constructor(path: string, handlers: DisplayHostHandlers
|
|
88
|
+
constructor(path: string, handlers: DisplayHostHandlers,
|
|
89
|
+
/** Test seam: how long a silent connection may wait for its HELLO. */
|
|
90
|
+
options?: {
|
|
91
|
+
helloGraceMs?: number;
|
|
92
|
+
});
|
|
86
93
|
listen(): Promise<void>;
|
|
87
94
|
private accept;
|
|
88
95
|
sendStdout(bytes: Buffer | string): boolean;
|
|
@@ -119,15 +126,64 @@ export interface SpawnedHost {
|
|
|
119
126
|
/** Exit watch so a Host that dies before listening is reported at once. */
|
|
120
127
|
exitWatch: HostExitWatch;
|
|
121
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Keep what the user types while no display exists yet.
|
|
131
|
+
*
|
|
132
|
+
* A fresh Host takes a moment to boot; the launcher used to leave stdin flowing
|
|
133
|
+
* with nobody listening, so every key pressed in that window was dropped. The
|
|
134
|
+
* capture strips cursor replies (it is the same stream the relay will probe)
|
|
135
|
+
* and hands the rest to the relay as its `seed`.
|
|
136
|
+
*/
|
|
137
|
+
export declare function captureTerminalInput(stdin?: NodeJS.ReadStream): {
|
|
138
|
+
stop(): string;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Keep the terminal quiet while the launcher retries or waits for a Host.
|
|
142
|
+
*
|
|
143
|
+
* Between attempts the TTY is back in cooked mode, so a DSR reply still in
|
|
144
|
+
* flight from the previous probe is echoed to the screen as `^[[17;1R`-style
|
|
145
|
+
* garbage. Raw mode plus a drain swallows those bytes instead: they are either
|
|
146
|
+
* stale replies or keys typed before any display existed, and neither should
|
|
147
|
+
* reach the shell.
|
|
148
|
+
*/
|
|
149
|
+
export declare function quietTerminalInput(stdin?: NodeJS.ReadStream): number;
|
|
150
|
+
/**
|
|
151
|
+
* Hand the TTY back to the shell: cooked mode, echo back, input paused.
|
|
152
|
+
*
|
|
153
|
+
* `quietTerminalInput` deliberately leaves raw mode on while a relay is about
|
|
154
|
+
* to take the terminal, and the relay restores it on its way out — but every
|
|
155
|
+
* exit path then calls `quiet()` once more to drain a cursor reply still owed,
|
|
156
|
+
* which turns raw mode back on. Without this the shell comes back with no line
|
|
157
|
+
* discipline and no echo: the user cannot type, and the only recovery is
|
|
158
|
+
* dropping the SSH connection. Only local termios calls here — nothing is
|
|
159
|
+
* written into a link that may already be dead.
|
|
160
|
+
*/
|
|
161
|
+
export declare function restoreTerminalInput(stdin?: NodeJS.ReadStream): void;
|
|
122
162
|
/** Spawn a detached Host copy of this `dsh` invocation and return its sock path. */
|
|
123
163
|
export declare function spawnDetachedHost(sessionId: string): SpawnedHost;
|
|
124
164
|
export declare function probeDisplaySock(path: string, timeoutMs?: number): Promise<boolean>;
|
|
125
165
|
export interface RelayResult {
|
|
126
166
|
/** Host sent goodbye — user exited from the attached session. */
|
|
127
|
-
reason: 'goodbye' | 'host-closed' | 'signal';
|
|
167
|
+
reason: 'goodbye' | 'host-closed' | 'signal' | 'replaced';
|
|
168
|
+
}
|
|
169
|
+
/** Terminal and signal sources; injectable so a test can drive the relay. */
|
|
170
|
+
export interface DisplayRelayOptions {
|
|
171
|
+
stdin?: NodeJS.ReadStream;
|
|
172
|
+
stdout?: NodeJS.WriteStream;
|
|
173
|
+
signals?: Pick<NodeJS.Process, 'on' | 'off' | 'removeListener'>;
|
|
174
|
+
/** Link kind for the RTT probe; defaults to this process's SSH env. */
|
|
175
|
+
ssh?: boolean;
|
|
176
|
+
/** Typing captured before this relay existed; sent to the Host after HELLO. */
|
|
177
|
+
seed?: string;
|
|
178
|
+
/**
|
|
179
|
+
* The waiting status line currently on screen, if the launcher announced one.
|
|
180
|
+
* It is erased here — after the measurement, before the Host's first paint —
|
|
181
|
+
* so the picker's screen does not sit frozen while the probe runs.
|
|
182
|
+
*/
|
|
183
|
+
announce?: boolean;
|
|
128
184
|
}
|
|
129
185
|
/**
|
|
130
186
|
* Turn this process into a Display relay until the Host hangs up or the
|
|
131
187
|
* local TTY dies. Restores the terminal before resolving.
|
|
132
188
|
*/
|
|
133
|
-
export declare function runDisplayRelay(path: string): Promise<RelayResult>;
|
|
189
|
+
export declare function runDisplayRelay(path: string, options?: DisplayRelayOptions): Promise<RelayResult>;
|
|
@@ -62,6 +62,13 @@ export interface SessionInspectionLike {
|
|
|
62
62
|
events: readonly unknown[];
|
|
63
63
|
meta?: SessionHeaderLike;
|
|
64
64
|
header?: SessionHeaderLike;
|
|
65
|
+
/**
|
|
66
|
+
* Backend state for the slice. `detached` means the backend never
|
|
67
|
+
* materialized a physical artifact (the writer is in this process and has
|
|
68
|
+
* not flushed yet), so `events: []` says nothing about whether the session
|
|
69
|
+
* is blank. Callers must not delete artifacts on a `detached` read.
|
|
70
|
+
*/
|
|
71
|
+
eventState?: string;
|
|
65
72
|
}
|
|
66
73
|
/**
|
|
67
74
|
* 0.1.2 `list()` returns headers; 0.1.5 returns `{ header, revision, … }`
|
|
@@ -16,12 +16,16 @@ export declare const UI_LOCALE_SCHEMA: z<Schemastery.ObjectS<{
|
|
|
16
16
|
view: z<string, string>;
|
|
17
17
|
disconnect: z<string, string>;
|
|
18
18
|
autoApproval: z<string, string>;
|
|
19
|
+
/** Milliseconds a leftover, finished Host waits before exiting; 0 = never. */
|
|
20
|
+
idleExit: z<number, number>;
|
|
19
21
|
}>, Schemastery.ObjectT<{
|
|
20
22
|
language: z<string, string>;
|
|
21
23
|
skipUpdate: z<string, string>;
|
|
22
24
|
view: z<string, string>;
|
|
23
25
|
disconnect: z<string, string>;
|
|
24
26
|
autoApproval: z<string, string>;
|
|
27
|
+
/** Milliseconds a leftover, finished Host waits before exiting; 0 = never. */
|
|
28
|
+
idleExit: z<number, number>;
|
|
25
29
|
}>>;
|
|
26
30
|
export declare function localeFromTag(tag: string): Locale | undefined;
|
|
27
31
|
/** Pick zh/en from env, optionally after a saved settings value. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
* subagents, sandbox approvals) is the same one the web surface uses.
|
|
6
6
|
*/
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
-
|
|
9
|
-
export declare const ATTACH_RECOVERY_WINDOW_MS = 5000;
|
|
10
|
-
/** True when a relay error means the peer vanished rather than a real fault. */
|
|
11
|
-
export declare function attachPeerVanished(error: unknown, elapsedMs: number): boolean;
|
|
8
|
+
export { ATTACH_RECOVERY_WINDOW_MS, attachPeerVanished } from './attach.js';
|
|
12
9
|
export declare const name = "ssh-tui";
|
|
13
10
|
/** Core services required before the terminal channel can drive an agent. */
|
|
14
11
|
export declare const inject: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Grace allowed to the launcher's graceful `appExit` before the process is
|
|
14
|
+
* forced out. That shutdown only sets `process.exitCode`, and a lingering
|
|
15
|
+
* event-loop handle (the profile patch watcher) can keep the drain from ever
|
|
16
|
+
* finishing — with the user's shell still blocked on the foreground process.
|
|
17
|
+
*/
|
|
18
|
+
export declare const EXIT_FALLBACK_MS = 2000;
|
|
15
19
|
/** Plugin config: the session identity and presentation defaults. */
|
|
16
20
|
export interface Config {
|
|
17
21
|
sessionId: string;
|
package/lib/types/paint.d.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Kept off `tui.ts` so the launch picker can dirty-paint without loading
|
|
5
5
|
* the agent-backed SshTui class.
|
|
6
6
|
*/
|
|
7
|
-
export declare const DSR_PROBE_TIMEOUT_MS = 800;
|
|
8
7
|
/** Give a running turn this long to settle after cancel before we flush anyway. */
|
|
9
8
|
export declare const HANGUP_CANCEL_TIMEOUT_MS = 10000;
|
|
10
9
|
export type PaintLinkKind = 'local' | 'ssh';
|
|
@@ -20,7 +19,7 @@ export declare function resolvePaintIntervalMs(configured?: number, env?: NodeJS
|
|
|
20
19
|
rttMs?: number;
|
|
21
20
|
}): number;
|
|
22
21
|
/** True when this process is attached to an SSH session (jump host / proxy). */
|
|
23
|
-
export
|
|
22
|
+
export { detectSshSession } from './terminal-input.js';
|
|
24
23
|
/** Node errno on a write/close that means the TTY is gone (SSH drop, HUP). */
|
|
25
24
|
export declare function isHangupErrno(error: unknown): boolean;
|
|
26
25
|
/**
|
|
@@ -85,8 +84,12 @@ export declare function findCursorPositionReply(text: string): {
|
|
|
85
84
|
* Round-trip to the attached terminal via CSI 6n. Returns undefined when the
|
|
86
85
|
* reply never arrives (dumb pipe, blocked DSR). Does not interpret the
|
|
87
86
|
* coordinates — only the elapsed milliseconds matter.
|
|
87
|
+
*
|
|
88
|
+
* Sampling and plausibility live in `terminal-input.ts`: a single request used
|
|
89
|
+
* to accept a previous probe's answer (~2 ms) and to report a repaint queue as
|
|
90
|
+
* the link (~1900 ms on a 50 ms SSH line).
|
|
88
91
|
*/
|
|
89
|
-
export declare function probeTerminalRttMs(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream, timeoutMs?: number): Promise<number | undefined>;
|
|
92
|
+
export declare function probeTerminalRttMs(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream, timeoutMs?: number, ssh?: boolean): Promise<number | undefined>;
|
|
90
93
|
/** Sliding window of `windowSize` items that keeps `cursor` visible. */
|
|
91
94
|
export declare function pickerWindowStart(cursor: number, total: number, windowSize?: number): number;
|
|
92
95
|
/** Immediate first-frame chrome so a 2–3s Host boot is not a blank TTY. */
|
package/lib/types/picker.d.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* TUI mounts, so launching without an explicit session id lands on a choice
|
|
4
4
|
* instead of a fresh main screen.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Reading is lazy. The first page is inspected up front and only then painted,
|
|
7
|
+
* so nothing on screen is a raw id waiting to turn into a title; older sessions
|
|
8
|
+
* are read only when the user reaches for them — pressing past the last row, or
|
|
9
|
+
* filtering, which has to look deeper than the rows on screen. The visible page
|
|
10
|
+
* is always nine rows so digits 1-9 map onto every on-screen item.
|
|
9
11
|
*/
|
|
10
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
-
import {
|
|
13
|
+
import { openResumableSessionPager, type ResumableSession } from './session-list.js';
|
|
12
14
|
/** What the launch picker decided. */
|
|
13
15
|
export type SessionPickerResult = {
|
|
14
16
|
kind: 'resume';
|
|
@@ -37,8 +39,10 @@ export interface SessionPickerState {
|
|
|
37
39
|
* shortcuts. Set automatically by the first letter, or by `/` / Ctrl+F.
|
|
38
40
|
*/
|
|
39
41
|
filterActive: boolean;
|
|
40
|
-
/**
|
|
42
|
+
/** A page is being read right now (the first one, or a lazy load). */
|
|
41
43
|
loading?: boolean;
|
|
44
|
+
/** Sessions in history that have not been read yet. */
|
|
45
|
+
more?: number;
|
|
42
46
|
}
|
|
43
47
|
/** One key / control action against {@link SessionPickerState}. */
|
|
44
48
|
export type SessionPickerAction = {
|
|
@@ -96,6 +100,22 @@ export declare function clampPickerCursor(cursor: number, total: number): number
|
|
|
96
100
|
export declare function pickerCapacity(rows: number): number;
|
|
97
101
|
/** Whether two picker states would paint the same frame. */
|
|
98
102
|
export declare function pickerStateUnchanged(previous: SessionPickerState, next: SessionPickerState): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Whether this input asked for sessions the picker has not read yet.
|
|
105
|
+
*
|
|
106
|
+
* Older history is read lazily, so the trigger matters: while filtering, a page
|
|
107
|
+
* that leaves fewer matches than fit on screen is deepened (a search has to look
|
|
108
|
+
* past the rows on screen); otherwise the user has to press past the last loaded
|
|
109
|
+
* row — pressing Down at the end, PageDown, or End — before older sessions are
|
|
110
|
+
* read.
|
|
111
|
+
*/
|
|
112
|
+
export declare function pickerWantsMorePage(input: {
|
|
113
|
+
previous: Pick<SessionPickerState, 'sessions' | 'cursor' | 'query' | 'filterActive'>;
|
|
114
|
+
next: Pick<SessionPickerState, 'sessions' | 'cursor' | 'query' | 'filterActive'>;
|
|
115
|
+
actions: readonly SessionPickerAction[];
|
|
116
|
+
more: number;
|
|
117
|
+
windowSize?: number;
|
|
118
|
+
}): boolean;
|
|
99
119
|
/**
|
|
100
120
|
* Apply one picker action. Pure: the TTY layer maps keystrokes onto this, and
|
|
101
121
|
* tests drive it without stdin.
|
|
@@ -110,7 +130,7 @@ export declare function splitPickerInput(text: string): string[];
|
|
|
110
130
|
* unit; ordinary text is applied character by character so a pasted "fix 2"
|
|
111
131
|
* types the digit instead of treating it as a 1-9 shortcut.
|
|
112
132
|
*/
|
|
113
|
-
export declare function feedPicker(state: SessionPickerState, text: string, windowSize?: number): SessionPickerStep;
|
|
133
|
+
export declare function feedPicker(state: SessionPickerState, text: string, windowSize?: number, onAction?: (action: SessionPickerAction) => void): SessionPickerStep;
|
|
114
134
|
/**
|
|
115
135
|
* Show the picker and wait for a selection, a new-session request, or cancel.
|
|
116
136
|
* Restores the terminal before resolving; an AbortSignal cancels as well.
|
|
@@ -123,6 +143,6 @@ export declare function feedPicker(state: SessionPickerState, text: string, wind
|
|
|
123
143
|
export interface SessionPickerOptions {
|
|
124
144
|
stdin?: NodeJS.ReadStream;
|
|
125
145
|
stdout?: NodeJS.WriteStream;
|
|
126
|
-
|
|
146
|
+
openPager?: typeof openResumableSessionPager;
|
|
127
147
|
}
|
|
128
148
|
export declare function showSessionPicker(ctx: Context, color: boolean, signal?: AbortSignal, options?: SessionPickerOptions): Promise<SessionPickerResult>;
|