Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Open http://127.0.0.1:7681 (or the address you chose above).

- Command palette (<kbd>Cmd/Ctrl+K</kbd>) — search terminals, switch themes, run actions
- Worktree-naming flow — drilling into `New terminal → <recent repo>` opens a leaf with the worktree name pre-filled (random ADJ-NOUN, auto-selected) and an agent picker below; type a custom name and hit Enter to land in a freshly-branched worktree, or pick an agent to launch it in one step. The typed name becomes the branch name and surfaces verbatim on the workspace-switcher pill, so worktrees stay identifiable at a glance
- Terminal intent — `Set terminal intent` attaches a short user-authored note to a live terminal. It persists with the session and appears in the tile title bar, inspector, mobile sheet, and workspace switcher search/cards
- Queued worktrees — `Queue worktree → <recent repo>` stores a repo + intent as a durable backlog item without opening a terminal. The workspace switcher's `Queued` column can later start it as a worktree; the shell/agent choice happens at start time, not when the item is queued
- Agent-aware command palette — once you've run a known agent CLI (`claude`, `aider`, `opencode`, `codex`, `goose`, `gemini`, `cursor-agent`) in any kolu terminal, it surfaces in two places: as a row in the worktree-naming leaf above (so the same Enter creates the worktree and launches the agent), and under `Debug → Recent agents` as a prefill-into-active-terminal affordance. Prompt/message flag values (`-p`/`--prompt`/`-m`/`--message`) are stripped before storage so ephemeral prompt text never lands in the persisted MRU
- Workspace-switcher pings — when an agent is waiting on you (or has finished with an unread completion), its collapsed switcher pill pulses an alert dot so you can spot it without panning. <kbd>Ctrl+Tab</kbd> (or <kbd>Alt+Tab</kbd>) cycles terminals in MRU order: hold the modifier, press Tab to advance, release to commit
- Keyboard-driven — <kbd>Cmd+T</kbd> new terminal, <kbd>Cmd+1</kbd>…<kbd>Cmd+9</kbd> jump, <kbd>Cmd+Shift+[</kbd> / <kbd>Cmd+Shift+]</kbd> cycle, <kbd>Cmd+/</kbd> shortcuts help
Expand All @@ -53,7 +55,7 @@ The desktop workspace is mode-less — every terminal renders as a draggable, re
- **Infinite pan & zoom** — two-finger scroll / trackpad to pan, pinch or <kbd>Ctrl+scroll</kbd> to zoom. Hold <kbd>Shift</kbd> to force pan even with the cursor over a terminal tile (hand-tool style). No boundaries — the canvas extends freely in every direction via CSS `transform: translate() scale()` (Figma/Excalidraw model)
- **Snap-to-grid** — tiles snap to a 24px grid on drag and resize for tidy layouts
- **Maximize a tile** — double-click any tile's title bar (or click the maximize button) to fill the viewport; the maximized posture persists across reload via localStorage so you land back where you left off
- **Floating workspace switcher** — a compact repo/branch pill strip sits at the top of the canvas, ghosted at rest and behind any tile that overlaps it; hover opens a searchable panel grouped by live agent state (`Awaiting you`, `Working`, `No agent`) with repo facets. Ordering is **agent recency** (most recent agent semantic-key transition first) with canvas position as the secondary key. The collapsed pill strip always keeps the active-agent terminals plus the user's currently-active terminal in view, then fills remaining slots with up to five idle peers per repo. Repo positions in the strip are alphabetical so they don't shuffle as agents come and go; pills within a repo follow recency order. Recency is persisted, so a restored session lands where it left off. Click a pill or card to focus and center its tile
- **Floating workspace switcher** — a compact repo/branch pill strip sits at the top of the canvas, ghosted at rest and behind any tile that overlaps it; hover opens a searchable panel grouped by live agent state (`Awaiting you`, `Working`, `No agent`) with repo facets, plus a `Queued` column for backlog worktrees. Ordering is **agent recency** (most recent agent semantic-key transition first) with canvas position as the secondary key. The collapsed pill strip always keeps the active-agent terminals plus the user's currently-active terminal in view, then fills remaining slots with up to five idle peers per repo. Repo positions in the strip are alphabetical so they don't shuffle as agents come and go; pills within a repo follow recency order. Recency is persisted, so a restored session lands where it left off. Click a pill or card to focus and center its tile; start a queued card to create its worktree terminal
- **Switcher border encodes state** — each collapsed pill or panel card border doubles as identity (repo color) and live status: a conic-gradient sweep while the agent is `thinking`/`tool_use`, a breathing pulse while `waiting`, a static ring when the terminal is just active, and an inset glow when the active tile also has a working agent
- **Auto-park stale terminals** — when a terminal's last observed agent transition is more than 4 hours old, its switcher pill, panel card, and canvas tile fade and the agent-state border drops, so a wall of "awaiting" tiles you parked yesterday doesn't drown out the one that genuinely needs you now. It also stops counting toward the awaiting bucket and is dropped from the OS/PWA dock badge. Any fresh agent transition unparks automatically — pure derivation off `lastActivityAt`, no persisted state
- **Minimap awaiting heatmap** — the canvas minimap dots any tile whose agent is currently `waiting` (and not auto-parked) with a small alert indicator, so you can scan a 20-tile workspace for "who needs me" without opening the switcher
Expand Down Expand Up @@ -267,9 +269,9 @@ flowchart TB

