tmux-ide 2.9.0-beta.40 → 2.9.0-beta.41

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 (37) hide show
  1. package/README.md +5 -0
  2. package/bin/cli.js +6 -3
  3. package/package.json +6 -3
  4. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-tabs.js +29 -2
  5. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +10 -13
  6. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +1 -0
  7. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +45 -7
  8. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +43 -31
  9. package/packages/daemon/dist/tui/mirror/runtime/application-palette-command-owner.js +2 -2
  10. package/packages/daemon/dist/tui/mirror/runtime/application-palette-input.js +1 -0
  11. package/packages/daemon/dist/tui/mirror/ui/agent-status-marker.js +6 -5
  12. package/packages/daemon/dist/tui/mirror/ui/icons.js +9 -0
  13. package/packages/daemon/dist/tui/mirror/ui/index.js +3 -0
  14. package/packages/daemon/dist/tui/mirror/ui/session-row.jsx +71 -0
  15. package/packages/daemon/dist/tui/mirror/ui/working-sessions.jsx +61 -0
  16. package/packages/daemon/dist/tui/mirror/workspace/application-action-descriptions.js +51 -0
  17. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +10 -3
  18. package/packages/daemon/dist/tui/mirror/workspace/application-shortcuts.js +15 -7
  19. package/packages/daemon/dist/tui/mirror/workspace/palette-list-layout.js +1 -1
  20. package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +34 -2
  21. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +14 -18
  22. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +1 -0
  23. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +44 -7
  24. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +67 -63
  25. package/packages/daemon/src/tui/mirror/runtime/application-palette-command-owner.ts +4 -2
  26. package/packages/daemon/src/tui/mirror/runtime/application-palette-input.ts +1 -0
  27. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +32 -8
  28. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +2 -1
  29. package/packages/daemon/src/tui/mirror/ui/agent-status-marker.ts +7 -5
  30. package/packages/daemon/src/tui/mirror/ui/icons.ts +9 -0
  31. package/packages/daemon/src/tui/mirror/ui/index.ts +4 -0
  32. package/packages/daemon/src/tui/mirror/ui/session-row.tsx +122 -0
  33. package/packages/daemon/src/tui/mirror/ui/working-sessions.tsx +99 -0
  34. package/packages/daemon/src/tui/mirror/workspace/application-action-descriptions.ts +52 -0
  35. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +12 -3
  36. package/packages/daemon/src/tui/mirror/workspace/application-shortcuts.ts +19 -7
  37. package/packages/daemon/src/tui/mirror/workspace/palette-list-layout.ts +1 -1
package/README.md CHANGED
@@ -146,6 +146,11 @@ environment.
146
146
 
147
147
  Regenerate the production-renderer demo with `pnpm demo:tui`.
148
148
 
