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

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 (26) hide show
  1. package/bin/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +7 -2
  4. package/packages/daemon/dist/tui/mirror/runtime/application-transient-note-owner.js +2 -1
  5. package/packages/daemon/dist/tui/mirror/shell-chrome.js +9 -3
  6. package/packages/daemon/dist/tui/mirror/theme.js +2 -0
  7. package/packages/daemon/dist/tui/mirror/ui/button.jsx +1 -1
  8. package/packages/daemon/dist/tui/mirror/ui/menu.jsx +1 -1
  9. package/packages/daemon/dist/tui/mirror/ui/overlay-frame.jsx +20 -5
  10. package/packages/daemon/dist/tui/mirror/workspace/pane-action-menu.jsx +1 -1
  11. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +18 -15
  12. package/packages/daemon/src/tui/mirror/__snapshots__/shell-chrome-renderer.test.tsx.snap +6 -6
  13. package/packages/daemon/src/tui/mirror/runtime/__snapshots__/application-shell-view-renderer.test.tsx.snap +11 -11
  14. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +12 -6
  15. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +9 -0
  16. package/packages/daemon/src/tui/mirror/runtime/application-transient-note-owner.ts +7 -1
  17. package/packages/daemon/src/tui/mirror/shell-chrome-view.tsx +40 -24
  18. package/packages/daemon/src/tui/mirror/shell-chrome.ts +14 -3
  19. package/packages/daemon/src/tui/mirror/theme.ts +3 -0
  20. package/packages/daemon/src/tui/mirror/ui/button.tsx +2 -2
  21. package/packages/daemon/src/tui/mirror/ui/menu.tsx +2 -0
  22. package/packages/daemon/src/tui/mirror/ui/overlay-frame.tsx +43 -7
  23. package/packages/daemon/src/tui/mirror/workspace/__snapshots__/application-shell-renderer.test.tsx.snap +3 -3
  24. package/packages/daemon/src/tui/mirror/workspace/application-shell-view.tsx +2 -0
  25. package/packages/daemon/src/tui/mirror/workspace/pane-action-menu.tsx +2 -0
  26. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +53 -42
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.35",
11282
+ version: "2.9.0-beta.37",
11283
11283
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
11284
11284
  type: "module",
11285
11285
  bin: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.35",
3
+ "version": "2.9.0-beta.37",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -91,6 +91,11 @@ export function ApplicationTerminalWorkspace(props) {
91
91
  ? clampTerminalViewportOrigin(snapshot, { cols: frame.width, rows: frame.contentHeight }, origin, snapshot.history.length)
92
92
  : origin;
93
93
  }, (paneId) => props.adapter.retainPaneView?.(paneId) ?? null);
94
+ createEffect(() => {
95
+ const paneId = props.focusedPane;
96
+ props.onScrollbackChange?.(paneId !== null && scrollback.offset(paneId) > 0);
97
+ });
98
+ onCleanup(() => props.onScrollbackChange?.(false));
94
99
  const selectionViewport = (paneId, frame) => props.adapter.renderSource.supportsViewportOrigin
95
100
  ? {
96
101
  cols: frame.width,
@@ -1318,10 +1323,10 @@ export function ApplicationTerminalWorkspace(props) {
1318
1323
  </box>)}
1319
1324
  </For>
1320
1325
  <Show when={windowLinkMenu()}>
1321
- <Menu theme={props.theme} title="Window link" left={0} top={1} width={Math.min(36, props.width)} viewportWidth={props.width} viewportHeight={props.height} selectedId="unlink" items={[{ id: "unlink", label: "Unlink this tab", shortcut: "U" }]} footer="Other links keep their panes" onDismiss={() => setWindowLinkMenu(null)} onSelect={unlinkWindowTab}/>
1326
+ <Menu theme={props.theme} viewportOrigin={{ x: props.originX ?? 0, y: props.originY ?? 0 }} title="Window link" left={0} top={1} width={Math.min(36, props.width)} viewportWidth={props.width} viewportHeight={props.height} selectedId="unlink" items={[{ id: "unlink", label: "Unlink this tab", shortcut: "U" }]} footer="Other links keep their panes" onDismiss={() => setWindowLinkMenu(null)} onSelect={unlinkWindowTab}/>
1322
1327
  </Show>
1323
1328
  <Show when={paneContextMenu()}>
1324
- {(menu) => (<PaneActionMenu theme={props.theme} left={menu().left} top={menu().top} width={paneContextMenuWidth()} viewportWidth={props.width} viewportHeight={props.height + topOffset()} paneTitle={menu().displayName} active onDismiss={paneMenu.dismiss} selectedId={menu().selectedId} closeArmed={menu().closeArmed} onHighlight={paneMenu.highlight} onActionIntent={paneMenu.activate}/>)}
1329
+ {(menu) => (<PaneActionMenu theme={props.theme} left={menu().left} top={menu().top} width={paneContextMenuWidth()} viewportWidth={props.width} viewportHeight={props.height + topOffset()} viewportOrigin={{ x: props.originX ?? 0, y: props.originY ?? 0 }} paneTitle={menu().displayName} active onDismiss={paneMenu.dismiss} selectedId={menu().selectedId} closeArmed={menu().closeArmed} onHighlight={paneMenu.highlight} onActionIntent={paneMenu.activate}/>)}
1325
1330
  </Show>
