@mp-lb/termkit 0.2.1 → 0.2.3
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.md +18 -111
- package/dist/Terminal.d.ts +7 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +88 -15
- package/dist/TermkitContext.d.ts +8 -0
- package/dist/TermkitContext.d.ts.map +1 -0
- package/dist/TermkitContext.js +2 -0
- package/dist/TermkitProvider.d.ts +8 -0
- package/dist/TermkitProvider.d.ts.map +1 -0
- package/dist/{context.js → TermkitProvider.js} +11 -18
- package/dist/createTerminalWriteQueue.d.ts +1 -1
- package/dist/createTerminalWriteQueue.d.ts.map +1 -1
- package/dist/createTerminalWriteQueue.js +2 -2
- package/dist/{store.d.ts → createTermkitStore.d.ts} +1 -1
- package/dist/createTermkitStore.d.ts.map +1 -0
- package/dist/{store.js → createTermkitStore.js} +11 -5
- package/dist/fitTerminal.d.ts.map +1 -1
- package/dist/fitTerminal.js +35 -3
- package/dist/index.d.ts +16 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/terminalColors.d.ts +11 -0
- package/dist/terminalColors.d.ts.map +1 -0
- package/dist/terminalColors.js +57 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -1
- package/dist/useDanglingSessions.d.ts +3 -0
- package/dist/useDanglingSessions.d.ts.map +1 -0
- package/dist/useDanglingSessions.js +6 -0
- package/dist/useFocusedSession.d.ts +2 -0
- package/dist/useFocusedSession.d.ts.map +1 -0
- package/dist/useFocusedSession.js +6 -0
- package/dist/useIsFocused.d.ts +2 -0
- package/dist/useIsFocused.d.ts.map +1 -0
- package/dist/useIsFocused.js +4 -0
- package/dist/useIsRinging.d.ts +2 -0
- package/dist/useIsRinging.d.ts.map +1 -0
- package/dist/useIsRinging.js +4 -0
- package/dist/useRingingSessions.d.ts +2 -0
- package/dist/useRingingSessions.d.ts.map +1 -0
- package/dist/useRingingSessions.js +6 -0
- package/dist/useSession.d.ts +3 -0
- package/dist/useSession.d.ts.map +1 -0
- package/dist/useSession.js +6 -0
- package/dist/useSessionStatus.d.ts +3 -0
- package/dist/useSessionStatus.d.ts.map +1 -0
- package/dist/useSessionStatus.js +6 -0
- package/dist/useSessions.d.ts +3 -0
- package/dist/useSessions.d.ts.map +1 -0
- package/dist/useSessions.js +6 -0
- package/dist/useSlots.d.ts +3 -0
- package/dist/useSlots.d.ts.map +1 -0
- package/dist/useSlots.js +6 -0
- package/dist/useTermkitActions.d.ts +10 -0
- package/dist/useTermkitActions.d.ts.map +1 -0
- package/dist/useTermkitActions.js +20 -0
- package/dist/useTermkitContext.d.ts +3 -0
- package/dist/useTermkitContext.d.ts.map +1 -0
- package/dist/useTermkitContext.js +8 -0
- package/package.json +7 -7
- package/dist/Terminal.js.map +0 -1
- package/dist/context.d.ts +0 -15
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/createTerminalWriteQueue.js.map +0 -1
- package/dist/fitTerminal.js.map +0 -1
- package/dist/hooks.d.ts +0 -22
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js +0 -83
- package/dist/hooks.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/types.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,122 +1,29 @@
|
|
|
1
1
|
# @mp-lb/termkit
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
all your live sessions — including their attention bells — with a small set of
|
|
5
|
-
global hooks.
|
|
6
|
-
|
|
7
|
-
## Mental model
|
|
8
|
-
|
|
9
|
-
- A **session** is a long-lived shell (a PTY) living in the **daemon**
|
|
10
|
-
(`@mp-lb/termkit-server`), addressed by a stable id you choose.
|
|
11
|
-
- A `<Terminal session={id} />` is just a **view** onto a session. Mounting it
|
|
12
|
-
attaches (reconnecting to the existing shell if there is one, via a replayed
|
|
13
|
-
screen snapshot); unmounting detaches but leaves the shell running.
|
|
14
|
-
- The `<TermkitProvider>` holds the **global registry** of sessions and polls the
|
|
15
|
-
daemon for the live list. So there is **no one-to-one** requirement between the
|
|
16
|
-
terminals on screen and the sessions that are alive — you can have background
|
|
17
|
-
sessions with no terminal, and you can see/manage them from anywhere.
|
|
18
|
-
|
|
19
|
-
## Two ways to use a session
|
|
20
|
-
|
|
21
|
-
- **Local:** drop `<Terminal session="abc" />` somewhere. Rendering it creates or
|
|
22
|
-
reattaches session `abc`. Its lifecycle reads as component-local — but the
|
|
23
|
-
session itself outlives the component.
|
|
24
|
-
- **Global:** use the hooks (`useSessions`, `useRingingSessions`,
|
|
25
|
-
`useTermkitActions`, …) anywhere under the provider to list every session, watch
|
|
26
|
-
which ones are **belling** for attention, and act on them — whether or not a
|
|
27
|
-
terminal is mounted for them.
|
|
28
|
-
|
|
29
|
-
## Setup
|
|
3
|
+
Build React terminal experiences backed by persistent PTY sessions. This workspace package is private while its API is being designed; use it through the workspace dependency `workspace:*`.
|
|
30
4
|
|
|
31
5
|
```tsx
|
|
32
|
-
import { TermkitProvider } from "@mp-lb/termkit"
|
|
6
|
+
import { Terminal, TermkitProvider, type TermkitConfig } from "@mp-lb/termkit";
|
|
33
7
|
|
|
34
|
-
const config = {
|
|
35
|
-
// Where the byte stream lives. `params` is forwarded to the daemon as session
|
|
36
|
-
// metadata and drives its resolveCwd.
|
|
8
|
+
const config: TermkitConfig = {
|
|
37
9
|
connectionUrl: ({ sessionId, params }) =>
|
|
38
|
-
`
|
|
39
|
-
new URLSearchParams({ session: sessionId, ...params }),
|
|
40
|
-
// The control plane: list / kill / clear-bell. Wire to your backend.
|
|
10
|
+
`wss://example.com/_terminal?${new URLSearchParams({ ...params, session: sessionId })}`,
|
|
41
11
|
transport: {
|
|
42
|
-
list: () =>
|
|
43
|
-
kill: (sessionId) =>
|
|
44
|
-
seen: (sessionId) =>
|
|
12
|
+
list: () => api.terminal.list(),
|
|
13
|
+
kill: (sessionId) => api.terminal.kill(sessionId),
|
|
14
|
+
seen: (sessionId) => api.terminal.seen(sessionId),
|
|
45
15
|
},
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```tsx
|
|
56
|
-
import { Terminal } from "@mp-lb/termkit"
|
|
57
|
-
|
|
58
|
-
<Terminal session="global" autoFocus />
|
|
59
|
-
<Terminal session={tab.id} params={{ slug: "myproject" }} />
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Props: `session` (required, stable id), `params`, `autoFocus`, `dismissOnView`
|
|
63
|
-
(default `true` — clear this session's bell when you view it), `pendingCommand`
|
|
64
|
-
(auto-typed once after the prompt settles), `onExitFocus` (called on
|
|
65
|
-
Ctrl+Shift+Enter, after termkit blurs the terminal), `background`, `className`,
|
|
66
|
-
`style`.
|
|
67
|
-
|
|
68
|
-
## Bells & session hooks
|
|
69
|
-
|
|
70
|
-
```tsx
|
|
71
|
-
const sessions = useSessions() // LiveSession[] — the global list
|
|
72
|
-
const ringing = useRingingSessions() // Set<string> of ids belling for attention
|
|
73
|
-
const isRinging = useIsRinging(id) // one session
|
|
74
|
-
const status = useSessionStatus(id) // connecting | ready | disconnected | dead
|
|
75
|
-
const { focus, kill, dismissBell, dismissAllBells, setPendingCommand } =
|
|
76
|
-
useTermkitActions()
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const Shell = () => (
|
|
19
|
+
<TermkitProvider config={config}>
|
|
20
|
+
<div style={{ height: 400 }}>
|
|
21
|
+
<Terminal session="workspace" autoFocus />
|
|
22
|
+
</div>
|
|
23
|
+
</TermkitProvider>
|
|
24
|
+
);
|
|
77
25
|
```
|
|
78
26
|
|
|
79
|
-
|
|
80
|
-
via `dismissBell(id)` / `dismissAllBells()`.
|
|
81
|
-
|
|
82
|
-
## Theming
|
|
83
|
-
|
|
84
|
-
`<Terminal>` renders xterm on a transparent surface inside a full-size wrapper.
|
|
85
|
-
Set the wrapper background with Tailwind/classes, an explicit colour, a CSS
|
|
86
|
-
variable, or a provider default:
|
|
87
|
-
|
|
88
|
-
```tsx
|
|
89
|
-
<Terminal session="global" className="bg-zinc-950" />
|
|
90
|
-
<Terminal session="global" background="var(--background)" />
|
|
91
|
-
<Terminal session="global" style={{ backgroundColor: theme.terminal }} />
|
|
92
|
-
|
|
93
|
-
const config = {
|
|
94
|
-
...baseConfig,
|
|
95
|
-
terminalOptions: { background: "var(--termkit-bg)" },
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Because the terminal itself is transparent, any leftover space that cannot fit a
|
|
100
|
-
full row or column uses the same background as the terminal.
|
|
101
|
-
|
|
102
|
-
## Migrating dash onto termkit
|
|
103
|
-
|
|
104
|
-
termkit is a superset of what `dash` does today; the mapping is mechanical:
|
|
105
|
-
|
|
106
|
-
| dash | termkit |
|
|
107
|
-
| --- | --- |
|
|
108
|
-
| `<Terminal slot=… slug=… instanceId=… />` | `<Terminal session=… params={{ slug, instanceId }} />` |
|
|
109
|
-
| `useTerminalStore` (zustand) | the provider store + `useTermkitActions` |
|
|
110
|
-
| `live-sessions.ts` poller + `useLiveSessions` | built into `<TermkitProvider>` + `useSessions` |
|
|
111
|
-
| `useRingingSessionIds` | `useRingingSessions` |
|
|
112
|
-
| `markTerminalSeen` on focus/view | `dismissOnView` (default on) + `dismissBell` |
|
|
113
|
-
| `LiveSession.slug` / `.instanceId` | `LiveSession.meta.slug` / `.meta.instanceId` |
|
|
114
|
-
| exit-chord → focus `[data-session-tab]` | `onExitFocus` callback |
|
|
115
|
-
| `useKnownSessionIds` / `useSessionSummaries` | **stay in dash** — built on `useSessions()` (project/slug grouping is dash domain) |
|
|
116
|
-
| daemon (`server/terminal-daemon.ts`) | `createTerminalDaemon({ resolveCwd })` from `@mp-lb/termkit-server` |
|
|
117
|
-
| tRPC `terminal` router | `createTerminalRouter(client)` from `@mp-lb/termkit-trpc`, or keep dash's |
|
|
27
|
+
`api` represents your host application's control-plane adapter. Keep the provider configuration stable. Unmounting a terminal detaches the view; the shell keeps running in the daemon. Remounting the same session ID reattaches to its current screen.
|
|
118
28
|
|
|
119
|
-
The
|
|
120
|
-
project/instance groupings — are deliberately **not** baked in: the daemon takes a
|
|
121
|
-
`resolveCwd` callback, and per-project rollups stay in dash, layered over the
|
|
122
|
-
generic `useSessions()` list.
|
|
29
|
+
See the [guide](../../docs/public/index.mdx) and [generated reference](../../docs/public/api/index.mdx). The Node daemon and optional tRPC adapter live in sibling packages and are kept out of the browser entrypoint.
|
package/dist/Terminal.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type CSSProperties } from "react";
|
|
2
|
+
import { type ITheme } from "@xterm/xterm";
|
|
2
3
|
import "@xterm/xterm/css/xterm.css";
|
|
4
|
+
import { type TerminalColors } from "./terminalColors.js";
|
|
3
5
|
export interface TerminalProps {
|
|
4
6
|
session: string;
|
|
5
7
|
params?: Record<string, string>;
|
|
@@ -7,9 +9,13 @@ export interface TerminalProps {
|
|
|
7
9
|
dismissOnView?: boolean;
|
|
8
10
|
pendingCommand?: string;
|
|
9
11
|
onExitFocus?: () => void;
|
|
12
|
+
exitFocusChord?: "ctrl-enter" | "ctrl-shift-enter";
|
|
10
13
|
background?: string;
|
|
14
|
+
colors?: TerminalColors;
|
|
15
|
+
theme?: ITheme;
|
|
16
|
+
visible?: boolean;
|
|
11
17
|
className?: string;
|
|
12
18
|
style?: CSSProperties;
|
|
13
19
|
}
|
|
14
|
-
export declare function Terminal({ session, params, autoFocus, dismissOnView, pendingCommand, onExitFocus, background, className, style, }: TerminalProps): import("react
|
|
20
|
+
export declare function Terminal({ session, params, autoFocus, dismissOnView, pendingCommand, onExitFocus, exitFocusChord, background, colors, theme, visible, className, style, }: TerminalProps): import("react").JSX.Element;
|
|
15
21
|
//# sourceMappingURL=Terminal.d.ts.map
|
package/dist/Terminal.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Terminal.d.ts","sourceRoot":"","sources":["../src/Terminal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,OAAO,
|
|
1
|
+
{"version":3,"file":"Terminal.d.ts","sourceRoot":"","sources":["../src/Terminal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAC9D,OAAO,EAAqB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAE9D,OAAO,4BAA4B,CAAC;AAUpC,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAE7B,MAAM,WAAW,aAAa;IAK5B,OAAO,EAAE,MAAM,CAAC;IAIhB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEhC,SAAS,CAAC,EAAE,OAAO,CAAC;IAKpB,aAAa,CAAC,EAAE,OAAO,CAAC;IAGxB,cAAc,CAAC,EAAE,MAAM,CAAC;IAIxB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAGzB,cAAc,CAAC,EAAE,YAAY,GAAG,kBAAkB,CAAC;IAInD,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,MAAM,CAAC,EAAE,cAAc,CAAC;IAIxB,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,CAAC,EAAE,OAAO,CAAC;IAGlB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EACP,MAAM,EACN,SAAiB,EACjB,aAAoB,EACpB,cAAc,EACd,WAAW,EACX,cAAmC,EACnC,UAAU,EACV,MAAM,EACN,KAAK,EACL,OAAc,EACd,SAAS,EACT,KAAK,GACN,EAAE,aAAa,+BAscf"}
|
package/dist/Terminal.js
CHANGED
|
@@ -4,12 +4,14 @@ import { Terminal as XTerm } from "@xterm/xterm";
|
|
|
4
4
|
import { FitAddon } from "@xterm/addon-fit";
|
|
5
5
|
import "@xterm/xterm/css/xterm.css";
|
|
6
6
|
import { CLOSE_SESSION_GONE, HEARTBEAT_INTERVAL_MS, HEARTBEAT_PONG_GRACE_MS, } from "@mp-lb/termkit-core";
|
|
7
|
-
import { useTermkitContext } from "./
|
|
7
|
+
import { useTermkitContext } from "./useTermkitContext.js";
|
|
8
8
|
import { createTerminalWriteQueue } from "./createTerminalWriteQueue.js";
|
|
9
9
|
import { fitTerminal } from "./fitTerminal.js";
|
|
10
|
-
|
|
10
|
+
import { resolveTerminalColors, } from "./terminalColors.js";
|
|
11
|
+
export function Terminal({ session, params, autoFocus = false, dismissOnView = true, pendingCommand, onExitFocus, exitFocusChord = "ctrl-shift-enter", background, colors, theme, visible = true, className, style, }) {
|
|
11
12
|
const { store, config } = useTermkitContext();
|
|
12
13
|
const containerRef = useRef(null);
|
|
14
|
+
const refreshLayoutBurstRef = useRef(() => { });
|
|
13
15
|
const containerStyle = {
|
|
14
16
|
width: "100%",
|
|
15
17
|
height: "100%",
|
|
@@ -18,7 +20,7 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
18
20
|
};
|
|
19
21
|
useEffect(() => {
|
|
20
22
|
const sessionId = session;
|
|
21
|
-
const { attach, detach, focus, blur, setStatus, setPendingCommand, consumePendingCommand } = store.getState();
|
|
23
|
+
const { attach, detach, focus, blur, setStatus, setPendingCommand, consumePendingCommand, } = store.getState();
|
|
22
24
|
attach(sessionId);
|
|
23
25
|
if (pendingCommand !== undefined)
|
|
24
26
|
setPendingCommand(sessionId, pendingCommand);
|
|
@@ -32,18 +34,40 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
32
34
|
// if the ref is repointed before teardown runs.
|
|
33
35
|
const container = containerRef.current;
|
|
34
36
|
const opts = config.terminalOptions ?? {};
|
|
37
|
+
const terminalTheme = {
|
|
38
|
+
...resolveTerminalColors(opts.colors),
|
|
39
|
+
...opts.theme,
|
|
40
|
+
...resolveTerminalColors(colors),
|
|
41
|
+
...theme,
|
|
42
|
+
background: theme?.background ??
|
|
43
|
+
(typeof colors === "object" ? colors.background : undefined) ??
|
|
44
|
+
opts.theme?.background ??
|
|
45
|
+
(typeof opts.colors === "object"
|
|
46
|
+
? opts.colors.background
|
|
47
|
+
: undefined) ??
|
|
48
|
+
background ??
|
|
49
|
+
opts.background,
|
|
50
|
+
};
|
|
35
51
|
const term = new XTerm({
|
|
36
52
|
fontFamily: opts.fontFamily ?? '"IBM Plex Mono", monospace',
|
|
37
53
|
fontSize: opts.fontSize ?? 13,
|
|
38
54
|
cursorBlink: opts.cursorBlink ?? true,
|
|
39
|
-
theme:
|
|
55
|
+
theme: terminalTheme,
|
|
40
56
|
});
|
|
41
57
|
const fitAddon = new FitAddon();
|
|
42
58
|
term.loadAddon(fitAddon);
|
|
43
59
|
term.open(container);
|
|
60
|
+
for (const selector of [
|
|
61
|
+
".xterm-viewport",
|
|
62
|
+
".xterm-scrollable-element",
|
|
63
|
+
".xterm-screen",
|
|
64
|
+
]) {
|
|
65
|
+
container
|
|
66
|
+
.querySelector(selector)
|
|
67
|
+
?.style.setProperty("background-color", "transparent", "important");
|
|
68
|
+
}
|
|
44
69
|
let lastFitSize = fitTerminal(container, term, fitAddon, null);
|
|
45
70
|
let lastSentSize = null;
|
|
46
|
-
const terminalWriteQueue = createTerminalWriteQueue(term);
|
|
47
71
|
// If a bell lands while you're actively looking at this session — its terminal
|
|
48
72
|
// is visible and the window has focus — it's already "seen", so clear it now
|
|
49
73
|
// instead of making you switch away and back. Bells that arrive while you're
|
|
@@ -59,6 +83,7 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
59
83
|
else if (term.textarea) {
|
|
60
84
|
term.textarea.tabIndex = -1;
|
|
61
85
|
}
|
|
86
|
+
let scheduleLayoutRefreshBurst = () => { };
|
|
62
87
|
// Subscribe to store focus changes so external callers (focus/blur actions) work.
|
|
63
88
|
const unsubFocus = store.subscribe((state, prev) => {
|
|
64
89
|
const isFocused = state.focusedSlot === sessionId;
|
|
@@ -68,6 +93,7 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
68
93
|
if (term.textarea)
|
|
69
94
|
term.textarea.tabIndex = 0;
|
|
70
95
|
term.focus();
|
|
96
|
+
scheduleLayoutRefreshBurst();
|
|
71
97
|
}
|
|
72
98
|
else {
|
|
73
99
|
term.blur();
|
|
@@ -80,15 +106,21 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
80
106
|
const onFocus = () => {
|
|
81
107
|
focus(sessionId);
|
|
82
108
|
markSeen();
|
|
109
|
+
scheduleLayoutRefreshBurst();
|
|
83
110
|
};
|
|
84
111
|
const onBlur = () => blur(sessionId);
|
|
85
112
|
term.textarea?.addEventListener("focus", onFocus);
|
|
86
113
|
term.textarea?.addEventListener("blur", onBlur);
|
|
87
|
-
//
|
|
114
|
+
// The configured chord exits terminal focus. Handled here (capture on the container,
|
|
88
115
|
// before xterm sees it) because global capture listeners don't reliably fire for
|
|
89
116
|
// Enter when xterm has focus. We blur and hand control back to the host.
|
|
90
117
|
const onExitChord = (e) => {
|
|
91
|
-
|
|
118
|
+
const isExitChord = e.key === "Enter" &&
|
|
119
|
+
e.ctrlKey &&
|
|
120
|
+
!e.altKey &&
|
|
121
|
+
!e.metaKey &&
|
|
122
|
+
(exitFocusChord === "ctrl-shift-enter" ? e.shiftKey : !e.shiftKey);
|
|
123
|
+
if (isExitChord) {
|
|
92
124
|
e.preventDefault();
|
|
93
125
|
e.stopPropagation();
|
|
94
126
|
blur(sessionId);
|
|
@@ -104,6 +136,7 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
104
136
|
let socket = null;
|
|
105
137
|
let reconnectTimer = null;
|
|
106
138
|
let refreshFrame = null;
|
|
139
|
+
const refreshTimers = new Set();
|
|
107
140
|
let disposed = false;
|
|
108
141
|
let attachedBefore = false;
|
|
109
142
|
// App-level heartbeat. A half-open socket (laptop sleep/wake, Wi-Fi change, a
|
|
@@ -138,7 +171,9 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
138
171
|
try {
|
|
139
172
|
socket?.close();
|
|
140
173
|
}
|
|
141
|
-
catch {
|
|
174
|
+
catch {
|
|
175
|
+
/* already closing */
|
|
176
|
+
}
|
|
142
177
|
}, HEARTBEAT_PONG_GRACE_MS);
|
|
143
178
|
}
|
|
144
179
|
}, HEARTBEAT_INTERVAL_MS);
|
|
@@ -198,7 +233,10 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
198
233
|
if (nextSize === null)
|
|
199
234
|
return;
|
|
200
235
|
lastFitSize = nextSize;
|
|
201
|
-
if (lastSentSize?.cols === nextSize.cols &&
|
|
236
|
+
if (lastSentSize?.cols === nextSize.cols &&
|
|
237
|
+
lastSentSize.rows === nextSize.rows)
|
|
238
|
+
return;
|
|
239
|
+
if (socket?.readyState !== WebSocket.OPEN)
|
|
202
240
|
return;
|
|
203
241
|
lastSentSize = nextSize;
|
|
204
242
|
send({ type: "resize", cols: nextSize.cols, rows: nextSize.rows });
|
|
@@ -216,10 +254,28 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
216
254
|
return;
|
|
217
255
|
refreshFrame = requestAnimationFrame(refreshVisibleRows);
|
|
218
256
|
};
|
|
257
|
+
const refreshAfterDelay = (ms) => {
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
refreshTimers.delete(timer);
|
|
260
|
+
refreshAfterLayout();
|
|
261
|
+
}, ms);
|
|
262
|
+
refreshTimers.add(timer);
|
|
263
|
+
};
|
|
264
|
+
const refreshLayoutBurst = () => {
|
|
265
|
+
refreshAfterLayout();
|
|
266
|
+
refreshAfterDelay(50);
|
|
267
|
+
refreshAfterDelay(150);
|
|
268
|
+
refreshAfterDelay(500);
|
|
269
|
+
refreshAfterDelay(1000);
|
|
270
|
+
refreshAfterDelay(2000);
|
|
271
|
+
};
|
|
272
|
+
scheduleLayoutRefreshBurst = refreshLayoutBurst;
|
|
273
|
+
refreshLayoutBurstRef.current = refreshLayoutBurst;
|
|
219
274
|
const refreshWhenVisible = () => {
|
|
220
275
|
if (document.visibilityState === "visible")
|
|
221
|
-
|
|
276
|
+
refreshLayoutBurst();
|
|
222
277
|
};
|
|
278
|
+
const terminalWriteQueue = createTerminalWriteQueue(term, refreshAfterLayout);
|
|
223
279
|
const connect = () => {
|
|
224
280
|
if (disposed)
|
|
225
281
|
return;
|
|
@@ -240,6 +296,7 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
240
296
|
attachedBefore = true;
|
|
241
297
|
setStatus(sessionId, "ready");
|
|
242
298
|
fitAndSendResize();
|
|
299
|
+
refreshLayoutBurst();
|
|
243
300
|
// Run the queued command once, on the very first attach. Wait for the prompt
|
|
244
301
|
// to finish painting (see noteOutput/flushPendingCmd) so it lands on the real
|
|
245
302
|
// prompt instead of racing shell startup. The cap timer is a backstop.
|
|
@@ -262,7 +319,9 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
262
319
|
if (msg.type === "pong")
|
|
263
320
|
onPong();
|
|
264
321
|
}
|
|
265
|
-
catch {
|
|
322
|
+
catch {
|
|
323
|
+
/* unknown control frame — ignore */
|
|
324
|
+
}
|
|
266
325
|
}
|
|
267
326
|
return;
|
|
268
327
|
}
|
|
@@ -288,20 +347,30 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
288
347
|
};
|
|
289
348
|
};
|
|
290
349
|
connect();
|
|
291
|
-
term.onData((data) =>
|
|
350
|
+
term.onData((data) => {
|
|
351
|
+
fitAndSendResize();
|
|
352
|
+
send({ type: "input", data });
|
|
353
|
+
});
|
|
292
354
|
const observer = new ResizeObserver(refreshAfterLayout);
|
|
293
355
|
observer.observe(container);
|
|
294
356
|
container.addEventListener("pointerdown", refreshAfterLayout, true);
|
|
295
|
-
container.addEventListener("wheel", refreshAfterLayout, {
|
|
357
|
+
container.addEventListener("wheel", refreshAfterLayout, {
|
|
358
|
+
capture: true,
|
|
359
|
+
passive: true,
|
|
360
|
+
});
|
|
296
361
|
window.addEventListener("focus", refreshAfterLayout);
|
|
297
362
|
window.addEventListener("pageshow", refreshAfterLayout);
|
|
298
363
|
document.addEventListener("visibilitychange", refreshWhenVisible);
|
|
364
|
+
document.fonts?.ready.then(refreshLayoutBurst).catch(() => { });
|
|
299
365
|
return () => {
|
|
300
366
|
disposed = true;
|
|
301
367
|
if (reconnectTimer !== null)
|
|
302
368
|
clearTimeout(reconnectTimer);
|
|
303
369
|
if (refreshFrame !== null)
|
|
304
370
|
cancelAnimationFrame(refreshFrame);
|
|
371
|
+
for (const timer of refreshTimers)
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
refreshTimers.clear();
|
|
305
374
|
stopHeartbeat();
|
|
306
375
|
clearAutoType();
|
|
307
376
|
blur(sessionId);
|
|
@@ -323,8 +392,12 @@ export function Terminal({ session, params, autoFocus = false, dismissOnView = t
|
|
|
323
392
|
term.textarea?.removeEventListener("blur", onBlur);
|
|
324
393
|
terminalWriteQueue.dispose();
|
|
325
394
|
term.dispose();
|
|
395
|
+
refreshLayoutBurstRef.current = () => { };
|
|
326
396
|
};
|
|
327
397
|
}, [session]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
328
|
-
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (visible)
|
|
400
|
+
refreshLayoutBurstRef.current();
|
|
401
|
+
}, [visible]);
|
|
402
|
+
return (_jsx("div", { ref: containerRef, className: className, style: containerStyle }));
|
|
329
403
|
}
|
|
330
|
-
//# sourceMappingURL=Terminal.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TermkitStore } from "./createTermkitStore.js";
|
|
2
|
+
import type { TermkitConfig } from "./types.js";
|
|
3
|
+
export interface TermkitContextValue {
|
|
4
|
+
store: TermkitStore;
|
|
5
|
+
config: TermkitConfig;
|
|
6
|
+
}
|
|
7
|
+
export declare const TermkitContext: import("react").Context<TermkitContextValue | null>;
|
|
8
|
+
//# sourceMappingURL=TermkitContext.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TermkitContext.d.ts","sourceRoot":"","sources":["../src/TermkitContext.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,eAAO,MAAM,cAAc,qDAAkD,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { TermkitConfig } from "./types.js";
|
|
3
|
+
export interface TermkitProviderProps {
|
|
4
|
+
config: TermkitConfig;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare const TermkitProvider: ({ config, children }: TermkitProviderProps) => import("react").JSX.Element;
|
|
8
|
+
//# sourceMappingURL=TermkitProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TermkitProvider.d.ts","sourceRoot":"","sources":["../src/TermkitProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAG5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,aAAa,CAAC;IACtB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAOD,eAAO,MAAM,eAAe,GAAI,sBAAsB,oBAAoB,gCAuCzE,CAAC"}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { createTermkitStore } from "./
|
|
4
|
-
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { createTermkitStore } from "./createTermkitStore.js";
|
|
4
|
+
import { TermkitContext } from "./TermkitContext.js";
|
|
5
5
|
// Holds the global session registry and config for everything below it. Mount it
|
|
6
6
|
// once near your app root. The registry lives here (not in each <Terminal>), which
|
|
7
7
|
// is what makes sessions outlive the terminals that render them: a <Terminal> is
|
|
8
8
|
// just a view onto a session, and the provider's poller keeps the live list fresh
|
|
9
9
|
// whether or not any terminal is currently mounted for it.
|
|
10
|
-
export
|
|
10
|
+
export const TermkitProvider = ({ config, children }) => {
|
|
11
11
|
// Create the store once. Config is captured at first render; treat it as stable
|
|
12
12
|
// (define it outside render or memoize it host-side, like any context value).
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const { store } =
|
|
13
|
+
const [value] = useState(() => ({
|
|
14
|
+
store: createTermkitStore(config.transport),
|
|
15
|
+
config,
|
|
16
|
+
}));
|
|
17
|
+
const { store } = value;
|
|
18
18
|
// The single poller: one fetch loop publishes the live snapshot every consumer
|
|
19
19
|
// reads. Cheap on the daemon (an in-memory map read), so a couple of seconds
|
|
20
20
|
// keeps bells and counts responsive without being chatty.
|
|
@@ -39,12 +39,5 @@ export function TermkitProvider({ config, children }) {
|
|
|
39
39
|
};
|
|
40
40
|
// config.transport is captured once with the store; re-poll only if cadence changes.
|
|
41
41
|
}, [intervalMs, store, config.transport]);
|
|
42
|
-
return _jsx(TermkitContext.Provider, { value:
|
|
43
|
-
}
|
|
44
|
-
export function useTermkitContext() {
|
|
45
|
-
const ctx = useContext(TermkitContext);
|
|
46
|
-
if (!ctx)
|
|
47
|
-
throw new Error("useTermkit must be used within a <TermkitProvider>");
|
|
48
|
-
return ctx;
|
|
49
|
-
}
|
|
50
|
-
//# sourceMappingURL=context.js.map
|
|
42
|
+
return (_jsx(TermkitContext.Provider, { value: value, children: children }));
|
|
43
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
interface WritableTerminal {
|
|
2
2
|
write: (data: string, callback?: () => void) => void;
|
|
3
3
|
}
|
|
4
|
-
export declare const createTerminalWriteQueue: (term: WritableTerminal) => {
|
|
4
|
+
export declare const createTerminalWriteQueue: (term: WritableTerminal, afterWrite?: () => void) => {
|
|
5
5
|
write(data: string): void;
|
|
6
6
|
clear(): void;
|
|
7
7
|
dispose(): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createTerminalWriteQueue.d.ts","sourceRoot":"","sources":["../src/createTerminalWriteQueue.ts"],"names":[],"mappings":"AAAA,UAAU,gBAAgB;IACxB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,
|
|
1
|
+
{"version":3,"file":"createTerminalWriteQueue.d.ts","sourceRoot":"","sources":["../src/createTerminalWriteQueue.ts"],"names":[],"mappings":"AAAA,UAAU,gBAAgB;IACxB,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACtD;AAED,eAAO,MAAM,wBAAwB,GACnC,MAAM,gBAAgB,EACtB,aAAa,MAAM,IAAI;gBAoBT,MAAM;;;CAarB,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const createTerminalWriteQueue = (term) => {
|
|
1
|
+
export const createTerminalWriteQueue = (term, afterWrite) => {
|
|
2
2
|
let chunks = [];
|
|
3
3
|
let isWriting = false;
|
|
4
4
|
let isDisposed = false;
|
|
@@ -10,6 +10,7 @@ export const createTerminalWriteQueue = (term) => {
|
|
|
10
10
|
isWriting = true;
|
|
11
11
|
term.write(data, () => {
|
|
12
12
|
isWriting = false;
|
|
13
|
+
afterWrite?.();
|
|
13
14
|
drain();
|
|
14
15
|
});
|
|
15
16
|
};
|
|
@@ -29,4 +30,3 @@ export const createTerminalWriteQueue = (term) => {
|
|
|
29
30
|
},
|
|
30
31
|
};
|
|
31
32
|
};
|
|
32
|
-
//# sourceMappingURL=createTerminalWriteQueue.js.map
|
|
@@ -23,4 +23,4 @@ export interface TermkitState {
|
|
|
23
23
|
}
|
|
24
24
|
export type TermkitStore = StoreApi<TermkitState>;
|
|
25
25
|
export declare function createTermkitStore(transport: TermkitTransport): TermkitStore;
|
|
26
|
-
//# sourceMappingURL=
|
|
26
|
+
//# sourceMappingURL=createTermkitStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createTermkitStore.d.ts","sourceRoot":"","sources":["../src/createTermkitStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAOlE,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAE3B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAEpC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAI3B,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAIxC,IAAI,EAAE,WAAW,EAAE,CAAC;IAGpB,MAAM,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,MAAM,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IAG9D,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAGlC,iBAAiB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,qBAAqB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAG5D,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAGzC,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC,YAAY,CAAC,CAAC;AAElD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,gBAAgB,GAAG,YAAY,CAkG5E"}
|
|
@@ -19,7 +19,10 @@ export function createTermkitStore(transport) {
|
|
|
19
19
|
return s;
|
|
20
20
|
const slots = { ...s.slots };
|
|
21
21
|
delete slots[sessionId];
|
|
22
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
slots,
|
|
24
|
+
focusedSlot: s.focusedSlot === sessionId ? null : s.focusedSlot,
|
|
25
|
+
};
|
|
23
26
|
});
|
|
24
27
|
},
|
|
25
28
|
setStatus(sessionId, status) {
|
|
@@ -34,10 +37,14 @@ export function createTermkitStore(transport) {
|
|
|
34
37
|
set({ focusedSlot: sessionId });
|
|
35
38
|
},
|
|
36
39
|
blur(sessionId) {
|
|
37
|
-
set((s) => ({
|
|
40
|
+
set((s) => ({
|
|
41
|
+
focusedSlot: s.focusedSlot === sessionId ? null : s.focusedSlot,
|
|
42
|
+
}));
|
|
38
43
|
},
|
|
39
44
|
setPendingCommand(sessionId, command) {
|
|
40
|
-
set((s) => ({
|
|
45
|
+
set((s) => ({
|
|
46
|
+
pendingCommands: { ...s.pendingCommands, [sessionId]: command },
|
|
47
|
+
}));
|
|
41
48
|
},
|
|
42
49
|
consumePendingCommand(sessionId) {
|
|
43
50
|
const command = get().pendingCommands[sessionId];
|
|
@@ -74,7 +81,7 @@ export function createTermkitStore(transport) {
|
|
|
74
81
|
// Optimistically clear attention so the badge goes away immediately; the
|
|
75
82
|
// next poll confirms it.
|
|
76
83
|
set((s) => ({
|
|
77
|
-
live: s.live.map((l) =>
|
|
84
|
+
live: s.live.map((l) => l.sessionId === sessionId ? { ...l, attention: false } : l),
|
|
78
85
|
}));
|
|
79
86
|
},
|
|
80
87
|
setLive(live) {
|
|
@@ -82,4 +89,3 @@ export function createTermkitStore(transport) {
|
|
|
82
89
|
},
|
|
83
90
|
}));
|
|
84
91
|
}
|
|
85
|
-
//# sourceMappingURL=store.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fitTerminal.d.ts","sourceRoot":"","sources":["../src/fitTerminal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"fitTerminal.d.ts","sourceRoot":"","sources":["../src/fitTerminal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,QAAQ,IAAI,KAAK,EAAE,MAAM,cAAc,CAAC;AAEtD,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAiDD,eAAO,MAAM,WAAW,GACtB,WAAW,WAAW,EACtB,MAAM,KAAK,EACX,UAAU,QAAQ,EAClB,UAAU,YAAY,GAAG,IAAI,KAC5B,YAAY,GAAG,IAiBjB,CAAC"}
|
package/dist/fitTerminal.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
+
function parsePixels(value) {
|
|
2
|
+
const parsed = Number.parseFloat(value);
|
|
3
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
4
|
+
}
|
|
5
|
+
function fallbackDimensions(container) {
|
|
6
|
+
if (typeof container.querySelector !== "function")
|
|
7
|
+
return null;
|
|
8
|
+
const terminal = container.querySelector(".xterm");
|
|
9
|
+
const measure = container.querySelector(".xterm-helper-textarea");
|
|
10
|
+
if (!terminal || !measure)
|
|
11
|
+
return null;
|
|
12
|
+
const terminalStyle = window.getComputedStyle(terminal);
|
|
13
|
+
const cell = measure.getBoundingClientRect();
|
|
14
|
+
if (cell.width <= 0 || cell.height <= 0)
|
|
15
|
+
return null;
|
|
16
|
+
const paddingX = parsePixels(terminalStyle.paddingLeft) +
|
|
17
|
+
parsePixels(terminalStyle.paddingRight);
|
|
18
|
+
const paddingY = parsePixels(terminalStyle.paddingTop) +
|
|
19
|
+
parsePixels(terminalStyle.paddingBottom);
|
|
20
|
+
const scrollbarWidth = 14;
|
|
21
|
+
const availableWidth = Math.max(0, container.clientWidth - paddingX - scrollbarWidth);
|
|
22
|
+
const availableHeight = Math.max(0, container.clientHeight - paddingY);
|
|
23
|
+
return {
|
|
24
|
+
cols: Math.max(2, Math.floor(availableWidth / cell.width)),
|
|
25
|
+
rows: Math.max(1, Math.floor(availableHeight / cell.height)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
1
28
|
export const fitTerminal = (container, term, fitAddon, lastSize) => {
|
|
2
29
|
if (container.clientWidth === 0 || container.clientHeight === 0)
|
|
3
30
|
return lastSize;
|
|
4
|
-
|
|
5
|
-
const
|
|
31
|
+
const fallback = fallbackDimensions(container);
|
|
32
|
+
const proposed = fitAddon.proposeDimensions();
|
|
33
|
+
const nextSize = fallback ?? proposed;
|
|
34
|
+
if (!nextSize)
|
|
35
|
+
return lastSize;
|
|
36
|
+
if (term.cols !== nextSize.cols || term.rows !== nextSize.rows) {
|
|
37
|
+
term.resize(nextSize.cols, nextSize.rows);
|
|
38
|
+
}
|
|
6
39
|
if (lastSize?.cols === nextSize.cols && lastSize.rows === nextSize.rows)
|
|
7
40
|
return lastSize;
|
|
8
41
|
return nextSize;
|
|
9
42
|
};
|
|
10
|
-
//# sourceMappingURL=fitTerminal.js.map
|