149
+ Explore real Home, sidebar, Help, pane-header and footer components with `pnpm gallery:tui`. The
150
+ development gallery uses fixture data and local actions, so it does not connect
151
+ to your daemon or change live tmux sessions. See the
152
+ [gallery controls and checks](scripts/tui-gallery/README.md).
153
+
149
154
  - [Documentation](https://github.com/wavyrai/tmux-ide/tree/main/docs)
150
155
  - [Contributing](CONTRIBUTING.md)
151
156
  - [Release checklist](RELEASE.md)
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.40",
11282
+ version: "2.9.0-beta.41",
11283
11283
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
11284
11284
  type: "module",
11285
11285
  bin: {
@@ -11339,7 +11339,7 @@ var require_package = __commonJS({
11339
11339
  "test:pack-installed": "node scripts/pack-check-run.mjs",
11340
11340
  "release:opentui:check": "node scripts/opentui-release-check.mjs",
11341
11341
  "check:native-deps": "node packages/daemon/scripts/check-native-deps.mjs",
11342
- check: "pnpm run lint:workspace && pnpm run check:control-bytes && pnpm run format:check && pnpm run typecheck:workspace && pnpm run test:postinstall && pnpm run test:portable-release-contract && pnpm run test:benchmark-comparative && pnpm run test:contracts-initializer-purity && pnpm run test:unit && pnpm run test:tui-testdrive && pnpm run test:product-test-rig && pnpm run test:daemon-bun && pnpm run test:tui-renderer && pnpm run test:workbench-dock-package && pnpm run test:pane-frame-package && pnpm run docs:build && pnpm run pack:check && pnpm run test:pack-installed && pnpm run check:native-deps && pnpm run smoke:desktop",
11342
+ check: "pnpm run lint:workspace && pnpm run check:control-bytes && pnpm run format:check && pnpm run typecheck:workspace && pnpm run test:postinstall && pnpm run test:portable-release-contract && pnpm run test:benchmark-comparative && pnpm run test:contracts-initializer-purity && pnpm run test:unit && pnpm run test:tui-testdrive && pnpm run test:product-test-rig && pnpm run test:daemon-bun && pnpm run test:tui-renderer && pnpm run typecheck:tui-gallery && pnpm run test:tui-gallery && pnpm run test:workbench-dock-package && pnpm run test:pane-frame-package && pnpm run docs:build && pnpm run pack:check && pnpm run test:pack-installed && pnpm run check:native-deps && pnpm run smoke:desktop",
11343
11343
  postinstall: "node scripts/postinstall.js",
11344
11344
  docs: "turbo run dev --filter=@tmux-ide/docs",
11345
11345
  "demo:tui": "bun --preload @opentui/solid/preload docs/scripts/render-tui-demo.tsx",
@@ -11374,7 +11374,10 @@ var require_package = __commonJS({
11374
11374
  "test:postinstall": "node --test scripts/postinstall-daemon-upgrade.test.mjs",
11375
11375
  "test:development-isolation": "tsx scripts/lib/development-isolation-qualification.ts",
11376
11376
  "test:development-isolation-unit": "node --test scripts/lib/development-isolation-resources.test.mjs",
11377
- "test:development-ci": "node --test scripts/lib/development-ci.test.mjs"
11377
+ "test:development-ci": "node --test scripts/lib/development-ci.test.mjs",
11378
+ "gallery:tui": "bun --preload @opentui/solid/preload scripts/tui-gallery/index.tsx",
11379
+ "test:tui-gallery": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./scripts/tui-gallery/gallery.test.tsx",
11380
+ "typecheck:tui-gallery": "tsc --noEmit -p scripts/tsconfig.tui-gallery.json"
11378
11381
  },
11379
11382
  keywords: [
11380
11383
  "tmux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.40",
3
+ "version": "2.9.0-beta.41",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@
60
60
  "test:pack-installed": "node scripts/pack-check-run.mjs",
61
61
  "release:opentui:check": "node scripts/opentui-release-check.mjs",
62
62
  "check:native-deps": "node packages/daemon/scripts/check-native-deps.mjs",
63
- "check": "pnpm run lint:workspace && pnpm run check:control-bytes && pnpm run format:check && pnpm run typecheck:workspace && pnpm run test:postinstall && pnpm run test:portable-release-contract && pnpm run test:benchmark-comparative && pnpm run test:contracts-initializer-purity && pnpm run test:unit && pnpm run test:tui-testdrive && pnpm run test:product-test-rig && pnpm run test:daemon-bun && pnpm run test:tui-renderer && pnpm run test:workbench-dock-package && pnpm run test:pane-frame-package && pnpm run docs:build && pnpm run pack:check && pnpm run test:pack-installed && pnpm run check:native-deps && pnpm run smoke:desktop",
63
+ "check": "pnpm run lint:workspace && pnpm run check:control-bytes && pnpm run format:check && pnpm run typecheck:workspace && pnpm run test:postinstall && pnpm run test:portable-release-contract && pnpm run test:benchmark-comparative && pnpm run test:contracts-initializer-purity && pnpm run test:unit && pnpm run test:tui-testdrive && pnpm run test:product-test-rig && pnpm run test:daemon-bun && pnpm run test:tui-renderer && pnpm run typecheck:tui-gallery && pnpm run test:tui-gallery && pnpm run test:workbench-dock-package && pnpm run test:pane-frame-package && pnpm run docs:build && pnpm run pack:check && pnpm run test:pack-installed && pnpm run check:native-deps && pnpm run smoke:desktop",
64
64
  "postinstall": "node scripts/postinstall.js",
65
65
  "docs": "turbo run dev --filter=@tmux-ide/docs",
66
66
  "demo:tui": "bun --preload @opentui/solid/preload docs/scripts/render-tui-demo.tsx",
@@ -95,7 +95,10 @@
95
95
  "test:postinstall": "node --test scripts/postinstall-daemon-upgrade.test.mjs",
96
96
  "test:development-isolation": "tsx scripts/lib/development-isolation-qualification.ts",
97
97
  "test:development-isolation-unit": "node --test scripts/lib/development-isolation-resources.test.mjs",
98
- "test:development-ci": "node --test scripts/lib/development-ci.test.mjs"
98
+ "test:development-ci": "node --test scripts/lib/development-ci.test.mjs",
99
+ "gallery:tui": "bun --preload @opentui/solid/preload scripts/tui-gallery/index.tsx",
100
+ "test:tui-gallery": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./scripts/tui-gallery/gallery.test.tsx",
101
+ "typecheck:tui-gallery": "tsc --noEmit -p scripts/tsconfig.tui-gallery.json"
99
102
  },
100
103
  "keywords": [
101
104
  "tmux",
@@ -1,21 +1,43 @@
1
1
  /** Retained targets, with at most one active terminal owner. Suspended tabs own no streams. */
2
2
  export function createFleetTabs(options) {
3
3
  const tabs = new Map();
4
+ const unread = new Set();
5
+ const observations = new Map();
4
6
  let active = null;
5
7
  let pending = null;
6
8
  let epoch = 0;
7
9
  let disposed = false;
8
10
  return {
9
- snapshot: () => ({ tabs: [...tabs.values()], active }),
11
+ snapshot: () => ({ tabs: [...tabs.values()], active, unread: [...unread] }),
12
+ observe(key, agents) {
13
+ if (disposed || !tabs.has(key))
14
+ return;
15
+ if (!agents) {
16
+ observations.delete(key);
17
+ return;
18
+ }
19
+ const previous = observations.get(key);
20
+ observations.set(key, new Map(agents.map((agent) => [agent.id, agent.activity])));
21
+ if (active !== key &&
22
+ !unread.has(key) &&
23
+ agents.some((agent) => agent.activity === "complete" && previous?.get(agent.id) === "running")) {
24
+ unread.add(key);
25
+ options.publish();
26
+ }
27
+ },
10
28
  remember(target) {
11
29
  if (disposed)
12
30
  return;
13
31
  if (!tabs.has(target.key) && tabs.size >= 8) {
14
32
  const removable = [...tabs.keys()].find((key) => key !== active);
15
- if (removable)
33
+ if (removable) {
16
34
  tabs.delete(removable);
35
+ unread.delete(removable);
36
+ observations.delete(removable);
37
+ }
17
38
  }
18
39
  tabs.set(target.key, Object.freeze({ ...target }));
40
+ unread.delete(target.key);
19
41
  active = target.key;
20
42
  options.publish();
21
43
  },
@@ -41,6 +63,7 @@ export function createFleetTabs(options) {
41
63
  options.unavailable();
42
64
  return false;
43
65
  }
66
+ unread.delete(key);
44
67
  active = key;
45
68
  options.publish();
46
69
  return true;
@@ -55,6 +78,8 @@ export function createFleetTabs(options) {
55
78
  pending = null;
56
79
  }
57
80
  tabs.delete(key);
81
+ unread.delete(key);
82
+ observations.delete(key);
58
83
  options.publish();
59
84
  },
60
85
  suspend() {
@@ -70,6 +95,8 @@ export function createFleetTabs(options) {
70
95
  disposed = true;
71
96
  epoch++;
72
97
  tabs.clear();
98
+ unread.clear();
99
+ observations.clear();
73
100
  active = null;
74
101
  },
75
102
  };
@@ -1,4 +1,5 @@
1
1
  /* @jsxImportSource @opentui/solid */
2
+ import { HOME_ACTIONS, HOME_ACTIVITY_ACTIONS, } from "../workspace/application-action-descriptions.js";
2
3
  import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
3
4
  import { clipTerminal, terminalDisplayWidth } from "../terminal-text.js";
4
5
  import { NavigationRow } from "../ui/navigation-row.jsx";
@@ -100,13 +101,13 @@ export function HomeAgentRoster(props) {
100
101
  return false;
101
102
  const key = event.name.toLowerCase();
102
103
  const filter = !editing() &&
103
- (key === "f"
104
+ (key === HOME_ACTIONS.machine.key
104
105
  ? props.onCycleMachine
105
- : key === "a"
106
+ : key === HOME_ACTIONS.attention.key
106
107
  ? props.onToggleAttention
107
- : key === "w" && props.onSetActivityFilter
108
+ : key === HOME_ACTIONS.working.key && props.onSetActivityFilter
108
109
  ? () => props.onSetActivityFilter?.("working")
109
- : key === "0" && props.onSetActivityFilter
110
+ : key === HOME_ACTIONS.all.key && props.onSetActivityFilter
110
111
  ? () => props.onSetActivityFilter?.("all")
111
112
  : undefined);
112
113
  if (filter) {
@@ -162,7 +163,7 @@ export function HomeAgentRoster(props) {
162
163
  </text>
163
164
  </box>
164
165
  <box width={width()} height={filterRows()} flexShrink={0} flexDirection={filterRows() > 1 ? "column" : "row"} overflow="hidden">
165
- <KeyHint theme={props.theme} keys="f" label={props.filterLabel?.split(" · ")[0] ?? "All machines"} width={props.onSetActivityFilter && filterRows() === 1
166
+ <KeyHint theme={props.theme} keys={HOME_ACTIONS.machine.keys} label={props.filterLabel?.split(" · ")[0] ?? "All machines"} width={props.onSetActivityFilter && filterRows() === 1
166
167
  ? Math.max(1, width() - 47)
167
168
  : props.onSetActivityFilter
168
169
  ? Math.min(width(), 28)
@@ -171,16 +172,12 @@ export function HomeAgentRoster(props) {
171
172
  props.onCycleMachine?.();
172
173
  }}/>
173
174
  <box height={1} flexShrink={0} flexDirection="row" overflow="hidden" width={Math.min(width(), 47)}>
174
- <Show when={props.onSetActivityFilter} fallback={<KeyHint theme={props.theme} keys="a" label="Attention" quiet button onPress={() => {
175
+ <Show when={props.onSetActivityFilter} fallback={<KeyHint theme={props.theme} keys={HOME_ACTIONS.attention.keys} label="Attention" quiet button onPress={() => {
175
176
  if (props.inputActive)
176
177
  props.onToggleAttention?.();
177
178
  }}/>}>
178
- <For each={[
179
- { key: "0", label: "All", value: "all" },
180
- { key: "w", label: "Working", value: "working" },
181
- { key: "a", label: "Needs attention", value: "attention" },
182
- ]}>
183
- {(filter) => (<KeyHint theme={props.theme} keys={filter.key} label={filter.value === "attention" && width() < 47 ? "Attention" : filter.label} quiet button selected={(props.activityFilter ?? "all") === filter.value} onPress={() => {
179
+ <For each={HOME_ACTIVITY_ACTIONS}>
180
+ {(filter) => (<KeyHint theme={props.theme} keys={filter.keys} label={filter.value === "attention" && width() < 47 ? "Attention" : filter.label} quiet button selected={(props.activityFilter ?? "all") === filter.value} onPress={() => {
184
181
  if (props.inputActive)
185
182
  props.onSetActivityFilter?.(filter.value);
186
183
  }}/>)}
@@ -267,7 +264,7 @@ export function HomeAgentRoster(props) {
267
264
  {clipTerminal(footer().replace(/ Enter open$/u, ""), width())}
268
265
  </text>
269
266
  <Show when={footer().endsWith("Enter open") && width() >= 20}>
270
- <KeyHint theme={props.theme} keys="Enter" label="open" quiet button onPress={() => openSelected("mouse")}/>
267
+ <KeyHint theme={props.theme} keys={HOME_ACTIONS.open.keys} label={HOME_ACTIONS.open.label} quiet button onPress={() => openSelected("mouse")}/>
271
268
  </Show>
272
269
  </box>
273
270
  <Show when={showRecovery()}>
@@ -215,6 +215,7 @@ export function createApplicationHomeNavigationOwner(options) {
215
215
  command === "appearance" ||
216
216
  command === "hide-sidebar" ||
217
217
  command === "show-sidebar" ||
218
+ command === "help" ||
218
219
  command === "shortcuts" ||
219
220
  command === "whats-new" ||
220
221
  command === "switch-session")
@@ -3,7 +3,7 @@ import { createFleetTabs } from "./application-fleet-tabs.js";
3
3
  import { saveMachineProfiles } from "../../../lib/local-fleet-request.js";
4
4
  import { createApplicationFleetPreferences } from "./application-fleet-preferences.js";
5
5
  import { createApplicationMachineAgents, } from "./application-machine-agents.js";
6
- import { createSignal, onCleanup } from "solid-js";
6
+ import { createEffect, createSignal, onCleanup } from "solid-js";
7
7
  import { applicationMachineAuthorityManager as manager } from "./application-machine-authority.js";
8
8
  import { createApplicationMachineCatalog } from "./application-machine-catalog.js";
9
9
  import { ephemeralMachineProfile } from "./application-machine-startup.js";
@@ -159,7 +159,8 @@ export function createApplicationMachineNavigation(options) {
159
159
  machineId: id,
160
160
  liveSessionId: session.liveSessionId,
161
161
  label: session.name,
162
- hostLabel: `${manager.getMachine(id)?.label ?? id}${session.serverLabel ? ` / ${session.serverLabel}` : ""}`,
162
+ hostLabel: manager.getMachine(id)?.label ?? id,
163
+ serverLabel: session.serverLabel ?? "default",
163
164
  });
164
165
  preferences.change({ type: "visit", key: session.id });
165
166
  if (remember && history[historyIndex] !== session.id) {
@@ -198,6 +199,28 @@ export function createApplicationMachineNavigation(options) {
198
199
  });
199
200
  const isDefaultSession = (machineId, session) => !session.server ||
200
201
  session.server.generation === manager.getMachine(machineId)?.read()?.instanceId;
202
+ const tabAgents = (tab) => {
203
+ const group = agentGroups().find((value) => value.machineId === tab.machineId);
204
+ // This observer covers only the default tmux server; never guess for other scopes.
205
+ if (!resolveTab(tab) || !group?.available || !isDefaultSession(tab.machineId, tab))
206
+ return null;
207
+ const observation = group.observation;
208
+ if (observation &&
209
+ (observation.loadingSessions > 0 ||
210
+ observation.unavailableSessions > 0 ||
211
+ observation.truncatedSessions > 0 ||
212
+ observation.refreshingSessionKeys.length > 0))
213
+ return null;
214
+ const rows = group.agents.filter((agent) => agent.liveSessionId === tab.liveSessionId);
215
+ if (rows.some((agent) => agent.disabled))
216
+ return null;
217
+ return rows;
218
+ };
219
+ createEffect(() => {
220
+ tabRevision();
221
+ for (const tab of tabs.snapshot().tabs)
222
+ tabs.observe(tab.key, tabAgents(tab));
223
+ });
201
224
  const sidebar = {
202
225
  groups: () => snapshot().groups.map((group) => ({
203
226
  ...group,
@@ -214,11 +237,26 @@ export function createApplicationMachineNavigation(options) {
214
237
  tabs: () => {
215
238
  tabRevision();
216
239
  const value = tabs.snapshot();
217
- return value.tabs.map((tab) => ({
218
- ...tab,
219
- active: value.active === tab.key,
220
- available: !!resolveTab(tab),
221
- }));
240
+ return value.tabs.map((tab) => {
241
+ const agents = tabAgents(tab);
242
+ const activity = !agents
243
+ ? undefined
244
+ : agents.some((agent) => agent.activity === "failed")
245
+ ? "failed"
246
+ : agents.some((agent) => agent.attention || agent.activity === "waiting")
247
+ ? "waiting"
248
+ : agents.some((agent) => agent.activity === "running")
249
+ ? "running"
250
+ : "idle";
251
+ return {
252
+ ...tab,
253
+ active: value.active === tab.key,
254
+ available: !!resolveTab(tab),
255
+ activity,
256
+ attention: agents?.some((agent) => agent.attention || agent.activity === "waiting"),
257
+ unread: value.unread.includes(tab.key),
258
+ };
259
+ });
222
260
  },
223
261
  onOpenTab: (key) => {
224
262
  void tabs.activate(key);
@@ -1,6 +1,7 @@
1
+ import { CHROME_ACTIONS, SIDEBAR_ACTIONS } from "../workspace/application-action-descriptions.js";
1
2
  import { createAgentStatusMarker } from "../ui/agent-status-marker.js";
2
3
  import { fleetHostColor, summarizeFleetActivity } from "./fleet-presentation.js";
3
- import { TuiButton } from "../ui/button.jsx";
4
+ import { WorkingSessions } from "../ui/working-sessions.jsx";
4
5
  /* @jsxImportSource @opentui/solid */
5
6
  import { fleetConnectionMessage, } from "@tmux-ide/daemon-client/fleet-connection-status";
6
7
  import { terminalAgentStatusLabel } from "./application-terminal-workspace-policy.js";
@@ -23,13 +24,12 @@ export function ApplicationMachineSidebar(props) {
23
24
  : 0;
24
25
  const controlsHeight = () => (props.height >= 8 && controlGroup() ? 4 : 0);
25
26
  const searchHeight = () => (props.model.onOpenSwitcher ? 1 : 0) + (props.model.onOpenAttention ? 1 : 0);
26
- const tabHeight = () => Math.min(props.model.tabs?.().length ?? 0, Math.max(0, Math.floor(props.height / 3)));
27
- const visibleTabs = () => {
28
- const tabs = props.model.tabs?.() ?? [];
29
- const active = tabs.findIndex((tab) => tab.active);
30
- const start = Math.max(0, active - tabHeight() + 1);
31
- return tabs.slice(start, start + tabHeight());
32
- };
27
+ const [workingFocused, setWorkingFocused] = createSignal(true);
28
+ const tabHeight = () => (props.model.tabs?.().length ?? 0) > 0 && props.height >= 12
29
+ ? 1 +
30
+ Math.min(props.model.tabs().length, Math.max(1, Math.floor((props.height / 2 - 1) / 3))) *
31
+ 3
32
+ : 0;
33
33
  const machineHeight = () => Math.max(0, props.height -
34
34
  1 -
35
35
  (props.model.onAddMachine ? 1 : 0) -
@@ -133,6 +133,7 @@ export function ApplicationMachineSidebar(props) {
133
133
  props.model.onCollapse?.(preferenceKey(group), value);
134
134
  };
135
135
  const activate = (row, source) => {
136
+ setWorkingFocused(false);
136
137
  setSelectedKey(row.key);
137
138
  setLocalFocused(true);
138
139
  props.model.onFocus?.();
@@ -169,7 +170,7 @@ export function ApplicationMachineSidebar(props) {
169
170
  : 0;
170
171
  const rowHeight = (row) => (row.agent ? (row.agentHeading ? 3 : 2) : row.serverHeading ? 2 : 1) + sectionGap(row);
171
172
  const revealFocusedRow = () => {
172
- if (!focused() || !scroll)
173
+ if (!focused() || (workingFocused() && tabHeight()) || !scroll)
173
174
  return;
174
175
  const y = rows()
175
176
  .slice(0, index())
@@ -191,6 +192,14 @@ export function ApplicationMachineSidebar(props) {
191
192
  if (!focused() || event.eventType !== "press" || event.meta)
192
193
  return false;
193
194
  let key = event.name.toLowerCase();
195
+ if (key === "tab" && tabHeight()) {
196
+ setWorkingFocused((value) => !value);
197
+ event.preventDefault();
198
+ event.stopPropagation();
199
+ return true;
200
+ }
201
+ if (workingFocused() && tabHeight() && !["escape", "?", "question", "/", "a"].includes(key))
202
+ return false;
194
203
  if (event.ctrl && key === "d")
195
204
  key = "halfdown";
196
205
  else if (event.ctrl && key === "u")
@@ -204,7 +213,7 @@ export function ApplicationMachineSidebar(props) {
204
213
  l: "right",
205
214
  g: event.shift ? "end" : "home",
206
215
  "/": "search",
207
- "?": "help",
216
+ [SIDEBAR_ACTIONS.help.key]: "help",
208
217
  question: "help",
209
218
  }[key] ?? key;
210
219
  if (event.ctrl && !["halfdown", "halfup"].includes(key))
@@ -226,9 +235,9 @@ export function ApplicationMachineSidebar(props) {
226
235
  "return",
227
236
  "space",
228
237
  "escape",
229
- "a",
230
- "r",
231
- "d",
238
+ SIDEBAR_ACTIONS.add.key,
239
+ SIDEBAR_ACTIONS.retry.key,
240
+ SIDEBAR_ACTIONS.disconnect.key,
232
241
  "f",
233
242
  ].includes(key))
234
243
  return false;
@@ -247,7 +256,7 @@ export function ApplicationMachineSidebar(props) {
247
256
  props.model.onBlur?.();
248
257
  return true;
249
258
  }
250
- if (key === "a") {
259
+ if (key === SIDEBAR_ACTIONS.add.key) {
251
260
  props.model.onAddMachine?.();
252
261
  return true;
253
262
  }
@@ -260,9 +269,9 @@ export function ApplicationMachineSidebar(props) {
260
269
  props.model.onFavorite?.(row.session.id, !props.model.favorites?.().includes(row.session.id));
261
270
  return true;
262
271
  }
263
- if (key === "r" || key === "d") {
272
+ if (key === SIDEBAR_ACTIONS.retry.key || key === SIDEBAR_ACTIONS.disconnect.key) {
264
273
  if (row.group.id !== "local") {
265
- if (key === "r")
274
+ if (key === SIDEBAR_ACTIONS.retry.key)
266
275
  props.model.onRetryMachine?.(row.group.id);
267
276
  else
268
277
  props.model.onDisconnectMachine?.(row.group.id);
@@ -297,19 +306,22 @@ export function ApplicationMachineSidebar(props) {
297
306
  });
298
307
  return (<Surface id="application-machine-sidebar" theme={props.theme} variant="panel" width={props.width} height={props.height} flexShrink={0} flexDirection="column" overflow="hidden">
299
308
  <box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
300
- <text fg={props.theme.roles.text.secondary}>{" Machines"}</text>
309
+ <text fg={props.theme.roles.text.secondary}>
310
+ {tabHeight() ? " Workspace" : " Machines"}
311
+ </text>
301
312
  <box flexGrow={1}/>
302
313
  <Show when={props.model.tabs?.().length && props.width >= 28}>
303
- <KeyHint theme={props.theme} keys="F9" label="Tabs" quiet/>
314
+ <KeyHint theme={props.theme} keys={CHROME_ACTIONS.tabs.keys} label={CHROME_ACTIONS.tabs.label} quiet/>
304
315
  </Show>
305
- <KeyHint theme={props.theme} keys="?" label={props.width >= 20 ? "Help" : undefined} quiet button onPress={() => props.onHelp?.("mouse")}/>
316
+ <KeyHint theme={props.theme} keys={SIDEBAR_ACTIONS.help.keys} label={props.width >= 20 ? SIDEBAR_ACTIONS.help.label : undefined} quiet button onPress={() => props.onHelp?.("mouse")}/>
306
317
  </box>
307
- <For each={visibleTabs()}>
308
- {(tab) => (<box height={1} flexDirection="row">
309
- <NavigationRow theme={props.theme} width={Math.max(1, props.width - 4)} id={`fleet-tab:${tab.key}`} label={`${tab.hostLabel} / ${tab.label}`} marker={tab.active ? "●" : "○"} detail={tab.available ? "" : "offline"} focused={false} onActivate={() => props.model.onOpenTab?.(tab.key)}/>
310
- <TuiButton theme={props.theme} label="×" size="compact" width={3} onPress={() => props.model.onCloseTab?.(tab.key)}/>
311
- </box>)}
312
- </For>
318
+ <Show when={tabHeight() > 0}>
319
+ <WorkingSessions theme={props.theme} rows={props.model.tabs?.() ?? []} width={props.width} height={tabHeight()} focused={focused() && workingFocused()} onFocus={() => {
320
+ setWorkingFocused(true);
321
+ setLocalFocused(true);
322
+ props.model.onFocus?.();
323
+ }} onOpen={(key) => props.model.onOpenTab?.(key)} onClose={props.model.onCloseTab}/>
324
+ </Show>
313
325
  <scrollbox ref={(value) => {
314
326
  scroll = value;
315
327
  const resized = value.content.onSizeChange;
@@ -394,7 +406,7 @@ export function ApplicationMachineSidebar(props) {
394
406
  : `${row.session.paneCount}p${row.group.agents?.length ? ` ${activity(row).label}` : ""}`
395
407
  : row.group.agents?.length && row.group.state === "ready"
396
408
  ? activity(row).label
397
- : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`} selected={Boolean((row.session || row.agent) && active(row))} focused={Boolean(focused() && row.key === selectedKey())} status={(row.agent?.attention ?? activity(row).kind === "attention")
409
+ : `${connectionDetail(row.group)}${row.group.agents?.length ? " ?" : ""}`} selected={Boolean((row.session || row.agent) && active(row))} focused={Boolean(focused() && !(workingFocused() && tabHeight()) && row.key === selectedKey())} status={(row.agent?.attention ?? activity(row).kind === "attention")
398
410
  ? "blocked"
399
411
  : undefined} onActivate={(source) => activate(row, source)}/>
400
412
  <Show when={row.agent}>
@@ -410,10 +422,10 @@ export function ApplicationMachineSidebar(props) {
410
422
  </scrollbox>)}
411
423
  </For>
412
424
  <Show when={props.model.onOpenSwitcher}>
413
- <KeyHint theme={props.theme} keys="F6" label="Sessions" width={props.width} quiet button onPress={() => props.model.onOpenSwitcher?.()}/>
425
+ <KeyHint theme={props.theme} keys={CHROME_ACTIONS.sessions.keys} label="Browse all sessions" width={props.width} quiet button onPress={() => props.model.onOpenSwitcher?.()}/>
414
426
  </Show>
415
427
  <Show when={props.model.onOpenAttention}>
416
- <KeyHint theme={props.theme} keys="F7" label={`Attention (${props.model.groups().reduce((sum, group) => sum + (group.state === "ready" ? (group.agents ?? []).filter((agent) => agent.attention && !agent.disabled).length : 0), 0)})`} width={props.width} quiet button onPress={() => props.model.onOpenAttention?.()}/>
428
+ <KeyHint theme={props.theme} keys={CHROME_ACTIONS.attention.keys} label={`${CHROME_ACTIONS.attention.label} (${props.model.groups().reduce((sum, group) => sum + (group.state === "ready" ? (group.agents ?? []).filter((agent) => agent.attention && !agent.disabled).length : 0), 0)})`} width={props.width} quiet button onPress={() => props.model.onOpenAttention?.()}/>
417
429
  </Show>
418
430
  <Show when={controlsHeight() > 0 && controlGroup()}>
419
431
  {(group) => (<>
@@ -422,12 +434,12 @@ export function ApplicationMachineSidebar(props) {
422
434
  ? fleetConnectionMessage(group().diagnostic)
423
435
  : connectionDetail(group())}
424
436
  </text>
425
- <KeyHint theme={props.theme} keys="R" label="Retry connection" width={props.width} quiet button onPress={() => props.model.onRetryMachine?.(group().id)}/>
426
- <KeyHint theme={props.theme} keys="D" label="Disconnect" width={props.width} quiet button onPress={() => props.model.onDisconnectMachine?.(group().id)}/>
437
+ <KeyHint theme={props.theme} keys={SIDEBAR_ACTIONS.retry.keys} label={SIDEBAR_ACTIONS.retry.label} width={props.width} quiet button onPress={() => props.model.onRetryMachine?.(group().id)}/>
438
+ <KeyHint theme={props.theme} keys={SIDEBAR_ACTIONS.disconnect.keys} label={SIDEBAR_ACTIONS.disconnect.label} width={props.width} quiet button onPress={() => props.model.onDisconnectMachine?.(group().id)}/>
427
439
  </>)}
428
440
  </Show>
429
441
  <For each={props.model.onAddMachine ? [props.model.onAddMachine] : []}>
430
- {(add) => (<KeyHint theme={props.theme} keys="A" label="Add machine" width={props.width} quiet button onPress={add}/>)}
442
+ {(add) => (<KeyHint theme={props.theme} keys={SIDEBAR_ACTIONS.add.keys} label={SIDEBAR_ACTIONS.add.label} width={props.width} quiet button onPress={add}/>)}
431
443
  </For>
432
444
  </Surface>);
433
445
  }
@@ -27,9 +27,9 @@ export function createApplicationPaletteCommandOwner(options) {
27
27
  const activate = (command, source, confirmed = false) => {
28
28
  if (busy())
29
29
  return;
30
- if (command === "shortcuts" || command === "whats-new") {
30
+ if (command === "help" || command === "shortcuts" || command === "whats-new") {
31
31
  setCloseArmed(false);
32
- setReferencePage(command === "shortcuts" ? "shortcuts" : "changes");
32
+ setReferencePage(command === "help" ? "help" : command === "shortcuts" ? "shortcuts" : "changes");
33
33
  return;
34
34
  }
35
35
  if (command === "switch-session") {
@@ -10,6 +10,7 @@ const BASE_COMMANDS = [
10
10
  "close-pane",
11
11
  "zoom-pane",
12
12
  "appearance",
13
+ "help",
13
14
  "shortcuts",
14
15
  "whats-new",
15
16
  "switch-session",
@@ -1,4 +1,5 @@
1
1
  import { batch, createEffect, createSignal, onCleanup } from "solid-js";
2
+ import { UI_ICONS } from "./icons.js";
2
3
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
4
  const listeners = new Set();
4
5
  let timer;
@@ -37,25 +38,25 @@ export function createAgentStatusMarker(options) {
37
38
  });
38
39
  return () => {
39
40
  if (options.unavailable?.())
40
- return "·";
41
+ return UI_ICONS.unknown;
41
42
  if (options.attention?.())
42
- return "!";
43
+ return UI_ICONS.attention;
43
44
  switch (options.status()) {
44
45
  case "running":
45
46
  case "working":
46
47
  return animated() ? FRAMES[current()] : options.theme().glyphs.active;
47
48
  case "waiting":
48
49
  case "blocked":
49
- return "!";
50
+ return UI_ICONS.attention;
50
51
  case "complete":
51
52
  case "done":
52
53
  return options.theme().glyphs.check;
53
54
  case "failed":
54
- return "×";
55
+ return UI_ICONS.failed;
55
56
  case "idle":
56
57
  return options.theme().glyphs.inactive;
57
58
  default:
58
- return "·";
59
+ return UI_ICONS.unknown;
59
60
  }
60
61
  };
61
62
  }
@@ -0,0 +1,9 @@
1
+ /** Shared semantic glyphs; theme-specific active/idle/check glyphs stay in the theme. */
2
+ export const UI_ICONS = {
3
+ attention: "!",
4
+ failed: "×",
5
+ unknown: "·",
6
+ offline: "○",
7
+ newResult: "•",
8
+ close: "×",
9
+ };
@@ -14,3 +14,6 @@ export { componentPalette, } from "./state.js";
14
14
  export { Tabs } from "./tabs.jsx";
15
15
  export { Surface } from "./surface.jsx";
16
16
  export { StatusBar, StatusBarAction, StatusBarGroup, StatusBarSegment, StatusSegment, } from "./status-bar.jsx";
17
+ export { UI_ICONS } from "./icons.js";
18
+ export { SessionRow, sessionRowStatus } from "./session-row.jsx";
19
+ export { WorkingSessions } from "./working-sessions.jsx";
@@ -0,0 +1,71 @@
1
+ import { Show } from "solid-js";
2
+ import { clipTerminal } from "../terminal-text.js";
3
+ import { NavigationRow } from "./navigation-row.jsx";
4
+ import { TuiButton } from "./button.jsx";
5
+ import { componentPalette } from "./state.js";
6
+ import { createAgentStatusMarker } from "./agent-status-marker.js";
7
+ import { UI_ICONS } from "./icons.js";
8
+ export function sessionRowStatus(row) {
9
+ if (!row.available)
10
+ return "offline";
11
+ if (row.attention || row.activity === "waiting")
12
+ return "needs input";
13
+ if (row.activity === "failed")
14
+ return "failed";
15
+ if (row.activity === "running")
16
+ return "working";
17
+ if (row.unread)
18
+ return "new result";
19
+ if (!row.activity || row.activity === "disconnected")
20
+ return "unknown";
21
+ return "idle";
22
+ }
23
+ /** Shared two-line session identity. Pure presentation: opening/closing belongs to its owner. */
24
+ export function SessionRow(props) {
25
+ const marker = createAgentStatusMarker({
26
+ theme: () => props.theme,
27
+ status: () => (sessionRowStatus(props.row) === "idle" ? "idle" : props.row.activity),
28
+ attention: () => !!props.row.attention,
29
+ unavailable: () => !props.row.available,
30
+ });
31
+ const tone = () => {
32
+ const status = sessionRowStatus(props.row);
33
+ return status === "needs input" || status === "failed"
34
+ ? "blocked"
35
+ : status === "working"
36
+ ? "working"
37
+ : status === "new result"
38
+ ? "done"
39
+ : "neutral";
40
+ };
41
+ const glyph = () => !props.row.available
42
+ ? UI_ICONS.offline
43
+ : sessionRowStatus(props.row) === "new result"
44
+ ? UI_ICONS.newResult
45
+ : marker();
46
+ const palette = () => componentPalette(props.theme, {
47
+ selected: props.selected,
48
+ focused: props.focused,
49
+ disabled: !props.row.available,
50
+ });
51
+ const detail = () => `${props.row.hostLabel} · ${props.row.serverLabel ?? "default"}${props.row.active ? " · current" : ""}`;
52
+ return (<box id={`session-row:${props.row.key}`} width={props.width} height={2} flexDirection="column" overflow="hidden">
53
+ <box height={1} flexDirection="row">
54
+ <NavigationRow theme={props.theme} id={`fleet-tab:${props.row.key}`} width={Math.max(1, props.width - (props.onClose ? 3 : 0))} label={props.row.label} tone={tone()} marker={glyph()} detail={sessionRowStatus(props.row)} selected={props.selected} focused={props.focused} disabled={!props.row.available} onActivate={props.onOpen}/>
55
+ <Show when={props.onClose}>
56
+ <TuiButton theme={props.theme} label={UI_ICONS.close} size="compact" width={3} onPress={props.onClose}/>
57
+ </Show>
58
+ </box>
59
+ <box height={1} backgroundColor={palette().background} onMouseDown={(event) => {
60
+ if (event.button !== 0 || !props.row.available)
61
+ return;
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ props.onOpen();
65
+ }}>
66
+ <text fg={props.selected || props.focused ? palette().foreground : props.theme.roles.text.muted}>
67
+ {clipTerminal(` ${detail()}`, props.width)}
68
+ </text>
69
+ </box>
70
+ </box>);
71
+ }