tmux-ide 2.9.0-beta.33 → 2.9.0-beta.35

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 (24) hide show
  1. package/bin/cli.js +17 -1
  2. package/package.json +1 -1
  3. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +26 -0
  4. package/packages/daemon/dist/tui/mirror/runtime/application-root-renderer.js +5 -0
  5. package/packages/daemon/dist/tui/mirror/runtime/application-sidebar-shortcuts.js +31 -0
  6. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +19 -3
  7. package/packages/daemon/dist/tui/mirror/runtime/host-shift-capture.js +32 -0
  8. package/packages/daemon/dist/tui/mirror/runtime/terminal-links.js +3 -2
  9. package/packages/daemon/dist/tui/mirror/ui/key-hint.jsx +12 -5
  10. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +1 -0
  11. package/packages/daemon/dist/tui/mirror/workspace/application-shortcuts.js +3 -0
  12. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -0
  13. package/packages/daemon/src/tui/mirror/runtime/application-root-renderer.ts +5 -0
  14. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +9 -10
  15. package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +6 -2
  16. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +15 -2
  17. package/packages/daemon/src/tui/mirror/runtime/application-sidebar-shortcuts.ts +51 -0
  18. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +21 -3
  19. package/packages/daemon/src/tui/mirror/runtime/host-shift-capture.ts +32 -0
  20. package/packages/daemon/src/tui/mirror/runtime/terminal-links.ts +3 -2
  21. package/packages/daemon/src/tui/mirror/shell-chrome-view.tsx +1 -0
  22. package/packages/daemon/src/tui/mirror/ui/key-hint.tsx +16 -5
  23. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +1 -0
  24. package/packages/daemon/src/tui/mirror/workspace/application-shortcuts.ts +3 -0