[^client-state]: Local-only view state (active terminal, MRU order, attention flags) lives in SolidJS [signals and stores](https://docs.solidjs.com/reference/store-utilities/create-store) inside singleton `useXxx.ts` modules — separate from server-derived subscription state.

**Persistence** — sessions auto-save to `~/.config/kolu/state.json` via [`conf`](https://github.com/sindresorhus/conf), debounced at 500 ms[^persistence].
**Persistence** — sessions and queued worktrees auto-save to `~/.config/kolu/state.json` via [`conf`](https://github.com/sindresorhus/conf), debounced at 500 ms for terminal snapshots[^persistence].

[^persistence]: Schema is versioned with explicit migrations. Stores CWD, sort order, and parent relationships per terminal.
[^persistence]: Schema is versioned with explicit migrations. Terminal snapshots store CWD, git metadata, theme, intent, parent relationships, layout, sub-panel state, and agent recency; queued worktrees store repo path, optional worktree name, intent, and creation time.

[PartySocket](https://docs.partykit.io/reference/partysocket-api/) handles WebSocket auto-reconnect; `@kolu/surface/solid`'s `surfaceClient` (wired up in `packages/client/src/wire.ts`) installs oRPC's [`ClientRetryPlugin`](https://orpc.dev/docs/plugins/client-retry) so every Cell/Collection/Stream/Event hook (and the `streamCall` escape hatch for raw streams like terminal `attach`) transparently re-subscribes after a drop — every server-side streaming handler is already snapshot-then-deltas and the reducer in `useTerminalMetadata.ts` pattern-matches an `ActivityStreamEvent` discriminated union (`snapshot` replaces, `delta` appends) so re-subscribe resume is structural, not defensive. Transport events (`connecting` / `connected` / `disconnected` / `reconnected` / `restarted`) are exposed as a single `ServerLifecycleEvent` signal in `packages/client/src/rpc/rpc.ts`, and `TransportOverlay` pattern-matches it into one dim-backdrop card: `disconnected` shows "Reconnecting…" (the backdrop is pointer-events-none, so users can still scroll and read buffers underneath), and `restarted` swaps to "Server updated" with the Reload button inline in the card.

Expand Down
13 changes: 8 additions & 5 deletions ci/lib.just
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,14 @@ _summary:
echo ""
echo "━━━ CI Summary ({{ sha }}) ━━━"
# Fetch all statuses once
all_statuses=$(gh api "repos/{{ repo }}/statuses/{{ sha }}" --jq '
[.[] | select(.context | startswith("ci/"))]
| group_by(.context)
| map(max_by(.updated_at))
| .[] | {(.context): .state}' 2>/dev/null | jq -s 'add // {}')
all_statuses=$(gh api --paginate "repos/{{ repo }}/statuses/{{ sha }}" \
--jq '.[] | select(.context | startswith("ci/"))' 2>/dev/null \
| jq -s '
sort_by(.context)
| group_by(.context)
| map(max_by(.updated_at))
| map({(.context): .state})
| add // {}')
failed=0
while IFS= read -r ctx; do
context="ci/$ctx"
Expand Down
3 changes: 2 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
"@kolu/solid-pierre": "workspace:*",
"@kolu/surface": "workspace:*",
"@orpc/client": "^1.13.13",
"@orpc/contract": "^1.13.13",
"@pierre/diffs": "^1.1.20",
"@pierre/trees": "^1.0.0-beta.3",
"@orpc/contract": "^1.13.13",
"@solid-primitives/event-listener": "^2.4.5",
"@solid-primitives/media": "^2.3.5",
"@solid-primitives/resize-observer": "^2.1.5",
Expand All @@ -37,6 +37,7 @@
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"fix-webm-duration": "^1.0.6",
"marked": "^18.0.2",
"memorable-names": "workspace:*",
"neverthrow": "^8.2.0",
"nonempty": "workspace:*",
Expand Down
62 changes: 58 additions & 4 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,18 @@ import { useRecorder } from "./recorder/useRecorder";
import WebcamOverlay from "./recorder/WebcamOverlay";
import RightPanelLayout from "./right-panel/RightPanelLayout";
import { useRightPanel } from "./right-panel/useRightPanel";
import { client } from "./wire";
import { client, recentAgents } from "./wire";
import { serverProcessId, wsStatus } from "./rpc/rpc";
import TransportOverlay from "./rpc/TransportOverlay";
import ShortcutsHelp from "./ShortcutsHelp";
import { screenshotTerminal } from "./screenshotTerminal";
import { useColorScheme } from "./settings/useColorScheme";
import { useTips } from "./settings/useTips";
import TerminalContent from "./terminal/TerminalContent";
import IntentEditorDialog from "./intent/IntentEditorDialog";
import { useIntentEditor } from "./intent/useIntentEditor";
import TerminalMeta from "./terminal/TerminalMeta";
import { useQueuedWorktrees } from "./terminal/useQueuedWorktrees";
import { useSubPanel } from "./terminal/useSubPanel";
import { useTerminals } from "./terminal/useTerminals";
import ModalDialog, { refocusTerminal } from "./ui/ModalDialog";
Expand Down Expand Up @@ -178,11 +181,29 @@ const App: Component = () => {
if (id) store.activate(id);
}

function handleSetTerminalIntent(intent?: string) {
const id = store.activeId();
if (!id) return;
crud.setIntent(id, intent);
}

const arrange = useCanvasArrange({
store,
crud,
isMobile,
});
const queuedWorktrees = useQueuedWorktrees({
handleCreateWorktree: worktree.handleCreateWorktree,
});

const intentEditor = useIntentEditor({
queuedWorktrees: queuedWorktrees.items,
getTerminalIntent: (id) => store.getMetadata(id)?.intent,
setTerminalIntent: crud.setIntent,
enqueueQueuedWorktree: queuedWorktrees.enqueue,
updateQueuedWorktreeIntent: queuedWorktrees.updateIntent,
onClose: () => requestAnimationFrame(refocusTerminal),
});

// Shared between the keyboard dispatcher and the command palette so a single
// wiring keeps both surfaces in sync. Palette-only deps (theme management,
Expand Down Expand Up @@ -270,8 +291,19 @@ const App: Component = () => {
handleSetTheme,
setAboutOpen,
setDiagnosticInfoOpen,
handleCreateWorktree: (repoPath, name, initialCommand) =>
void worktree.handleCreateWorktree(repoPath, name, initialCommand),
handleCreateWorktree: (repoPath, name, options) =>
void worktree.handleCreateWorktree(repoPath, name, options),
queuedWorktrees: queuedWorktrees.items,
openQueueWorktreeIntent: intentEditor.openNewQueued,
startQueuedWorktree: (id, name, initialCommand) =>
void queuedWorktrees.start(id, {
worktreeName: name,
agentCommand: initialCommand,
}),
deleteQueuedWorktree: queuedWorktrees.remove,
openActiveTerminalIntent: () =>
intentEditor.openActiveTerminal(store.activeId()),
handleSetTerminalIntent,
handleClose: () => {
const id = store.activeId();
if (id) closeTerminal(id);
Expand Down Expand Up @@ -397,6 +429,15 @@ const App: Component = () => {
onOpenChange={setDiagnosticInfoOpen}
activeId={store.activeId()}
/>
<IntentEditorDialog
open={intentEditor.open()}
title={intentEditor.title()}
value={intentEditor.value()}
allowClear={intentEditor.allowClear()}
onOpenChange={intentEditor.onOpenChange}
onSave={intentEditor.save}
onClear={intentEditor.clear}
/>
<ModalDialog
open={aboutOpen()}
onOpenChange={withRefocus(setAboutOpen)}
Expand Down Expand Up @@ -471,10 +512,20 @@ const App: Component = () => {
workspaceSwitcher={
<WorkspaceSwitcher
entries={workspaceEntries()}
queuedWorktrees={queuedWorktrees.items()}
recentAgentCommands={recentAgents().map((a) => a.command)}
activeId={store.activeId()}
getRecency={recencyOf}
openRequest={workspaceSwitcherOpenRequest()}
onSelect={store.activate}
onStartQueuedWorktree={(id, initialCommand) =>
void queuedWorktrees.start(id, {
agentCommand: initialCommand,
})
}
onDeleteQueuedWorktree={queuedWorktrees.remove}
onEditQueuedWorktree={intentEditor.openQueued}
onEditTerminalIntent={intentEditor.openTerminal}
onCreate={() => openPaletteGroup("New terminal")}
/>
}
Expand Down Expand Up @@ -544,7 +595,10 @@ const App: Component = () => {
onSelect={store.setActiveSilently}
onClose={(id) => closeTerminal(id)}
renderTileTitle={(id) => (
<TerminalMeta info={store.getDisplayInfo(id)} />
<TerminalMeta
info={store.getDisplayInfo(id)}
onEditIntent={() => intentEditor.openTerminal(id)}
/>
)}
renderTileTitleActions={(id) => (
<TileTitleActions
Expand Down
19 changes: 14 additions & 5 deletions packages/client/src/MobileChromeSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,20 @@ const MobileChromeSheet: Component<{
>
└─
</span>
<span
class="flex-1 truncate"
style={{ color: item.info.branchColor }}
>
{item.label}
<span class="flex-1 min-w-0">
<span
class="block truncate"
style={{ color: item.info.branchColor }}
>
{item.label}
</span>
<Show when={item.info.meta.intent}>
{(intent) => (
<span class="block text-xs text-fg-3 truncate">
{intent()}
</span>
)}
</Show>
</span>
<Show when={unread()}>
<span class="w-2 h-2 rounded-full bg-alert" />
Expand Down
6 changes: 5 additions & 1 deletion packages/client/src/canvas/workspace-switcher/Collapsed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ const CollapsedWorkspaceSwitcher: Component<{
: {}),
}}
onClick={() => props.onSelect(item().id)}
title={item().info.meta.cwd}
title={
item().info.meta.intent
? `${item().info.meta.intent} - ${item().info.meta.cwd}`
: item().info.meta.cwd
}
>
<Show when={unread()}>
<span
Expand Down
Loading