1326
1331
  <For each={[
1327
1332
  ...new Set([
@@ -16,7 +16,8 @@ export function createApplicationTransientNoteOwner(options) {
16
16
  publish(note) {
17
17
  clear();
18
18
  options.write(note);
19
- if (!note)
19
+ if (!note ||
20
+ /\b(?:failed|failure|error|unavailable|disconnected|reconnecting|recovering|read.only|denied)\b/iu.test(note))
20
21
  return;
21
22
  timer = clock.setTimeout(() => {
22
23
  timer = null;
@@ -222,9 +222,10 @@ export function shellStatusLine(variant, input, width) {
222
222
  }
223
223
  return `${out}…`;
224
224
  }
225
- function meaningfulStatusMessage(message) {
225
+ export function meaningfulStatusMessage(message) {
226
226
  const value = message?.trim();
227
- if (!value || /^(?:ready|live|connected)$/iu.test(value))
227
+ if (!value ||
228
+ /^(?:ready|live|connected|live tmux sessions? discovered|\d+ sessions? live)$/iu.test(value))
228
229
  return null;
229
230
  return value;
230
231
  }
@@ -264,7 +265,12 @@ export function contextStatusPresentation(input) {
264
265
  : input.connectionState === "recovering"
265
266
  ? "Recovering"
266
267
  : "Disconnected";
267
- const activityLabel = transient ?? notification ?? connectionLabel;
268
+ const persistentProblem = notification && contextStatusTone(notification, "connected").tone === "blocked";
269
+ const activityLabel = input.connectionState !== "connected"
270
+ ? (notification ?? connectionLabel)
271
+ : persistentProblem
272
+ ? notification
273
+ : (transient ?? notification ?? connectionLabel);
268
274
  const activityTone = contextStatusTone(activityLabel, input.connectionState);
269
275
  const location = input.variant === "wide"
270
276
  ? [
@@ -10,6 +10,8 @@
10
10
  * verbatim, aliasing @opentui/core to a browser shim that only exposes RGBA.
11
11
  */
12
12
  import { RGBA } from "@opentui/core";
13
+ /** Shared scrim for modal surfaces across light and dark themes. */
14
+ export const MODAL_BACKDROP = RGBA.fromInts(0, 0, 0, 150);
13
15
  import { BUILTIN_VISUAL_THEMES, findVisualThemePreset, contrastRatio, deriveAttentionBlend, deriveFocusedHeader, mixSrgbColors, readableForeground, resolveVisualTheme, } from "@tmux-ide/contracts";
14
16
  import { LEGACY_THEME_OVERRIDE_PROVENANCE, } from "../../lib/legacy-theme-compat.js";
15
17
  import { parseTerminalHostColor, terminalHostMode } from "../../lib/terminal-host-color.js";
@@ -24,7 +24,7 @@ export function TuiButton(props) {
24
24
  const inset = size() === "compact" ? 1 : 1;
25
25
  return clipTerminal(`${" ".repeat(inset)}${marker}${props.label}${shortcut} `, width());
26
26
  };
27
- const background = () => variant() === "ghost" && palette().state === "base"
27
+ const background = () => palette().state === "base" && (variant() === "ghost" || variant() === "secondary")
28
28
  ? (props.background ?? props.theme.roles.surfaces.panel)
29
29
  : palette().background;
30
30
  const activate = () => {
@@ -12,7 +12,7 @@ export function Menu(props) {
12
12
  preferredHeight: preferredHeight(),
13
13
  });
14
14
  const innerWidth = () => metrics().contentWidth;
15
- return (<OverlayFrame theme={props.theme} viewportWidth={props.viewportWidth ?? Math.max(props.width, props.left + props.width)} viewportHeight={props.viewportHeight ?? Math.max(preferredHeight(), props.top + preferredHeight())} width={props.width} height={preferredHeight()} placement="anchor" anchor={{ x: props.left, y: props.top }} zIndex={props.zIndex ?? 20} active={props.active} title={props.title} footer={props.footer} onDismiss={props.onDismiss}>
15
+ return (<OverlayFrame theme={props.theme} viewportWidth={props.viewportWidth ?? Math.max(props.width, props.left + props.width)} viewportHeight={props.viewportHeight ?? Math.max(preferredHeight(), props.top + preferredHeight())} width={props.width} height={preferredHeight()} viewportOrigin={props.viewportOrigin} placement="anchor" anchor={{ x: props.left, y: props.top }} zIndex={props.zIndex ?? 20} active={props.active} title={props.title} footer={props.footer} onDismiss={props.onDismiss}>
16
16
  <For each={props.items.map((item) => item.id)}>
17
17
  {(id) => {
18
18
  const item = () => props.items.find((item) => item.id === id);
@@ -1,9 +1,13 @@
1
+ /* @jsxImportSource @opentui/solid */
2
+ import { Portal, useTerminalDimensions } from "@opentui/solid";
3
+ import { MODAL_BACKDROP } from "../theme.js";
1
4
  import { clipTerminal } from "../terminal-text.js";
2
5
  import { overlayFrameSize, overlaySurfacePadding } from "./overlay-model.js";
3
6
  /** Owns modal geometry and pointer capture without owning commands or renderer lifecycle. */
4
7
  export function OverlayFrame(props) {
5
- const viewportWidth = () => Math.max(1, Math.floor(props.viewportWidth));
6
- const viewportHeight = () => Math.max(1, Math.floor(props.viewportHeight));
8
+ const dimensions = useTerminalDimensions();
9
+ const viewportWidth = () => Math.max(1, Math.floor(props.viewportOrigin ? dimensions().width : props.viewportWidth));
10
+ const viewportHeight = () => Math.max(1, Math.floor(props.viewportOrigin ? dimensions().height : props.viewportHeight));
7
11
  const size = () => overlayFrameSize({
8
12
  viewportWidth: viewportWidth(),
9
13
  viewportHeight: viewportHeight(),
@@ -18,14 +22,14 @@ export function OverlayFrame(props) {
18
22
  const innerWidth = () => Math.max(1, width() - (props.surface !== false ? padding().horizontal * 2 : bordered() ? 2 : 0));
19
23
  const left = () => {
20
24
  if (props.placement === "anchor")
21
- return Math.max(0, Math.min(props.anchor?.x ?? 0, viewportWidth() - width()));
25
+ return Math.max(0, Math.min((props.anchor?.x ?? 0) + (props.viewportOrigin?.x ?? 0), viewportWidth() - width()));
22
26
  if (props.placement === "top-right")
23
27
  return Math.max(0, viewportWidth() - width() - 1);
24
28
  return Math.max(0, Math.floor((viewportWidth() - width()) / 2));
25
29
  };
26
30
  const top = () => {
27
31
  if (props.placement === "anchor")
28
- return Math.max(0, Math.min(props.anchor?.y ?? 0, viewportHeight() - height()));
32
+ return Math.max(0, Math.min((props.anchor?.y ?? 0) + (props.viewportOrigin?.y ?? 0), viewportHeight() - height()));
29
33
  if (props.placement === "top-right")
30
34
  return Math.min(1, viewportHeight() - height());
31
35
  return Math.max(0, Math.floor((viewportHeight() - height()) / 2));
@@ -61,7 +65,7 @@ export function OverlayFrame(props) {
61
65
  </box>);
62
66
  if (props.modal === false)
63
67
  return frame();
64
- return (<box id="ui-overlay-frame-host" position="absolute" left={0} top={0} width={viewportWidth()} height={viewportHeight()} zIndex={props.zIndex ?? 100} onMouseDown={(event) => {
68
+ const host = () => (<box id="ui-overlay-frame-host" position="absolute" left={0} top={0} width={viewportWidth()} height={viewportHeight()} zIndex={props.zIndex ?? 100} backgroundColor={props.active === false ? undefined : MODAL_BACKDROP} onMouseDown={(event) => {
65
69
  event.preventDefault();
66
70
  event.stopPropagation();
67
71
  if (props.active !== false && props.dismissOnOutsidePress !== false)
@@ -69,4 +73,15 @@ export function OverlayFrame(props) {
69
73
  }}>
70
74
  {frame()}
71
75
  </box>);
76
+ return props.viewportOrigin ? (<Portal ref={(node) => {
77
+ const container = node;
78
+ container.position = "absolute";
79
+ container.left = 0;
80
+ container.top = 0;
81
+ container.width = "100%";
82
+ container.height = "100%";
83
+ container.zIndex = props.zIndex ?? 100;
84
+ }}>
85
+ {host()}
86
+ </Portal>) : (host());
72
87
  }
@@ -11,5 +11,5 @@ function presentation(closeArmed) {
11
11
  }
12
12
  /** Pane-scoped menu compound. Command execution remains owned by the workspace runtime. */
13
13
  export function PaneActionMenu(props) {
14
- return (<Menu theme={props.theme} left={props.left} top={props.top} width={props.width} viewportWidth={props.viewportWidth} viewportHeight={props.viewportHeight} active={props.active} title={props.paneTitle} footer={props.closeArmed ? "X / Enter confirm · Esc cancel" : "↑↓ choose · Enter run · Esc back"} selectedId={props.selectedId} items={presentation(props.closeArmed)} onDismiss={props.onDismiss} onHighlight={(id) => props.onHighlight?.(id)} onSelect={(id) => props.onActionIntent(id)}/>);
14
+ return (<Menu theme={props.theme} left={props.left} top={props.top} width={props.width} viewportWidth={props.viewportWidth} viewportHeight={props.viewportHeight} viewportOrigin={props.viewportOrigin} active={props.active} title={props.paneTitle} footer={props.closeArmed ? "X / Enter confirm · Esc cancel" : "↑↓ choose · Enter run · Esc back"} selectedId={props.selectedId} items={presentation(props.closeArmed)} onDismiss={props.onDismiss} onHighlight={(id) => props.onHighlight?.(id)} onSelect={(id) => props.onActionIntent(id)}/>);
15
15
  }
@@ -31,6 +31,7 @@ function badgeWidth(status) {
31
31
  */
32
32
  export function PaneTitleBar(props) {
33
33
  const [pointerInside, setPointerInside] = createSignal(false);
34
+ const [menuHovered, setMenuHovered] = createSignal(false);
34
35
  const hovered = () => props.hovered ?? pointerInside();
35
36
  const safeWidth = () => Math.max(1, Math.floor(props.width));
36
37
  const status = () => agentStatus(props.activity);
@@ -41,10 +42,16 @@ export function PaneTitleBar(props) {
41
42
  : status();
42
43
  const presence = () => (props.interaction ? paneInteractionPresence(props.interaction) : null);
43
44
  const activityLabel = () => presence()
44
- ? `${presence().badge}${props.interaction?.origin === "external" ? " · External tmux" : ""}`
45
+ ? `${presence().badge[0]}${presence().badge.slice(1).toLowerCase()}${props.interaction?.origin === "external" ? " · tmux" : ""}`
45
46
  : "";
46
47
  const activityWidth = () => {
47
- const available = safeWidth() - markerGutterWidth() - markerWidth() - actionWidth() - zoomWidth() - 4;
48
+ const available = safeWidth() -
49
+ markerGutterWidth() -
50
+ markerWidth() -
51
+ actionWidth() -
52
+ zoomWidth() -
53
+ badgeWidth(statusLabel()) -
54
+ 12;
48
55
  if (!presence() || available < presence().badge.length + 2)
49
56
  return 0;
50
57
  return Math.min(available, terminalDisplayWidth(activityLabel()) + 2);
@@ -56,12 +63,6 @@ export function PaneTitleBar(props) {
56
63
  // Reserve these cells even when the control is quiet or unavailable. Hover
57
64
  // and menu lifetime must never change title clipping or terminal geometry.
58
65
  const actionWidth = () => (safeWidth() >= 7 ? 3 : 0);
59
- const actionsVisible = () => props.selected ||
60
- props.keyboardFocused ||
61
- props.terminalFocused ||
62
- hovered() ||
63
- props.menuFocused ||
64
- props.menuOpen;
65
66
  const zoomLabel = () => !props.zoomed
66
67
  ? ""
67
68
  : safeWidth() >= 32
@@ -146,16 +147,18 @@ export function PaneTitleBar(props) {
146
147
  }}>
147
148
  {clipTerminal(zoomLabel(), zoomWidth())}
148
149
  </text>) : null}
149
- <Show when={activityBadge()} keyed>
150
- {(activity) => (<Badge theme={props.theme} label={activity.label} width={activity.width} tone={activity.presence.tone === "danger"
150
+ <box id={`pane-header-status:${props.paneId}`} width={activityWidth() + (showBadge() ? badgeWidth(statusLabel()) : 0)} height={1} flexShrink={0} flexDirection="row" backgroundColor={props.theme.roles.surfaces.panel}>
151
+ <Show when={activityBadge()} keyed>
152
+ {(activity) => (<Badge theme={props.theme} label={activity.label} width={activity.width} tone={activity.presence.tone === "danger"
151
153
  ? "destructive"
152
154
  : activity.presence.tone === "info"
153
155
  ? "accent"
154
- : "done"} selected={props.selected} focused={props.keyboardFocused || props.terminalFocused}/>)}
155
- </Show>
156
- {showBadge() ? (<AgentBadge theme={props.theme} label={statusLabel()} status={status()} activity={props.activity} width={badgeWidth(statusLabel())} selected={props.selected} focused={props.keyboardFocused || props.terminalFocused} hovered={hovered()} attention={props.attention}/>) : null}
157
- {actionWidth() > 0 ? (<box width={actionWidth()} height={1} flexShrink={0}>
158
- <IconButton theme={props.theme} icon={actionsVisible() ? "⋯" : " "} label="Pane actions" variant="ghost" width={actionWidth()} focused={props.menuFocused} selected={props.menuOpen} disabled={props.menuDisabled} background={palette().background} onPress={() => activateMenu()}/>
156
+ : "done"}/>)}
157
+ </Show>
158
+ {showBadge() ? (<AgentBadge theme={props.theme} label={statusLabel()} status={status()} activity={props.activity} width={badgeWidth(statusLabel())} attention={props.attention}/>) : null}
159
+ </box>
160
+ {actionWidth() > 0 ? (<box width={actionWidth()} height={1} flexShrink={0} onMouseOver={() => setMenuHovered(true)} onMouseOut={() => setMenuHovered(false)}>
161
+ <IconButton theme={props.theme} icon="⋯" label="Pane actions" variant="secondary" width={actionWidth()} hovered={menuHovered()} focused={props.menuFocused} selected={props.menuOpen} disabled={props.menuDisabled} background={props.theme.roles.surfaces.panelRaised} onPress={() => activateMenu()}/>
159
162
  </box>) : null}
160
163
  </box>);
161
164
  }
@@ -1,7 +1,7 @@
1
1
  // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
2
 
3
3
  exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s compact shell chrome 1`] = `
4
- " ⌂ !❯ ▤ ± ◆ ready ⧉ web !blocked F5 palette
4
+ " ⌂ !❯ ▤ ± ◆ ⧉ web !blocked F5 palette
5
5
  tmux ┌──────────────────────────────────────────────────────────┐
6
6
  ● web │ › compact workspace │
7
7
  ○ api │● Active view and keyboard focus are selected terminal│
@@ -24,11 +24,11 @@ exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s compact shell
24
24
  │ │
25
25
  │ │
26
26
  └──────────────────────────────────────────────────────────┘
27
- F5 · ^q quit F6 Sessions F7 Attention F5 Commands"
27
+ F5 · ^q quit F6 Sessions F7 Attention F10 Sidebar F5 Commands"
28
28
  `;
29
29
 
30
30
  exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s standard shell chrome 1`] = `
31
- " ▸ tmux-ide ⌂ Home !❯ Terminal ▤ Files ± Diff ◆ Missions ready ⧉ web !blocked F5 palette
31
+ " ▸ tmux-ide ⌂ Home !❯ Terminal ▤ Files ± Diff ◆ Missions ⧉ web !blocked F5 palette
32
32
  tmux-ide ┌──────────────────────────────────────────────────────────────────────────────────────────┐
33
33
  ● web │ › standard workspace │
34
34
  ○ api │● Active view and keyboard focus are selected terminal│
@@ -67,11 +67,11 @@ exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s standard shell
67
67
  │ │
68
68
  │ │
69
69
  └──────────────────────────────────────────────────────────────────────────────────────────┘
70
- F5 palette · ^q quit F6 Sessions F7 Attention F5 Commands"
70
+ F5 palette · ^q quit F6 Sessions F7 Attention F10 Sidebar F5 Commands"
71
71
  `;
72
72
 
73
73
  exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s wide shell chrome 1`] = `
74
- " ▸ tmux-ide F1 ⌂ Home F2 !❯ Terminal F3 ▤ Files F4 ± Diff F6 ◆ Missions ready ⧉ web !blocked F5 palette
74
+ " ▸ tmux-ide F1 ⌂ Home F2 !❯ Terminal F3 ▤ Files F4 ± Diff F6 ◆ Missions ⧉ web !blocked F5 palette
75
75
  tmux-ide ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
76
76
  ● web │ › wide workspace │
77
77
  ○ api │● Active view and keyboard focus are selected terminal│
@@ -130,5 +130,5 @@ exports[`ShellChrome OpenTUI renderer renders deterministic %sx%s wide shell chr
130
130
  │ │
131
131
  │ │
132
132
  └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
133
- F5 palette · ^q quit F6 Sessions F7 Attention F5 Commands"
133
+ F5 palette · ^q quit F6 Sessions F7 Attention F10 Sidebar F5 Commands"
134
134
  `;
@@ -1,7 +1,7 @@
1
1
  // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
2
 
3
3
  exports[`production ApplicationShellView retains shell, window strip, pane chrome, and canvas at %sx%s 1`] = `
4
- " ⌂ ●❯ live
4
+ " ⌂ ●❯ Local · main
5
5
  tmux ● main ● workin… +
6
6
  ● main ● Codex ● working ⋯
7
7
  ○ website CANONICAL-CELL
@@ -13,11 +13,11 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
13
13
 
14
14
  …
15
15
 
16
- F6 Sessions F7 Attention F5 Commands"
16
+ F6 Sessions F7 Attention F10 Sidebar F5 Commands"
17
17
  `;
18
18
 
19
19
  exports[`production ApplicationShellView retains shell, window strip, pane chrome, and canvas at %sx%s 2`] = `
20
- " tmux-ide ⌂ Home ●❯ Terminals live
20
+ " tmux-ide ⌂ Home ●❯ Terminals Local · main
21
21
  tmux-ide ● main ● workin… + New window
22
22
  ● main ● Codex ● working ⋯
23
23
  ○ website CANONICAL-CELL
@@ -29,11 +29,11 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
29
29
 
30
30
  …
31
31
 
32
- F6 Sessions F7 Attention F5 Commands"
32
+ F6 Sessions F7 Attention F10 Sidebar F5 Commands"
33
33
  `;
34
34
 
35
35
  exports[`production ApplicationShellView retains shell, window strip, pane chrome, and canvas at %sx%s 3`] = `
36
- " tmux-ide F1 ⌂ Home F2 ●❯ Terminals live
36
+ " tmux-ide F1 ⌂ Home F2 ●❯ Terminals Local · main
37
37
  tmux-ide ● main ● workin… + New window
38
38
  ● main ● Codex ● working ⋯
39
39
  ○ website CANONICAL-CELL
@@ -45,11 +45,11 @@ exports[`production ApplicationShellView retains shell, window strip, pane chrom
45
45
 
46
46
  …
47
47
 
48
- F6 Sessions F7 Attention F5 Commands"
48
+ F6 Sessions F7 Attention F10 Sidebar F5 Commands"
49
49
  `;
50
50
 
51
51
  exports[`production ApplicationShellView reacts palette geometry through narrow live resizes 1`] = `
52
- " ⌂ ●❯ live
52
+ " ⌂ ●❯
53
53
  t Command palette esc
54
54
  m Search commands…
55
55
  2 Home F1
@@ -61,7 +61,7 @@ exports[`production ApplicationShellView reacts palette geometry through narrow
61
61
  `;
62
62
 
63
63
  exports[`production ApplicationShellView reacts palette geometry through narrow live resizes 2`] = `
64
- " ⌂ ●❯ live
64
+ " ⌂ ●❯
65
65
  tCommand palette
66
66
  m …rch commands…
67
67
  2 Home F1w
@@ -71,9 +71,9 @@ exports[`production ApplicationShellView reacts palette geometry through narrow
71
71
  `;
72
72
 
73
73
  exports[`production ApplicationShellView preserves canonical attention while prioritizing mixed window activity 1`] = `
74
- " tmux-ide ⌂ Home ●❯ Terminals live
74
+ " tmux-ide ⌂ Home ●❯ Terminals Local · main
75
75
  tmux-ide ! main ! workin… + New window
76
- ● main ● Codex ● working ⋯ ! Scout ! idle
76
+ ● main ● Codex ● working ⋯ ! Scout ! idle ⋯
77
77
  CANONICAL-CELL CANONICAL-CELL
78
78
  Agents
79
79
  ● Codex [WORKING]
@@ -83,5 +83,5 @@ exports[`production ApplicationShellView preserves canonical attention while pri
83
83
 
84
84
  …
85
85
 
86
- F6 Sessions F7 Attention F5 Commands"
86
+ F6 Sessions F7 Attention F10 Sidebar F5 Commands"
87
87
  `;
@@ -10,7 +10,7 @@ import type { ApplicationAppearanceOwner } from "./application-appearance-owner.
10
10
  /* @jsxImportSource @opentui/solid */
11
11
  import type { ApplicationShellProjectionV1 } from "@tmux-ide/contracts";
12
12
  import type { Accessor, ComponentProps, JSX } from "solid-js";
13
- import { Show, createMemo } from "solid-js";
13
+ import { Show, createMemo, createSignal } from "solid-js";
14
14
 
15
15
  import { friendlySessionLabel } from "../terminal-text.ts";
16
16
  import { ApplicationShell } from "../workspace/application-shell-view.tsx";
@@ -163,6 +163,7 @@ export interface ApplicationShellViewProps {
163
163
 
164
164
  /** Pure Solid composition over the WorkspaceClient semantic projection. */
165
165
  export function ApplicationShellView(props: ApplicationShellViewProps): JSX.Element {
166
+ const [scrollback, setScrollback] = createSignal(false);
166
167
  const projection = createMemo(() => {
167
168
  const semantic = props.semantic();
168
169
  if (!semantic) return null;
@@ -395,14 +396,13 @@ export function ApplicationShellView(props: ApplicationShellViewProps): JSX.Elem
395
396
  >
396
397
  <ApplicationShell
397
398
  footerContext={props.surface() === "home" ? "home" : "terminals"}
399
+ scrollback={props.surface() === "terminals" && scrollback()}
398
400
  rightChips={
399
- props.machineLabel
401
+ props.surface() === "terminals" && props.dimensions().width >= 60
400
402
  ? [
401
403
  {
402
- id: "machine",
403
- label: `SSH ${props.machineLabel}`,
404
- context: true,
405
- textColor: props.machineColor,
404
+ id: "workspace-context",
405
+ label: ` ${props.machineLabel ?? "Local"} · ${friendlySessionLabel(shell.activeSession)} `,
406
406
  },
407
407
  ]
408
408
  : undefined
@@ -413,6 +413,11 @@ export function ApplicationShellView(props: ApplicationShellViewProps): JSX.Elem
413
413
  onHelp={() => props.onSetPaletteOpen(true, "mouse")}
414
414
  note={
415
415
  props.bootstrapNote() ??
416
+ (props.copyFeedback
417
+ ? props.copyFeedback.copied
418
+ ? "Copied"
419
+ : "Copy unavailable"
420
+ : null) ??
416
421
  (props.generationStatus() === "read-only"
417
422
  ? `Read-only input · select a pane to request control · shared tmux ${props.layout().current?.cols ?? "?"}×${props.layout().current?.rows ?? "?"}`
418
423
  : props.generationStatus())
@@ -517,6 +522,7 @@ export function ApplicationShellView(props: ApplicationShellViewProps): JSX.Elem
517
522
  >
518
523
  {(source) => (
519
524
  <ApplicationTerminalWorkspace
525
+ onScrollbackChange={setScrollback}
520
526
  layout={props.layout}
521
527
  adapter={source.adapter}
522
528
  rendererEpoch={source.rendererEpoch}
@@ -148,6 +148,7 @@ export function beginApplicationMouseIngress(
148
148
  }
149
149
 
150
150
  export interface ApplicationTerminalWorkspaceProps {
151
+ readonly onScrollbackChange?: (active: boolean) => void;
151
152
  readonly paneInteractions?: Accessor<ReadonlyMap<string, PaneInteractionProjection>>;
152
153
  readonly layout: Accessor<OpenTuiWorkspaceLayoutSnapshot>;
153
154
  readonly adapter: PaneScopedTerminalAdapter;
@@ -318,6 +319,12 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
318
319
  },
319
320
  (paneId) => props.adapter.retainPaneView?.(paneId) ?? null,
320
321
  );
322
+
323
+ createEffect(() => {
324
+ const paneId = props.focusedPane;
325
+ props.onScrollbackChange?.(paneId !== null && scrollback.offset(paneId) > 0);
326
+ });
327
+ onCleanup(() => props.onScrollbackChange?.(false));
321
328
  const selectionViewport = (paneId: string, frame: OpenTuiPaneFrame) =>
322
329
  props.adapter.renderSource.supportsViewportOrigin
323
330
  ? {
@@ -1880,6 +1887,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1880
1887
  <Show when={windowLinkMenu()}>
1881
1888
  <Menu
1882
1889
  theme={props.theme}
1890
+ viewportOrigin={{ x: props.originX ?? 0, y: props.originY ?? 0 }}
1883
1891
  title="Window link"
1884
1892
  left={0}
1885
1893
  top={1}
@@ -1902,6 +1910,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1902
1910
  width={paneContextMenuWidth()}
1903
1911
  viewportWidth={props.width}
1904
1912
  viewportHeight={props.height + topOffset()}
1913
+ viewportOrigin={{ x: props.originX ?? 0, y: props.originY ?? 0 }}
1905
1914
  paneTitle={menu().displayName}
1906
1915
  active
1907
1916
  onDismiss={paneMenu.dismiss}
@@ -31,7 +31,13 @@ export function createApplicationTransientNoteOwner(options: {
31
31
  publish(note) {
32
32
  clear();
33
33
  options.write(note);
34
- if (!note) return;
34
+ if (
35
+ !note ||
36
+ /\b(?:failed|failure|error|unavailable|disconnected|reconnecting|recovering|read.only|denied)\b/iu.test(
37
+ note,
38
+ )
39
+ )
40
+ return;
35
41
  timer = clock.setTimeout(() => {
36
42
  timer = null;
37
43
  if (options.read() === note) options.write(null);
@@ -2,6 +2,7 @@
2
2
  import { For, Show } from "solid-js";
3
3
  import {
4
4
  contextStatusPresentation,
5
+ meaningfulStatusMessage,
5
6
  developmentChromeLabel,
6
7
  shellNavigationPresentation,
7
8
  shellSurfaceTabs,
@@ -106,7 +107,14 @@ export function ShellTabBar(props: ShellTabBarProps) {
106
107
  />
107
108
  )}
108
109
  </For>
109
- <For each={props.note ? [props.note] : []}>
110
+ <For
111
+ each={
112
+ meaningfulStatusMessage(props.note) &&
113
+ /read.only|reconnect|disconnect|recover|unavailable|failed/iu.test(props.note ?? "")
114
+ ? [props.note!]
115
+ : []
116
+ }
117
+ >
110
118
  {(note) => (
111
119
  <Badge
112
120
  theme={props.theme}
@@ -117,24 +125,28 @@ export function ShellTabBar(props: ShellTabBarProps) {
117
125
  1,
118
126
  Math.min(Math.floor(props.width / 3), terminalDisplayWidth(note) + 1),
119
127
  )}
120
- tone="accent"
128
+ tone="warning"
121
129
  />
122
130
  )}
123
131
  </For>
124
132
  <For each={props.rightChips ?? []}>
125
133
  {(chip) => (
126
- <Badge
127
- theme={props.theme}
128
- label={chip.label.trim()}
129
- textColor={chip.textColor}
130
- width={Math.max(1, terminalDisplayWidth(chip.label) + (chip.context ? 1 : 0))}
131
- presentation={`${chip.label}${chip.context ? " " : ""}`}
132
- surface="header"
133
- hovered={chip.hovered}
134
- context={chip.context}
135
- attention={chip.attention}
136
- tone={chip.attention ? "warning" : chip.context ? "accent" : "neutral"}
137
- />
134
+ <text
135
+ width={Math.min(
136
+ Math.max(1, Math.floor(props.width / 3)),
137
+ terminalDisplayWidth(chip.label),
138
+ )}
139
+ flexShrink={0}
140
+ fg={
141
+ chip.attention
142
+ ? props.theme.roles.statusTone.warning
143
+ : (chip.textColor ?? props.theme.roles.text.muted)
144
+ }
145
+ bg={props.theme.roles.surfaces.panel}
146
+ overflow="hidden"
147
+ >
148
+ {clipTerminal(chip.label, Math.max(1, Math.floor(props.width / 3)))}
149
+ </text>
138
150
  )}
139
151
  </For>
140
152
  </Surface>
@@ -152,6 +164,7 @@ export interface ShellStatusStripProps {
152
164
  tool?: string | null;
153
165
  dockMode?: string | null;
154
166
  focus?: string | null;
167
+ scrollback?: boolean;
155
168
  notification: string | null;
156
169
  transient?: string | null;
157
170
  connectionState?: "connected" | "reconnecting" | "disconnected" | "recovering";
@@ -178,16 +191,19 @@ export function ContextStatusBar(props: ShellStatusStripProps) {
178
191
  const activity = () => presentation().activity;
179
192
  const showMessage = () => !/^(?:Live|\d+ sessions? live)$/iu.test(activity().label);
180
193
  const hints = () => {
181
- const candidates = /^home$/iu.test(props.mode)
182
- ? [
183
- { keys: "↑↓", label: "Select" },
184
- { keys: "Enter", label: "Open" },
185
- { keys: "/", label: "Search" },
186
- ]
187
- : [
188
- { keys: "F6", label: "Sessions" },
189
- { keys: "F7", label: "Attention" },
190
- ];
194
+ const candidates = props.scrollback
195
+ ? [{ keys: "Esc", label: "Back to live" }]
196
+ : /^home$/iu.test(props.mode)
197
+ ? [
198
+ { keys: "↑↓", label: "Select" },
199
+ { keys: "Enter", label: "Open" },
200
+ { keys: "/", label: "Search" },
201
+ ]
202
+ : [
203
+ { keys: "F6", label: "Sessions" },
204
+ { keys: "F7", label: "Attention" },
205
+ { keys: "F10", label: "Sidebar" },
206
+ ];
191
207
  let remaining = available();
192
208
  return candidates.filter((hint) => {
193
209
  const width = terminalDisplayWidth(`${hint.keys} ${hint.label}`) + 2;
@@ -385,9 +385,13 @@ export interface ContextStatusPresentation {
385
385
  readonly hints: readonly ContextStatusHint[];
386
386
  }
387
387
 
388
- function meaningfulStatusMessage(message: string | null | undefined): string | null {
388
+ export function meaningfulStatusMessage(message: string | null | undefined): string | null {
389
389
  const value = message?.trim();
390
- if (!value || /^(?:ready|live|connected)$/iu.test(value)) return null;
390
+ if (
391
+ !value ||
392
+ /^(?:ready|live|connected|live tmux sessions? discovered|\d+ sessions? live)$/iu.test(value)
393
+ )
394
+ return null;
391
395
  return value;
392
396
  }
393
397
 
@@ -445,7 +449,14 @@ export function contextStatusPresentation(input: {
445
449
  : input.connectionState === "recovering"
446
450
  ? "Recovering"
447
451
  : "Disconnected";
448
- const activityLabel = transient ?? notification ?? connectionLabel;
452
+ const persistentProblem =
453
+ notification && contextStatusTone(notification, "connected").tone === "blocked";
454
+ const activityLabel =
455
+ input.connectionState !== "connected"
456
+ ? (notification ?? connectionLabel)
457
+ : persistentProblem
458
+ ? notification
459
+ : (transient ?? notification ?? connectionLabel);
449
460
  const activityTone = contextStatusTone(activityLabel, input.connectionState);
450
461
  const location: ContextStatusLocationSegment[] =
451
462
  input.variant === "wide"
@@ -10,6 +10,9 @@
10
10
  * verbatim, aliasing @opentui/core to a browser shim that only exposes RGBA.
11
11
  */
12
12
  import { RGBA } from "@opentui/core";
13
+
14
+ /** Shared scrim for modal surfaces across light and dark themes. */
15
+ export const MODAL_BACKDROP = RGBA.fromInts(0, 0, 0, 150);
13
16
  import {
14
17
  BUILTIN_VISUAL_THEMES,
15
18
  findVisualThemePreset,
@@ -16,7 +16,7 @@ export interface TuiButtonProps extends ComponentInteractionState {
16
16
  variant?: ButtonVariant;
17
17
  size?: ButtonSize;
18
18
  width?: number;
19
- /** Opaque surface used by neutral/ghost controls embedded in custom chrome. */
19
+ /** Opaque base surface used by neutral controls embedded in custom chrome. */
20
20
  background?: RGBA;
21
21
  onPress?: () => void;
22
22
  }
@@ -55,7 +55,7 @@ export function TuiButton(props: TuiButtonProps) {
55
55
  return clipTerminal(`${" ".repeat(inset)}${marker}${props.label}${shortcut} `, width());
56
56
  };
57
57
  const background = () =>
58
- variant() === "ghost" && palette().state === "base"
58
+ palette().state === "base" && (variant() === "ghost" || variant() === "secondary")
59
59
  ? (props.background ?? props.theme.roles.surfaces.panel)
60
60
  : palette().background;
61
61
  const activate = () => {
@@ -24,6 +24,7 @@ export interface MenuProps {
24
24
  width: number;
25
25
  viewportWidth?: number;
26
26
  viewportHeight?: number;
27
+ viewportOrigin?: Readonly<{ x: number; y: number }>;
27
28
  zIndex?: number;
28
29
  active?: boolean;
29
30
  footer?: string;
@@ -53,6 +54,7 @@ export function Menu(props: MenuProps) {
53
54
  }
54
55
  width={props.width}
55
56
  height={preferredHeight()}
57
+ viewportOrigin={props.viewportOrigin}
56
58
  placement="anchor"
57
59
  anchor={{ x: props.left, y: props.top }}
58
60
  zIndex={props.zIndex ?? 20}
@@ -1,7 +1,8 @@
1
1
  /* @jsxImportSource @opentui/solid */
2
- import type { JSX } from "@opentui/solid";
2
+ import { Portal, useTerminalDimensions, type JSX } from "@opentui/solid";
3
+ import { type BoxRenderable } from "@opentui/core";
3
4
 
4
- import type { SemanticThemeSnapshot } from "../theme.ts";
5
+ import { MODAL_BACKDROP, type SemanticThemeSnapshot } from "../theme.ts";
5
6
  import { clipTerminal } from "../terminal-text.ts";
6
7
  import { overlayFrameSize, overlaySurfacePadding, type OverlayPlacement } from "./overlay-model.ts";
7
8
 
@@ -17,6 +18,8 @@ export interface OverlayFrameProps {
17
18
  footer?: string;
18
19
  placement?: OverlayPlacement;
19
20
  anchor?: Readonly<{ x: number; y: number }>;
21
+ /** Lift an anchored workspace menu into the full application overlay plane. */
22
+ viewportOrigin?: Readonly<{ x: number; y: number }>;
20
23
  modal?: boolean;
21
24
  active?: boolean;
22
25
  zIndex?: number;
@@ -27,8 +30,11 @@ export interface OverlayFrameProps {
27
30
 
28
31
  /** Owns modal geometry and pointer capture without owning commands or renderer lifecycle. */
29
32
  export function OverlayFrame(props: OverlayFrameProps) {
30
- const viewportWidth = () => Math.max(1, Math.floor(props.viewportWidth));
31
- const viewportHeight = () => Math.max(1, Math.floor(props.viewportHeight));
33
+ const dimensions = useTerminalDimensions();
34
+ const viewportWidth = () =>
35
+ Math.max(1, Math.floor(props.viewportOrigin ? dimensions().width : props.viewportWidth));
36
+ const viewportHeight = () =>
37
+ Math.max(1, Math.floor(props.viewportOrigin ? dimensions().height : props.viewportHeight));
32
38
  const size = () =>
33
39
  overlayFrameSize({
34
40
  viewportWidth: viewportWidth(),
@@ -48,13 +54,25 @@ export function OverlayFrame(props: OverlayFrameProps) {
48
54
  );
49
55
  const left = () => {
50
56
  if (props.placement === "anchor")
51
- return Math.max(0, Math.min(props.anchor?.x ?? 0, viewportWidth() - width()));
57
+ return Math.max(
58
+ 0,
59
+ Math.min(
60
+ (props.anchor?.x ?? 0) + (props.viewportOrigin?.x ?? 0),
61
+ viewportWidth() - width(),
62
+ ),
63
+ );
52
64
  if (props.placement === "top-right") return Math.max(0, viewportWidth() - width() - 1);
53
65
  return Math.max(0, Math.floor((viewportWidth() - width()) / 2));
54
66
  };
55
67
  const top = () => {
56
68
  if (props.placement === "anchor")
57
- return Math.max(0, Math.min(props.anchor?.y ?? 0, viewportHeight() - height()));
69
+ return Math.max(
70
+ 0,
71
+ Math.min(
72
+ (props.anchor?.y ?? 0) + (props.viewportOrigin?.y ?? 0),
73
+ viewportHeight() - height(),
74
+ ),
75
+ );
58
76
  if (props.placement === "top-right") return Math.min(1, viewportHeight() - height());
59
77
  return Math.max(0, Math.floor((viewportHeight() - height()) / 2));
60
78
  };
@@ -139,7 +157,7 @@ export function OverlayFrame(props: OverlayFrameProps) {
139
157
  </box>
140
158
  );
141
159
  if (props.modal === false) return frame();
142
- return (
160
+ const host = () => (
143
161
  <box
144
162
  id="ui-overlay-frame-host"
145
163
  position="absolute"
@@ -148,6 +166,7 @@ export function OverlayFrame(props: OverlayFrameProps) {
148
166
  width={viewportWidth()}
149
167
  height={viewportHeight()}
150
168
  zIndex={props.zIndex ?? 100}
169
+ backgroundColor={props.active === false ? undefined : MODAL_BACKDROP}
151
170
  onMouseDown={(event) => {
152
171
  event.preventDefault();
153
172
  event.stopPropagation();
@@ -157,4 +176,21 @@ export function OverlayFrame(props: OverlayFrameProps) {
157
176
  {frame()}
158
177
  </box>
159
178
  );
179
+ return props.viewportOrigin ? (
180
+ <Portal
181
+ ref={(node) => {
182
+ const container = node as BoxRenderable;
183
+ container.position = "absolute";
184
+ container.left = 0;
185
+ container.top = 0;
186
+ container.width = "100%";
187
+ container.height = "100%";
188
+ container.zIndex = props.zIndex ?? 100;
189
+ }}
190
+ >
191
+ {host()}
192
+ </Portal>
193
+ ) : (
194
+ host()
195
+ );
160
196
  }
@@ -1,7 +1,7 @@
1
1
  // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
2
 
3
3
  exports[`production ApplicationShell → WorkbenchShell OpenTUI renderer records the %sx%s collapsed dock acceptance baseline 1`] = `
4
- " ⌂ ●❯ production application ro…
4
+ " ⌂ ●❯
5
5
  tmux ▌› Native terminal canvas terminals · canvas
6
6
  ● main
7
7
  ○ website
@@ -28,7 +28,7 @@ exports[`production ApplicationShell → WorkbenchShell OpenTUI renderer records
28
28
  `;
29
29
 
30
30
  exports[`production ApplicationShell → WorkbenchShell OpenTUI renderer records the %sx%s open dock acceptance baseline 1`] = `
31
- " tmux-ide ⌂ Home ●❯ Terminals production application root
31
+ " tmux-ide ⌂ Home ●❯ Terminals
32
32
  tmux-ide ▌› Native terminal canvas terminals · canvas
33
33
  ● main
34
34
  ○ website
@@ -71,7 +71,7 @@ exports[`production ApplicationShell → WorkbenchShell OpenTUI renderer records
71
71
  `;
72
72
 
73
73
  exports[`production ApplicationShell → WorkbenchShell OpenTUI renderer records the %sx%s maximized dock acceptance baseline 1`] = `
74
- " tmux-ide F1 ⌂ Home F2 ●❯ Terminals production application root
74
+ " tmux-ide F1 ⌂ Home F2 ●❯ Terminals
75
75
  tmux-ide ▸ tools F3 ▤ Files F4 ± Changes F6 ●◆ Missions F9 ◷ Activity ▾ close ▣ restore
76
76
  ● main ▌○ Native Missions surface maximized
77
77
  ○ website
@@ -11,6 +11,7 @@ import type { SemanticThemeSnapshot } from "../theme.ts";
11
11
  import type { ApplicationShellProjection } from "./application-shell.ts";
12
12
 
13
13
  export interface ApplicationShellProps {
14
+ scrollback?: boolean;
14
15
  footerContext?: "home" | "terminals";
15
16
  theme: SemanticThemeSnapshot;
16
17
  projection: ApplicationShellProjection;
@@ -144,6 +145,7 @@ export function ApplicationShell(props: ApplicationShellProps) {
144
145
  props.showToolStatus === false ? null : props.projection.semantic.bottomDock.mode
145
146
  }
146
147
  focus={props.focusLabel ?? applicationShellFocusLabel(props.projection)}
148
+ scrollback={props.scrollback}
147
149
  notification={props.projection.semantic.statusStrip.message}
148
150
  transient={props.note}
149
151
  connectionState={props.projection.semantic.statusStrip.state}
@@ -12,6 +12,7 @@ export interface PaneActionMenuProps {
12
12
  readonly width: number;
13
13
  readonly viewportWidth?: number;
14
14
  readonly viewportHeight?: number;
15
+ readonly viewportOrigin?: Readonly<{ x: number; y: number }>;
15
16
  readonly active?: boolean;
16
17
  readonly selectedId: PaneMenuActionId;
17
18
  readonly closeArmed: boolean;
@@ -39,6 +40,7 @@ export function PaneActionMenu(props: PaneActionMenuProps) {
39
40
  width={props.width}
40
41
  viewportWidth={props.viewportWidth}
41
42
  viewportHeight={props.viewportHeight}
43
+ viewportOrigin={props.viewportOrigin}
42
44
  active={props.active}
43
45
  title={props.paneTitle}
44
46
  footer={
@@ -84,6 +84,7 @@ function badgeWidth(status: string | undefined): number {
84
84
  */
85
85
  export function PaneTitleBar(props: PaneTitleBarProps) {
86
86
  const [pointerInside, setPointerInside] = createSignal(false);
87
+ const [menuHovered, setMenuHovered] = createSignal(false);
87
88
  const hovered = () => props.hovered ?? pointerInside();
88
89
  const safeWidth = () => Math.max(1, Math.floor(props.width));
89
90
  const status = () => agentStatus(props.activity);
@@ -96,11 +97,17 @@ export function PaneTitleBar(props: PaneTitleBarProps) {
96
97
  const presence = () => (props.interaction ? paneInteractionPresence(props.interaction) : null);
97
98
  const activityLabel = () =>
98
99
  presence()
99
- ? `${presence()!.badge}${props.interaction?.origin === "external" ? " · External tmux" : ""}`
100
+ ? `${presence()!.badge[0]}${presence()!.badge.slice(1).toLowerCase()}${props.interaction?.origin === "external" ? " · tmux" : ""}`
100
101
  : "";
101
102
  const activityWidth = () => {
102
103
  const available =
103
- safeWidth() - markerGutterWidth() - markerWidth() - actionWidth() - zoomWidth() - 4;
104
+ safeWidth() -
105
+ markerGutterWidth() -
106
+ markerWidth() -
107
+ actionWidth() -
108
+ zoomWidth() -
109
+ badgeWidth(statusLabel()) -
110
+ 12;
104
111
  if (!presence() || available < presence()!.badge.length + 2) return 0;
105
112
  return Math.min(available, terminalDisplayWidth(activityLabel()) + 2);
106
113
  };
@@ -111,13 +118,6 @@ export function PaneTitleBar(props: PaneTitleBarProps) {
111
118
  // Reserve these cells even when the control is quiet or unavailable. Hover
112
119
  // and menu lifetime must never change title clipping or terminal geometry.
113
120
  const actionWidth = () => (safeWidth() >= 7 ? 3 : 0);
114
- const actionsVisible = () =>
115
- props.selected ||
116
- props.keyboardFocused ||
117
- props.terminalFocused ||
118
- hovered() ||
119
- props.menuFocused ||
120
- props.menuOpen;
121
121
  const zoomLabel = () =>
122
122
  !props.zoomed
123
123
  ? ""
@@ -267,49 +267,60 @@ export function PaneTitleBar(props: PaneTitleBarProps) {
267
267
  {clipTerminal(zoomLabel(), zoomWidth())}
268
268
  </text>
269
269
  ) : null}
270
- <Show when={activityBadge()} keyed>
271
- {(activity) => (
272
- <Badge
270
+ <box
271
+ id={`pane-header-status:${props.paneId}`}
272
+ width={activityWidth() + (showBadge() ? badgeWidth(statusLabel()) : 0)}
273
+ height={1}
274
+ flexShrink={0}
275
+ flexDirection="row"
276
+ backgroundColor={props.theme.roles.surfaces.panel}
277
+ >
278
+ <Show when={activityBadge()} keyed>
279
+ {(activity) => (
280
+ <Badge
281
+ theme={props.theme}
282
+ label={activity.label}
283
+ width={activity.width}
284
+ tone={
285
+ activity.presence.tone === "danger"
286
+ ? "destructive"
287
+ : activity.presence.tone === "info"
288
+ ? "accent"
289
+ : "done"
290
+ }
291
+ />
292
+ )}
293
+ </Show>
294
+ {showBadge() ? (
295
+ <AgentBadge
273
296
  theme={props.theme}
274
- label={activity.label}
275
- width={activity.width}
276
- tone={
277
- activity.presence.tone === "danger"
278
- ? "destructive"
279
- : activity.presence.tone === "info"
280
- ? "accent"
281
- : "done"
282
- }
283
- selected={props.selected}
284
- focused={props.keyboardFocused || props.terminalFocused}
297
+ label={statusLabel()!}
298
+ status={status()!}
299
+ activity={props.activity}
300
+ width={badgeWidth(statusLabel())}
301
+ attention={props.attention}
285
302
  />
286
- )}
287
- </Show>
288
- {showBadge() ? (
289
- <AgentBadge
290
- theme={props.theme}
291
- label={statusLabel()!}
292
- status={status()!}
293
- activity={props.activity}
294
- width={badgeWidth(statusLabel())}
295
- selected={props.selected}
296
- focused={props.keyboardFocused || props.terminalFocused}
297
- hovered={hovered()}
298
- attention={props.attention}
299
- />
300
- ) : null}
303
+ ) : null}
304
+ </box>
301
305
  {actionWidth() > 0 ? (
302
- <box width={actionWidth()} height={1} flexShrink={0}>
306
+ <box
307
+ width={actionWidth()}
308
+ height={1}
309
+ flexShrink={0}
310
+ onMouseOver={() => setMenuHovered(true)}
311
+ onMouseOut={() => setMenuHovered(false)}
312
+ >
303
313
  <IconButton
304
314
  theme={props.theme}
305
- icon={actionsVisible() ? "⋯" : " "}
315
+ icon="⋯"
306
316
  label="Pane actions"
307
- variant="ghost"
317
+ variant="secondary"
308
318
  width={actionWidth()}
319
+ hovered={menuHovered()}
309
320
  focused={props.menuFocused}
310
321
  selected={props.menuOpen}
311
322
  disabled={props.menuDisabled}
312
- background={palette().background}
323
+ background={props.theme.roles.surfaces.panelRaised}
313
324
  onPress={() => activateMenu()}
314
325
  />
315
326
  </box>