package/bin/cli.js CHANGED
@@ -11279,7 +11279,7 @@ var require_package = __commonJS({
11279
11279
  "package.json"(exports, module) {
11280
11280
  module.exports = {
11281
11281
  name: "tmux-ide",
11282
- version: "2.9.0-beta.33",
11282
+ version: "2.9.0-beta.35",
11283
11283
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
11284
11284
  type: "module",
11285
11285
  bin: {
@@ -48541,6 +48541,8 @@ var init_terminal_replica_interpreter = __esm({
48541
48541
  #widgetGate = false;
48542
48542
  #markerTail = "";
48543
48543
  #pendingResize = null;
48544
+ // null: no unpublished writes; undefined: a write requires full projection.
48545
+ #pendingWriteDirty = null;
48544
48546
  #pendingRaw = [];
48545
48547
  #pendingRawBytes = 0;
48546
48548
  #rawContinuityLost = false;
@@ -48792,6 +48794,12 @@ var init_terminal_replica_interpreter = __esm({
48792
48794
  this.#observability.nowMicros(),
48793
48795
  trace
48794
48796
  );
48797
+ const dirty = this.#backend.dirtyRange();
48798
+ const pending = this.#pendingWriteDirty;
48799
+ this.#pendingWriteDirty = pending === null ? dirty : pending === void 0 || dirty === void 0 ? void 0 : {
48800
+ start: Math.min(pending.start, dirty.start),
48801
+ end: Math.max(pending.end, dirty.end)
48802
+ };
48795
48803
  if (this.#backend.requiresNativeReseed()) {
48796
48804
  this.#commit(false);
48797
48805
  return;
@@ -48847,7 +48855,15 @@ var init_terminal_replica_interpreter = __esm({
48847
48855
  return;
48848
48856
  }
48849
48857
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
48858
+ const pending = this.#pendingWriteDirty;
48859
+ if (pending !== null) {
48860
+ dirty = pending === void 0 || dirty === void 0 ? void 0 : {
48861
+ start: Math.min(pending.start, dirty.start),
48862
+ end: Math.max(pending.end, dirty.end)
48863
+ };
48864
+ }
48850
48865
  const projected = this.#project(forceSeed ? void 0 : dirty);
48866
+ this.#pendingWriteDirty = null;
48851
48867
  const previous = this.#snapshot;
48852
48868
  const dirtyRows = [];
48853
48869
  for (let index = 0; index < projected.grid.length; index += 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.33",
3
+ "version": "2.9.0-beta.35",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,8 @@ export class TerminalReplicaInterpreter {
38
38
  #widgetGate = false;
39
39
  #markerTail = "";
40
40
  #pendingResize = null;
41
+ // null: no unpublished writes; undefined: a write requires full projection.
42
+ #pendingWriteDirty = null;
41
43
  #pendingRaw = [];
42
44
  #pendingRawBytes = 0;
43
45
  #rawContinuityLost = false;
@@ -306,6 +308,19 @@ export class TerminalReplicaInterpreter {
306
308
  await this.#writeToBackend(this.#backend, data);
307
309
  if (this.#observability.enabled)
308
310
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
311
+ // The parser resets its dirty tracker per write. Synchronized output may
312
+ // span many writes before publication, so retain their union until commit.
313
+ const dirty = this.#backend.dirtyRange();
314
+ const pending = this.#pendingWriteDirty;
315
+ this.#pendingWriteDirty =
316
+ pending === null
317
+ ? dirty
318
+ : pending === undefined || dirty === undefined
319
+ ? undefined
320
+ : {
321
+ start: Math.min(pending.start, dirty.start),
322
+ end: Math.max(pending.end, dirty.end),
323
+ };
309
324
  // DEC synchronized-output is atomic: no intermediate frame leaks.
310
325
  if (this.#backend.requiresNativeReseed()) {
311
326
  this.#commit(false);
@@ -369,7 +384,18 @@ export class TerminalReplicaInterpreter {
369
384
  return;
370
385
  }
371
386
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
387
+ const pending = this.#pendingWriteDirty;
388
+ if (pending !== null) {
389
+ dirty =
390
+ pending === undefined || dirty === undefined
391
+ ? undefined
392
+ : {
393
+ start: Math.min(pending.start, dirty.start),
394
+ end: Math.max(pending.end, dirty.end),
395
+ };
396
+ }
372
397
  const projected = this.#project(forceSeed ? undefined : dirty);
398
+ this.#pendingWriteDirty = null;
373
399
  const previous = this.#snapshot;
374
400
  const dirtyRows = [];
375
401
  for (let index = 0; index < projected.grid.length; index += 1) {
@@ -1,3 +1,4 @@
1
+ import { acquireHostShiftCapture } from "./host-shift-capture.js";
1
2
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
2
3
  import { tuiPerfMark, tuiPerfStream } from "./application-performance-log.js";
3
4
  import { createRendererOutputTransport } from "./renderer-output-transport.js";
@@ -30,7 +31,10 @@ export async function createApplicationRootRenderer(kittyKeys, onOutputError, on
30
31
  let resize = () => { };
31
32
  let removeCapabilityObserver;
32
33
  const preserveCaptureDump = ["true", "1", "on", "yes"].includes((process.env.OTUI_DUMP_CAPTURES ?? "").toLowerCase());
34
+ let releaseShiftCapture;
33
35
  const disposeOutput = () => {
36
+ releaseShiftCapture?.();
37
+ releaseShiftCapture = undefined;
34
38
  removeCapabilityObserver?.();
35
39
  removeCapabilityObserver = undefined;
36
40
  clearTimeout(resizeTimer);
@@ -80,6 +84,7 @@ export async function createApplicationRootRenderer(kittyKeys, onOutputError, on
80
84
  disposeOutput();
81
85
  throw error;
82
86
  }
87
+ releaseShiftCapture = acquireHostShiftCapture();
83
88
  const activeRenderer = renderer;
84
89
  if (tuiPerfStream) {
85
90
  let previous;
@@ -0,0 +1,31 @@
1
+ export function isSidebarToggleKey(key) {
2
+ return (key.name.toLowerCase() === "f10" &&
3
+ !key.shift &&
4
+ !key.ctrl &&
5
+ !key.meta &&
6
+ key.eventType !== "release");
7
+ }
8
+ export function createApplicationSidebarShortcuts(surface, visible, setVisible, machines, palette) {
9
+ return (event) => {
10
+ if (event.name.toLowerCase() === "f5") {
11
+ machines.sidebar.onBlur?.();
12
+ palette.setOpen(true, "keyboard");
13
+ return true;
14
+ }
15
+ if (isSidebarToggleKey(event)) {
16
+ event.preventDefault();
17
+ event.stopPropagation();
18
+ if (event.eventType === "press")
19
+ setVisible((value) => !value);
20
+ return true;
21
+ }
22
+ if (event.ctrl && event.name.toLowerCase() === "g") {
23
+ if (surface() === "home" || !visible())
24
+ machines.showSwitcher(false);
25
+ else
26
+ machines.focus();
27
+ return true;
28
+ }
29
+ return false;
30
+ };
31
+ }
@@ -1032,6 +1032,16 @@ export function ApplicationTerminalWorkspace(props) {
1032
1032
  const head = gestureLeaseCurrent(active.lease) && cell
1033
1033
  ? terminalSelectionCell(snapshot, cell.col, cell.row, scrollback.offset(active.paneId), selectionViewport(active.paneId, active.frame))
1034
1034
  : null;
1035
+ if (active.linkUrl &&
1036
+ head &&
1037
+ !active.moved &&
1038
+ head.row === active.anchor.row &&
1039
+ head.col === active.anchor.col) {
1040
+ const url = active.linkUrl;
1041
+ endSelectionView();
1042
+ props.onOpenLink?.(url);
1043
+ return;
1044
+ }
1035
1045
  if (!snapshot ||
1036
1046
  !head ||
1037
1047
  (active.unit === "cell" &&
@@ -1096,15 +1106,20 @@ export function ApplicationTerminalWorkspace(props) {
1096
1106
  const lease = captureGestureLease(hit.frame.paneId, hit.frame);
1097
1107
  if (!lease)
1098
1108
  return;
1109
+ let shiftLink;
1099
1110
  if (props.onOpenLink && isTerminalLinkClick(event)) {
1100
1111
  const cell = terminalSelectionCell(snapshot, hit.col, hit.row, scrollback.offset(hit.frame.paneId), selectionViewport(hit.frame.paneId, hit.frame));
1101
1112
  const url = cell && terminalLinkAt(snapshot, cell);
1102
1113
  if (url) {
1103
1114
  event.stopPropagation?.();
1104
- linkPointer = true;
1105
1115
  lastSelectionClick = null;
1106
- props.onOpenLink(url);
1107
- return;
1116
+ if (event.modifiers?.shift)
1117
+ shiftLink = url;
1118
+ else {
1119
+ linkPointer = true;
1120
+ props.onOpenLink(url);
1121
+ return;
1122
+ }
1108
1123
  }
1109
1124
  }
1110
1125
  const appMouse = terminalMouseActionSupported(snapshot, "down");
@@ -1183,6 +1198,7 @@ export function ApplicationTerminalWorkspace(props) {
1183
1198
  frame: hit.frame,
1184
1199
  lease: selectionLease,
1185
1200
  moved: false,
1201
+ linkUrl: shiftLink ? (terminalLinkAt(selectionSnapshot, anchor) ?? undefined) : undefined,
1186
1202
  };
1187
1203
  setPointerSelecting(true);
1188
1204
  setCommittedSelection(null);
@@ -0,0 +1,32 @@
1
+ import { closeSync, openSync, writeSync } from "node:fs";
2
+ /** XTSHIFTESCAPE is a host request, not pane input or a persistent user setting. */
3
+ export function acquireHostShiftCapture(environment = process.env, write = (sequence) => {
4
+ const fd = openSync("/dev/tty", "w");
5
+ try {
6
+ writeSync(fd, sequence);
7
+ }
8
+ finally {
9
+ closeSync(fd);
10
+ }
11
+ }) {
12
+ // A surrounding multiplexer owns the outer terminal protocol. Do not send
13
+ // unqualified passthrough sequences to an unknown terminal through it.
14
+ if (environment.TERM_PROGRAM !== "ghostty" || environment.TMUX)
15
+ return () => { };
16
+ let active = true;
17
+ const request = (capture) => {
18
+ try {
19
+ write(capture ? "\u001b[>1s" : "\u001b[>0s");
20
+ }
21
+ catch {
22
+ // A missing controlling terminal must not prevent startup or cleanup.
23
+ }
24
+ };
25
+ request(true);
26
+ return () => {
27
+ if (!active)
28
+ return;
29
+ active = false;
30
+ request(false);
31
+ };
32
+ }
@@ -4,8 +4,9 @@ const MAX_LINK_LINE = 8192;
4
4
  export function isTerminalLinkClick(event) {
5
5
  return (event.type === "down" &&
6
6
  event.button === 0 &&
7
- Boolean(event.modifiers?.ctrl || event.modifiers?.meta) &&
8
- !event.modifiers?.shift &&
7
+ (event.modifiers?.shift
8
+ ? !event.modifiers.ctrl && !event.modifiers.meta
9
+ : Boolean(event.modifiers?.ctrl || event.modifiers?.meta)) &&
9
10
  !event.modifiers?.alt);
10
11
  }
11
12
  export function safeTerminalLink(value) {
@@ -5,9 +5,11 @@ import { useKeyboardRoute } from "./keyboard-router.jsx";
5
5
  /** A one-row, cell-aligned keyboard affordance with optional pointer/keyboard activation. */
6
6
  export function KeyHint(props) {
7
7
  const palette = () => componentPalette(props.theme, props, "neutral");
8
- const background = () => props.quiet && !props.focused && !props.hovered
9
- ? props.theme.roles.surfaces.panel
10
- : palette().background;
8
+ const background = () => props.button
9
+ ? props.theme.roles.surfaces.panelRaised
10
+ : props.quiet && !props.focused && !props.hovered
11
+ ? props.theme.roles.surfaces.panel
12
+ : palette().background;
11
13
  const foreground = () => props.quiet && !props.focused && !props.hovered
12
14
  ? props.theme.roles.text.muted
13
15
  : palette().foreground;
@@ -39,9 +41,14 @@ export function KeyHint(props) {
39
41
  activate();
40
42
  }}>
41
43
  <text fg={foreground()} bg={background()}>
42
- {props.focused || props.selected ? (<strong>{content()}</strong>) : props.quiet && !props.presentation ? (<>
44
+ {props.focused || props.selected ? (<strong>{content()}</strong>) : (props.quiet || props.button) && !props.presentation ? (<>
43
45
  {" "}
44
- <span style={{ fg: props.theme.roles.text.primary }}>
46
+ <span style={{
47
+ fg: props.button
48
+ ? props.theme.roles.selection.selectionText
49
+ : props.theme.roles.text.primary,
50
+ bg: props.button ? props.theme.roles.selection.selection : background(),
51
+ }}>
45
52
  {clipTerminal(props.keys, Math.max(0, width() - 1))}
46
53
  </span>
47
54
  {clipTerminal(props.label ? ` ${props.label} ` : " ", Math.max(0, width() - terminalDisplayWidth(props.keys) - 1))}
@@ -12,6 +12,7 @@ export function applicationCommandDescription(command) {
12
12
  id: command,
13
13
  label: command === "hide-sidebar" ? "Hide sidebar" : "Show sidebar",
14
14
  detail: "Terminal layout",
15
+ shortcut: "F10",
15
16
  };
16
17
  if (command === "switch-session")
17
18
  return {
@@ -6,8 +6,11 @@ export const APPLICATION_SHORTCUTS = [
6
6
  return { category: "Application", label: description.label, keys: description.shortcut };
7
7
  }),
8
8
  { category: "Application", label: "Commands", keys: "F5" },
9
+ { category: "Application", label: "Show / hide sidebar", keys: "F10" },
9
10
  { category: "Application", label: "Agent attention", keys: "F7" },
10
11
  { category: "Application", label: "Sidebar / Sessions when hidden", keys: "Ctrl+G" },
12
+ { category: "Terminals", label: "Open link", keys: "Shift+click" },
13
+ { category: "Terminals", label: "Select and copy text", keys: "Shift+drag" },
11
14
  { category: "Home", label: "Find an agent or workspace", keys: "/" },
12
15
  { category: "Home", label: "Cycle machine filter", keys: "f" },
13
16
  { category: "Home", label: "Toggle attention filter", keys: "a" },
@@ -161,6 +161,8 @@ export class TerminalReplicaInterpreter {
161
161
  #widgetGate = false;
162
162
  #markerTail = "";
163
163
  #pendingResize: { cols: number; rows: number } | null = null;
164
+ // null: no unpublished writes; undefined: a write requires full projection.
165
+ #pendingWriteDirty: { start: number; end: number } | null | undefined = null;
164
166
  #pendingRaw: Uint8Array[] = [];
165
167
  #pendingRawBytes = 0;
166
168
  #rawContinuityLost = false;
@@ -464,6 +466,19 @@ export class TerminalReplicaInterpreter {
464
466
  this.#observability.nowMicros(),
465
467
  trace,
466
468
  );
469
+ // The parser resets its dirty tracker per write. Synchronized output may
470
+ // span many writes before publication, so retain their union until commit.
471
+ const dirty = this.#backend.dirtyRange();
472
+ const pending = this.#pendingWriteDirty;
473
+ this.#pendingWriteDirty =
474
+ pending === null
475
+ ? dirty
476
+ : pending === undefined || dirty === undefined
477
+ ? undefined
478
+ : {
479
+ start: Math.min(pending.start, dirty.start),
480
+ end: Math.max(pending.end, dirty.end),
481
+ };
467
482
  // DEC synchronized-output is atomic: no intermediate frame leaks.
468
483
  if (this.#backend.requiresNativeReseed()) {
469
484
  this.#commit(false);
@@ -531,7 +546,18 @@ export class TerminalReplicaInterpreter {
531
546
  return;
532
547
  }
533
548
  const reduceStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
549
+ const pending = this.#pendingWriteDirty;
550
+ if (pending !== null) {
551
+ dirty =
552
+ pending === undefined || dirty === undefined
553
+ ? undefined
554
+ : {
555
+ start: Math.min(pending.start, dirty.start),
556
+ end: Math.max(pending.end, dirty.end),
557
+ };
558
+ }
534
559
  const projected = this.#project(forceSeed ? undefined : dirty);
560
+ this.#pendingWriteDirty = null;
535
561
  const previous = this.#snapshot;
536
562
  const dirtyRows: CanonicalTerminalReplicaPatch["patch"]["rows"] = [];
537
563
  for (let index = 0; index < projected.grid.length; index += 1) {
@@ -1,3 +1,4 @@
1
+ import { acquireHostShiftCapture } from "./host-shift-capture.ts";
1
2
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
2
3
 
3
4
  import { tuiPerfMark, tuiPerfStream } from "./application-performance-log.ts";
@@ -37,7 +38,10 @@ export async function createApplicationRootRenderer(
37
38
  const preserveCaptureDump = ["true", "1", "on", "yes"].includes(
38
39
  (process.env.OTUI_DUMP_CAPTURES ?? "").toLowerCase(),
39
40
  );
41
+ let releaseShiftCapture: (() => void) | undefined;
40
42
  const disposeOutput = () => {
43
+ releaseShiftCapture?.();
44
+ releaseShiftCapture = undefined;
41
45
  removeCapabilityObserver?.();
42
46
  removeCapabilityObserver = undefined;
43
47
  clearTimeout(resizeTimer);
@@ -84,6 +88,7 @@ export async function createApplicationRootRenderer(
84
88
  disposeOutput();
85
89
  throw error;
86
90
  }
91
+ releaseShiftCapture = acquireHostShiftCapture();
87
92
  const activeRenderer = renderer;
88
93
  if (tuiPerfStream) {
89
94
  let previous: string | undefined;
@@ -1,3 +1,4 @@
1
+ import { createApplicationSidebarShortcuts } from "./application-sidebar-shortcuts.ts";
1
2
  import type { TmuxServerScope } from "@tmux-ide/contracts";
2
3
  import { terminalWindowActionCallbacks } from "./application-terminal-workspace-policy.ts";
3
4
  import { applicationRouteConnection } from "./application-route-connection.ts";
@@ -425,6 +426,13 @@ export async function startApplicationRoot(options: StartApplicationRootOptions
425
426
  focusedPane();
426
427
  terminalInputIngress.adopt();
427
428
  });
429
+ const sidebarShortcut = createApplicationSidebarShortcuts(
430
+ activeSurface,
431
+ sidebarVisible,
432
+ setSidebarVisible,
433
+ machines,
434
+ paletteCommands,
435
+ );
428
436
  useKeyboard((event) => {
429
437
  noteHostInteraction();
430
438
  const name = event.name.toLowerCase();
@@ -438,11 +446,6 @@ export async function startApplicationRoot(options: StartApplicationRootOptions
438
446
  }
439
447
  if (name === "f6" || name === "f7") paletteCommands.setOpen(false, "keyboard");
440
448
  if (handleFleetShortcut(event, machines)) return;
441
- if (name === "f5") {
442
- machines.sidebar.onBlur?.();
443
- paletteCommands.setOpen(true, "keyboard");
444
- return;
445
- }
446
449
  if (appearance.handlePickerKey(event)) return;
447
450
  if (paneRename.handleKey(event)) return;
448
451
  if (
@@ -453,11 +456,7 @@ export async function startApplicationRoot(options: StartApplicationRootOptions
453
456
  )
454
457
  return;
455
458
  if (paletteCommands.handleKey(event)) return;
456
- if (event.ctrl && name === "g") {
457
- if (activeSurface() === "home" || !sidebarVisible()) machines.showSwitcher(false);
458
- else machines.focus();
459
- return;
460
- }
459
+ if (sidebarShortcut(event)) return;
461
460
  if (machines.focused() && !["f1", "f2"].includes(name) && !(event.ctrl && name === "q")) {
462
461
  componentKeyboardRoutes.route(event);
463
462
  return;
@@ -12,6 +12,7 @@ import { For, Show, createMemo } from "solid-js";
12
12
  import { shellChromeLayout, type ShellChromeView } from "../shell-chrome.ts";
13
13
  import type { SemanticThemeSnapshot } from "../theme.ts";
14
14
  import { clipTerminal, friendlySessionLabel } from "../terminal-text.ts";
15
+ import { KeyHint } from "../ui/key-hint.tsx";
15
16
  import { Button } from "../ui/button.tsx";
16
17
  import { NavigationRow } from "../ui/navigation-row.tsx";
17
18
  import type { OverlayLayer } from "../ui/overlay-host.tsx";
@@ -244,9 +245,12 @@ function CatalogStatusStrip(props: {
244
245
  />
245
246
  )}
246
247
  </For>
247
- <StatusBarSegment
248
+ <KeyHint
248
249
  theme={props.theme}
249
- label="F5 Commands"
250
+ keys="F5"
251
+ label="Commands"
252
+ quiet
253
+ button
250
254
  width={15}
251
255
  onPress={props.onOpenCommands}
252
256
  />
@@ -1,3 +1,4 @@
1
+ import { isSidebarToggleKey } from "./application-sidebar-shortcuts.ts";
1
2
  import {
2
3
  ApplicationMachineSidebar,
3
4
  type ApplicationMachineSidebarModel,
@@ -48,13 +49,25 @@ export { applicationShellViewport } from "./application-shell-viewport.ts";
48
49
  type TerminalWorkspaceProps = ComponentProps<typeof ApplicationTerminalWorkspace>;
49
50
  export type RootSurface = "home" | "terminals";
50
51
  type InputSource = "keyboard" | "mouse";
51
- export type ApplicationShellKeyAction = "home" | "terminals" | "palette-open" | "palette-close";
52
+ export type ApplicationShellKeyAction =
53
+ | "home"
54
+ | "terminals"
55
+ | "palette-open"
56
+ | "palette-close"
57
+ | "sidebar-toggle";
52
58
 
53
59
  export function applicationShellKeyAction(
54
- key: { readonly name: string },
60
+ key: {
61
+ readonly name: string;
62
+ readonly shift?: boolean;
63
+ readonly ctrl?: boolean;
64
+ readonly meta?: boolean;
65
+ readonly eventType?: string;
66
+ },
55
67
  paletteOpen: boolean,
56
68
  ): ApplicationShellKeyAction | null {
57
69
  const name = key.name.toLowerCase();
70
+ if (isSidebarToggleKey(key)) return "sidebar-toggle";
58
71
  if (name === "f1") return "home";
59
72
  if (name === "f2") return "terminals";
60
73
  if (name === "f5") return "palette-open";
@@ -0,0 +1,51 @@
1
+ interface Key {
2
+ readonly name: string;
3
+ readonly ctrl?: boolean;
4
+ readonly shift?: boolean;
5
+ readonly meta?: boolean;
6
+ readonly eventType?: string;
7
+ preventDefault(): void;
8
+ stopPropagation(): void;
9
+ }
10
+
11
+ export function isSidebarToggleKey(key: Omit<Key, "preventDefault" | "stopPropagation">): boolean {
12
+ return (
13
+ key.name.toLowerCase() === "f10" &&
14
+ !key.shift &&
15
+ !key.ctrl &&
16
+ !key.meta &&
17
+ key.eventType !== "release"
18
+ );
19
+ }
20
+
21
+ export function createApplicationSidebarShortcuts(
22
+ surface: () => string,
23
+ visible: () => boolean,
24
+ setVisible: (update: (value: boolean) => boolean) => unknown,
25
+ machines: {
26
+ showSwitcher(attention: boolean): void;
27
+ focus(): void;
28
+ sidebar: { onBlur?: () => void };
29
+ },
30
+ palette: { setOpen(open: boolean, source: "keyboard"): void },
31
+ ): (event: Key) => boolean {
32
+ return (event) => {
33
+ if (event.name.toLowerCase() === "f5") {
34
+ machines.sidebar.onBlur?.();
35
+ palette.setOpen(true, "keyboard");
36
+ return true;
37
+ }
38
+ if (isSidebarToggleKey(event)) {
39
+ event.preventDefault();
40
+ event.stopPropagation();
41
+ if (event.eventType === "press") setVisible((value) => !value);
42
+ return true;
43
+ }
44
+ if (event.ctrl && event.name.toLowerCase() === "g") {
45
+ if (surface() === "home" || !visible()) machines.showSwitcher(false);
46
+ else machines.focus();
47
+ return true;
48
+ }
49
+ return false;
50
+ };
51
+ }
@@ -512,6 +512,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
512
512
  }>;
513
513
  pointer: Readonly<{ x: number; y: number }>;
514
514
  moved: boolean;
515
+ readonly linkUrl?: string;
515
516
  } | null = null;
516
517
  let linkPointer = false;
517
518
  let liveReturnPointer = false;
@@ -1457,6 +1458,18 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1457
1458
  selectionViewport(active.paneId, active.frame),
1458
1459
  )
1459
1460
  : null;
1461
+ if (
1462
+ active.linkUrl &&
1463
+ head &&
1464
+ !active.moved &&
1465
+ head.row === active.anchor.row &&
1466
+ head.col === active.anchor.col
1467
+ ) {
1468
+ const url = active.linkUrl;
1469
+ endSelectionView();
1470
+ props.onOpenLink?.(url);
1471
+ return;
1472
+ }
1460
1473
  if (
1461
1474
  !snapshot ||
1462
1475
  !head ||
@@ -1522,6 +1535,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1522
1535
  if (!snapshot) return;
1523
1536
  const lease = captureGestureLease(hit.frame.paneId, hit.frame);
1524
1537
  if (!lease) return;
1538
+ let shiftLink: string | undefined;
1525
1539
  if (props.onOpenLink && isTerminalLinkClick(event)) {
1526
1540
  const cell = terminalSelectionCell(
1527
1541
  snapshot,
@@ -1533,10 +1547,13 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1533
1547
  const url = cell && terminalLinkAt(snapshot, cell);
1534
1548
  if (url) {
1535
1549
  event.stopPropagation?.();
1536
- linkPointer = true;
1537
1550
  lastSelectionClick = null;
1538
- props.onOpenLink(url);
1539
- return;
1551
+ if (event.modifiers?.shift) shiftLink = url;
1552
+ else {
1553
+ linkPointer = true;
1554
+ props.onOpenLink(url);
1555
+ return;
1556
+ }
1540
1557
  }
1541
1558
  }
1542
1559
  const appMouse = terminalMouseActionSupported(snapshot, "down");
@@ -1625,6 +1642,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1625
1642
  frame: hit.frame,
1626
1643
  lease: selectionLease,
1627
1644
  moved: false,
1645
+ linkUrl: shiftLink ? (terminalLinkAt(selectionSnapshot, anchor) ?? undefined) : undefined,
1628
1646
  };
1629
1647
  setPointerSelecting(true);
1630
1648
  setCommittedSelection(null);
@@ -0,0 +1,32 @@
1
+ import { closeSync, openSync, writeSync } from "node:fs";
2
+
3
+ /** XTSHIFTESCAPE is a host request, not pane input or a persistent user setting. */
4
+ export function acquireHostShiftCapture(
5
+ environment: NodeJS.ProcessEnv = process.env,
6
+ write: (sequence: string) => void = (sequence) => {
7
+ const fd = openSync("/dev/tty", "w");
8
+ try {
9
+ writeSync(fd, sequence);
10
+ } finally {
11
+ closeSync(fd);
12
+ }
13
+ },
14
+ ): () => void {
15
+ // A surrounding multiplexer owns the outer terminal protocol. Do not send
16
+ // unqualified passthrough sequences to an unknown terminal through it.
17
+ if (environment.TERM_PROGRAM !== "ghostty" || environment.TMUX) return () => {};
18
+ let active = true;
19
+ const request = (capture: boolean) => {
20
+ try {
21
+ write(capture ? "\u001b[>1s" : "\u001b[>0s");
22
+ } catch {
23
+ // A missing controlling terminal must not prevent startup or cleanup.
24
+ }
25
+ };
26
+ request(true);
27
+ return () => {
28
+ if (!active) return;
29
+ active = false;
30
+ request(false);
31
+ };
32
+ }
@@ -18,8 +18,9 @@ export function isTerminalLinkClick(event: {
18
18
  return (
19
19
  event.type === "down" &&
20
20
  event.button === 0 &&
21
- Boolean(event.modifiers?.ctrl || event.modifiers?.meta) &&
22
- !event.modifiers?.shift &&
21
+ (event.modifiers?.shift
22
+ ? !event.modifiers.ctrl && !event.modifiers.meta
23
+ : Boolean(event.modifiers?.ctrl || event.modifiers?.meta)) &&
23
24
  !event.modifiers?.alt
24
25
  );
25
26
  }
@@ -225,6 +225,7 @@ export function ContextStatusBar(props: ShellStatusStripProps) {
225
225
  label={props.layout.status.width >= 24 ? "Commands" : undefined}
226
226
  width={commandWidth()}
227
227
  quiet
228
+ button
228
229
  onPress={props.onHelp}
229
230
  />
230
231
  </StatusBarGroup>
@@ -14,15 +14,19 @@ export interface KeyHintProps extends ComponentInteractionState {
14
14
  presentation?: string;
15
15
  onPress?: () => void;
16
16
  quiet?: boolean;
17
+ /** Distinct footer button with an emphasized shortcut. */
18
+ button?: boolean;
17
19
  }
18
20
 
19
21
  /** A one-row, cell-aligned keyboard affordance with optional pointer/keyboard activation. */
20
22
  export function KeyHint(props: KeyHintProps) {
21
23
  const palette = () => componentPalette(props.theme, props, "neutral");
22
24
  const background = () =>
23
- props.quiet && !props.focused && !props.hovered
24
- ? props.theme.roles.surfaces.panel
25
- : palette().background;
25
+ props.button
26
+ ? props.theme.roles.surfaces.panelRaised
27
+ : props.quiet && !props.focused && !props.hovered
28
+ ? props.theme.roles.surfaces.panel
29
+ : palette().background;
26
30
  const foreground = () =>
27
31
  props.quiet && !props.focused && !props.hovered
28
32
  ? props.theme.roles.text.muted
@@ -67,10 +71,17 @@ export function KeyHint(props: KeyHintProps) {
67
71
  <text fg={foreground()} bg={background()}>
68
72
  {props.focused || props.selected ? (
69
73
  <strong>{content()}</strong>
70
- ) : props.quiet && !props.presentation ? (
74
+ ) : (props.quiet || props.button) && !props.presentation ? (
71
75
  <>
72
76
  {" "}
73
- <span style={{ fg: props.theme.roles.text.primary }}>
77
+ <span
78
+ style={{
79
+ fg: props.button
80
+ ? props.theme.roles.selection.selectionText
81
+ : props.theme.roles.text.primary,
82
+ bg: props.button ? props.theme.roles.selection.selection : background(),
83
+ }}
84
+ >
74
85
  {clipTerminal(props.keys, Math.max(0, width() - 1))}
75
86
  </span>
76
87
  {clipTerminal(
@@ -71,6 +71,7 @@ export function applicationCommandDescription(command: ApplicationPaletteCommand
71
71
  id: command,
72
72
  label: command === "hide-sidebar" ? "Hide sidebar" : "Show sidebar",
73
73
  detail: "Terminal layout",
74
+ shortcut: "F10",
74
75
  };
75
76
  if (command === "switch-session")
76
77
  return {
@@ -13,8 +13,11 @@ export const APPLICATION_SHORTCUTS: readonly ApplicationShortcut[] = [
13
13
  return { category: "Application", label: description.label, keys: description.shortcut! };
14
14
  }),
15
15
  { category: "Application", label: "Commands", keys: "F5" },
16
+ { category: "Application", label: "Show / hide sidebar", keys: "F10" },
16
17
  { category: "Application", label: "Agent attention", keys: "F7" },
17
18
  { category: "Application", label: "Sidebar / Sessions when hidden", keys: "Ctrl+G" },
19
+ { category: "Terminals", label: "Open link", keys: "Shift+click" },
20
+ { category: "Terminals", label: "Select and copy text", keys: "Shift+drag" },
18
21
  { category: "Home", label: "Find an agent or workspace", keys: "/" },
19
22
  { category: "Home", label: "Cycle machine filter", keys: "f" },
20
23
  { category: "Home", label: "Toggle attention filter", keys: "a" },