gitspace 0.2.0-rc.14 → 0.2.0-rc.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitspace",
3
- "version": "0.2.0-rc.14",
3
+ "version": "0.2.0-rc.15",
4
4
  "description": "CLI for managing GitHub workspaces with git worktrees and secure remote terminal access",
5
5
  "bin": {
6
6
  "gssh": "./bin/gssh"
@@ -17,10 +17,10 @@
17
17
  "relay": "bun src/relay/index.ts"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@gitspace/darwin-arm64": "0.2.0-rc.14",
21
- "@gitspace/darwin-x64": "0.2.0-rc.14",
22
- "@gitspace/linux-x64": "0.2.0-rc.14",
23
- "@gitspace/linux-arm64": "0.2.0-rc.14"
20
+ "@gitspace/darwin-arm64": "0.2.0-rc.15",
21
+ "@gitspace/darwin-x64": "0.2.0-rc.15",
22
+ "@gitspace/linux-x64": "0.2.0-rc.15",
23
+ "@gitspace/linux-arm64": "0.2.0-rc.15"
24
24
  },
25
25
  "keywords": [
26
26
  "cli",
package/src/app.web.tsx CHANGED
@@ -47,6 +47,9 @@ import {
47
47
 
48
48
  type View = "machines" | "terminal";
49
49
 
50
+ const PAGE_UP = '\x1b[5~';
51
+ const PAGE_DOWN = '\x1b[6~';
52
+
50
53
  export default function App() {
51
54
  const [view, setView] = useState<View>("machines");
52
55
  const [selectedMachine, setSelectedMachine] = useState<MachineInfo | null>(null);
@@ -762,6 +765,14 @@ export default function App() {
762
765
  if (view === "terminal" && terminal.status === "established" && terminal.mode === "attached") {
763
766
  // Handler for sending data from mobile controls (already processed)
764
767
  const handleSendData = (data: string) => {
768
+ if (data === PAGE_UP && terminalRef.current?.pageUp()) {
769
+ return;
770
+ }
771
+
772
+ if (data === PAGE_DOWN && terminalRef.current?.pageDown()) {
773
+ return;
774
+ }
775
+
765
776
  terminal.send(new TextEncoder().encode(data));
766
777
  };
767
778
 
@@ -12,6 +12,10 @@ import { findUtf8Boundary } from '../utils/utf8.js';
12
12
  import { BracketedPasteModeTracker, wrapPaste } from './terminal-bracketed-paste.tui.js';
13
13
  import { toast } from '@opentui-ui/toast';
14
14
  import { copyToClipboard } from '../utils/clipboard.js';
15
+ import {
16
+ getPageNavigationEscapeSequence,
17
+ shouldConsumePageNavigationInScrollbox,
18
+ } from './session-terminal-page-navigation.js';
15
19
 
16
20
  extend({ 'ghostty-terminal': GhosttyTerminalRenderable });
17
21
 
@@ -256,13 +260,35 @@ export function SessionTerminal({
256
260
  }
257
261
 
258
262
  if (key.name === 'pageup') {
259
- scrollBoxRef.current?.scrollBy(-1, 'viewport');
260
- return;
263
+ const scrollBox = scrollBoxRef.current;
264
+ if (
265
+ scrollBox &&
266
+ shouldConsumePageNavigationInScrollbox({
267
+ direction: 'up',
268
+ scrollTop: scrollBox.scrollTop,
269
+ scrollHeight: scrollBox.scrollHeight,
270
+ viewportHeight: scrollBox.viewport.height,
271
+ })
272
+ ) {
273
+ scrollBox.scrollBy(-1, 'viewport');
274
+ return;
275
+ }
261
276
  }
262
277
 
263
278
  if (key.name === 'pagedown') {
264
- scrollBoxRef.current?.scrollBy(1, 'viewport');
265
- return;
279
+ const scrollBox = scrollBoxRef.current;
280
+ if (
281
+ scrollBox &&
282
+ shouldConsumePageNavigationInScrollbox({
283
+ direction: 'down',
284
+ scrollTop: scrollBox.scrollTop,
285
+ scrollHeight: scrollBox.scrollHeight,
286
+ viewportHeight: scrollBox.viewport.height,
287
+ })
288
+ ) {
289
+ scrollBox.scrollBy(1, 'viewport');
290
+ return;
291
+ }
266
292
  }
267
293
 
268
294
  if (key.name === 'escape' && key.ctrl) {
@@ -298,6 +324,10 @@ export function SessionTerminal({
298
324
  } else if (code.length === 1) {
299
325
  data = `\x1b[1;${modifier}${code}`;
300
326
  }
327
+ } else if (key.name === 'pageup') {
328
+ data = getPageNavigationEscapeSequence('up');
329
+ } else if (key.name === 'pagedown') {
330
+ data = getPageNavigationEscapeSequence('down');
301
331
  } else if (key.sequence) {
302
332
  data = key.sequence;
303
333
  } else if (key.raw) {
@@ -1,5 +1,9 @@
1
- import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
1
+ import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from "react";
2
2
  import { init, Terminal as GhosttyTerminal, FitAddon } from "ghostty-web";
3
+ import {
4
+ canConsumePageNavigationInViewport,
5
+ type PageDirection,
6
+ } from './session-terminal-page-navigation.js';
3
7
 
4
8
  interface Props {
5
9
  onData: (data: Uint8Array) => void;
@@ -20,6 +24,19 @@ export interface SessionTerminalHandle {
20
24
  sendData: (data: string) => void;
21
25
  isFocused: () => boolean;
22
26
  getSize: () => { cols: number; rows: number } | null;
27
+ pageUp: () => boolean;
28
+ pageDown: () => boolean;
29
+ }
30
+
31
+ interface TerminalViewportLike {
32
+ viewportY?: number;
33
+ rows?: number;
34
+ scrollLines: (lines: number) => void;
35
+ buffer?: {
36
+ active?: {
37
+ baseY?: number;
38
+ };
39
+ };
23
40
  }
24
41
 
25
42
  // Touch scrolling constants (agentboard-inspired accumulated delta pattern)
@@ -73,6 +90,30 @@ export const SessionTerminal = forwardRef<SessionTerminalHandle, Props>(function
73
90
  allowTouchScrollRef.current = allowTouchScroll;
74
91
  }, [allowTouchScroll]);
75
92
 
93
+ const tryConsumePageNavigation = useCallback((direction: PageDirection): boolean => {
94
+ const terminal = terminalRef.current as unknown as TerminalViewportLike | null;
95
+ if (!terminal) {
96
+ return false;
97
+ }
98
+
99
+ const viewportY = terminal.viewportY ?? 0;
100
+ const baseY = terminal.buffer?.active?.baseY ?? 0;
101
+ const canConsume = canConsumePageNavigationInViewport({
102
+ direction,
103
+ viewportY,
104
+ baseY,
105
+ });
106
+
107
+ if (!canConsume) {
108
+ return false;
109
+ }
110
+
111
+ const linesPerPage = Math.max(1, (terminal.rows ?? 1) - 1);
112
+ terminal.scrollLines(direction === 'up' ? -linesPerPage : linesPerPage);
113
+ onActivityRef.current?.();
114
+ return true;
115
+ }, []);
116
+
76
117
  // Expose methods via ref for external control (e.g., from TerminalControls)
77
118
  useImperativeHandle(
78
119
  ref,
@@ -102,8 +143,10 @@ export const SessionTerminal = forwardRef<SessionTerminalHandle, Props>(function
102
143
  }
103
144
  return { cols: term.cols, rows: term.rows };
104
145
  },
146
+ pageUp: () => tryConsumePageNavigation('up'),
147
+ pageDown: () => tryConsumePageNavigation('down'),
105
148
  }),
106
- []
149
+ [tryConsumePageNavigation]
107
150
  );
108
151
 
109
152
  useEffect(() => {
@@ -164,6 +207,22 @@ export const SessionTerminal = forwardRef<SessionTerminalHandle, Props>(function
164
207
  event.preventDefault();
165
208
  event.stopPropagation();
166
209
  onDataRef.current(new TextEncoder().encode('\x1b[Z'));
210
+ return;
211
+ }
212
+
213
+ if (event.key === 'PageUp') {
214
+ if (tryConsumePageNavigation('up')) {
215
+ event.preventDefault();
216
+ event.stopPropagation();
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (event.key === 'PageDown') {
222
+ if (tryConsumePageNavigation('down')) {
223
+ event.preventDefault();
224
+ event.stopPropagation();
225
+ }
167
226
  }
168
227
  };
169
228
  container.addEventListener('keydown', handleKeyDown, true);
@@ -301,7 +360,7 @@ export const SessionTerminal = forwardRef<SessionTerminalHandle, Props>(function
301
360
  setWriteCallback(null);
302
361
  }
303
362
  };
304
- }, [setWriteCallback]);
363
+ }, [setWriteCallback, tryConsumePageNavigation]);
305
364
 
306
365
  return (
307
366
  <div
@@ -0,0 +1,48 @@
1
+ export type PageDirection = 'up' | 'down';
2
+
3
+ interface ScrollboxPageNavigationState {
4
+ direction: PageDirection;
5
+ scrollTop: number;
6
+ scrollHeight: number;
7
+ viewportHeight: number;
8
+ }
9
+
10
+ interface ViewportPageNavigationState {
11
+ direction: PageDirection;
12
+ viewportY: number;
13
+ baseY: number;
14
+ }
15
+
16
+ export function shouldConsumePageNavigationInScrollbox({
17
+ direction,
18
+ scrollTop,
19
+ scrollHeight,
20
+ viewportHeight,
21
+ }: ScrollboxPageNavigationState): boolean {
22
+ const maxScrollTop = Math.max(0, scrollHeight - viewportHeight);
23
+ if (maxScrollTop <= 0) {
24
+ return false;
25
+ }
26
+
27
+ if (direction === 'up') {
28
+ return scrollTop > 0;
29
+ }
30
+
31
+ return scrollTop < maxScrollTop;
32
+ }
33
+
34
+ export function canConsumePageNavigationInViewport({
35
+ direction,
36
+ viewportY,
37
+ baseY,
38
+ }: ViewportPageNavigationState): boolean {
39
+ if (direction === 'up') {
40
+ return viewportY < baseY;
41
+ }
42
+
43
+ return viewportY > 0;
44
+ }
45
+
46
+ export function getPageNavigationEscapeSequence(direction: PageDirection): string {
47
+ return direction === 'up' ? '\x1b[5~' : '\x1b[6~';
48
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ canConsumePageNavigationInViewport,
4
+ getPageNavigationEscapeSequence,
5
+ shouldConsumePageNavigationInScrollbox,
6
+ } from '../../components/session-terminal-page-navigation.js'
7
+
8
+ describe('session terminal page navigation helpers', () => {
9
+ it('consumes scrollbox page navigation only when scroll can move', () => {
10
+ expect(
11
+ shouldConsumePageNavigationInScrollbox({
12
+ direction: 'up',
13
+ scrollTop: 10,
14
+ scrollHeight: 100,
15
+ viewportHeight: 20,
16
+ })
17
+ ).toBe(true)
18
+
19
+ expect(
20
+ shouldConsumePageNavigationInScrollbox({
21
+ direction: 'up',
22
+ scrollTop: 0,
23
+ scrollHeight: 100,
24
+ viewportHeight: 20,
25
+ })
26
+ ).toBe(false)
27
+
28
+ expect(
29
+ shouldConsumePageNavigationInScrollbox({
30
+ direction: 'down',
31
+ scrollTop: 10,
32
+ scrollHeight: 100,
33
+ viewportHeight: 20,
34
+ })
35
+ ).toBe(true)
36
+
37
+ expect(
38
+ shouldConsumePageNavigationInScrollbox({
39
+ direction: 'down',
40
+ scrollTop: 80,
41
+ scrollHeight: 100,
42
+ viewportHeight: 20,
43
+ })
44
+ ).toBe(false)
45
+
46
+ expect(
47
+ shouldConsumePageNavigationInScrollbox({
48
+ direction: 'down',
49
+ scrollTop: 0,
50
+ scrollHeight: 20,
51
+ viewportHeight: 20,
52
+ })
53
+ ).toBe(false)
54
+ })
55
+
56
+ it('detects when viewport can consume page up/down', () => {
57
+ expect(
58
+ canConsumePageNavigationInViewport({
59
+ direction: 'up',
60
+ viewportY: 50,
61
+ baseY: 200,
62
+ })
63
+ ).toBe(true)
64
+
65
+ expect(
66
+ canConsumePageNavigationInViewport({
67
+ direction: 'down',
68
+ viewportY: 1,
69
+ baseY: 200,
70
+ })
71
+ ).toBe(true)
72
+
73
+ expect(
74
+ canConsumePageNavigationInViewport({
75
+ direction: 'down',
76
+ viewportY: 0,
77
+ baseY: 200,
78
+ })
79
+ ).toBe(false)
80
+
81
+ expect(
82
+ canConsumePageNavigationInViewport({
83
+ direction: 'up',
84
+ viewportY: 200,
85
+ baseY: 200,
86
+ })
87
+ ).toBe(false)
88
+ })
89
+
90
+ it('returns standard escape sequences for page keys', () => {
91
+ expect(getPageNavigationEscapeSequence('up')).toBe('\x1b[5~')
92
+ expect(getPageNavigationEscapeSequence('down')).toBe('\x1b[6~')
93
+ })
94
+ })