diff --git a/.agency/do.md b/.agency/do.md
index e51de5a16..1e42ba9f5 100644
--- a/.agency/do.md
+++ b/.agency/do.md
@@ -24,6 +24,7 @@ Keep these docs in sync:
- **`README.md`** (top-level) — user-facing changes, architecture prose, transport-resilience description.
- **`packages/surface/README.md`** — the `@kolu/surface` framework reference. The "How Kolu uses this framework" section is a concrete inventory of every cell, collection, and stream descriptor plus the raw-oRPC procedures that stay outside the framework. Update it whenever a new descriptor lands or whenever a contract entry's classification changes (added mutation, retired stream, …).
+- **`website/src/pages/index.astro`** — the kolu.dev landing page. Its hand-maintained `features` array (and the `NN features` count chip) must mirror the top-level `README.md`'s feature sections: when you add a `### NewThing` section to the README's Features region, add a corresponding tile to `features` and bump the chip count. The website is deployed independently (its own flake under `website/`), but treating the README and the landing page as one set keeps the public face from drifting behind the project's own description.
## PR evidence
diff --git a/README.md b/README.md
index 59fa32845..ada12bed0 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,20 @@ Detects [OpenCode](https://github.com/anomalyco/opencode) sessions and shows the
- Ctrl+V pastes images into any agent that accepts paste-as-file-path (Claude Code, codex, …) — the server saves the browser's clipboard image and bracketed-pastes its path into the PTY
+### Comments
+
+Annotate any file the agent wrote (or any file at all — code, markdown, configs) directly from the Code tab and flush the queue to your clipboard. Paste the resulting block into whichever agent terminal makes sense; no per-agent wiring, no `kolu artifact` CLI, no server-side delivery.
+
+- **Four ways to start** — 💬 chip in the Code-tab toolbar, Cmd/Ctrl+Shift+/ keybind, command palette entry, or right-click any selected line and pick *Add comment on `path:Lrange`*. The chrome bar also surfaces a `💬 N` count badge whenever the active worktree has queued comments, so you don't have to remember the queue is there
+- **Inline composer at the line** — click a line in comment mode and a tiny composer pops up anchored next to that line (GitHub-PR style). Enter submits, Shift+Enter inserts a newline, Esc dismisses. No mouse trip to a bottom textbox; the popover comes to you
+- **Tray = read-only roll-up** — bottom of the Code tab lists queued comments with click-line-ref-to-jump, ✎ to edit (re-opens the inline composer prefilled), and 🗑 to drop one. The header bar paints itself with an accent fill whenever the queue is non-empty so you don't accidentally close the tab on unsynced notes
+- **File-tree decoration** — files with queued comments get a `●` badge in the Code-tab file tree (via Pierre's `renderRowDecoration`), so the queue is discoverable from the top of the tree, not just from the bottom tray
+- **Pinpoint targets** — Pierre's existing line-selection (click a line, drag for a range) drives the popover's target chip; works uniformly across browse mode and the local/branch diff modes, so `.md`, `.ts`, and any other text file all accept comments without per-kind logic
+- **Cross-file accumulation** — comments collect across every file you visit; the tray sorts by (path, startLine) so a paste reads as a coherent walk through the repo, not click order
+- **Plain Markdown clipboard payload** — flushed text is a Markdown bullet list with a code-spanned line ref per entry: `` - `path:start-end` — note ``. Agent-native format (matches `grep -n`, ripgrep, vim, VS Code, and what `claude` / `codex` / `opencode` parse for their `Read` tool) — no `L` prefix. No envelope or wrapper either: paste it raw, or prefix your own framing (`"Apply these comments:"`, `"## Review notes"`) before pasting
+- **Copy-and-clear** — "Copy to clipboard" is destructive by design (issue [#878](https://github.com/juspay/kolu/issues/878)): users want the tray empty for the next review pass once feedback has been pasted into an agent prompt
+- **Per-worktree persistence** — the in-progress queue is keyed by `repoRoot` in localStorage via `makePersisted`, so an accidental reload doesn't lose work and switching between worktree terminals shows each worktree's own tray
+
### Screen recording
Record the Kolu tab — whole canvas or a single maximized terminal — with microphone and optional webcam PiP, straight to a local `.webm` file. Chromium-only (uses the File System Access API).
diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx
index 4e88ccb7e..0cc3f8b86 100644
--- a/packages/client/src/App.tsx
+++ b/packages/client/src/App.tsx
@@ -43,6 +43,7 @@ import MobileTileView from "./MobileTileView";
import { useRecorder } from "./recorder/useRecorder";
import WebcamOverlay from "./recorder/WebcamOverlay";
import RightPanelLayout from "./right-panel/RightPanelLayout";
+import { toggleCommentMode } from "./right-panel/useCommentMode";
import { useRightPanel } from "./right-panel/useRightPanel";
import { client } from "./wire";
import { serverProcessId, wsStatus } from "./rpc/rpc";
@@ -214,6 +215,14 @@ const App: Component = () => {
handleScreenshotTerminal: () => handleScreenshotTerminal(),
toggleRightPanel: rightPanel.togglePanel,
toggleRecordingPause: () => useRecorder().togglePause(),
+ // Opening the right panel + Code tab + ensuring the tray appears even
+ // when comments is empty puts the user one click from the composer —
+ // the keybind/palette flow doesn't make sense if mode toggles on but
+ // the tray is hidden behind the inspector panel.
+ toggleCommentMode: () => {
+ rightPanel.openCodeBrowser();
+ toggleCommentMode();
+ },
};
useShortcuts(actionContext);
@@ -468,6 +477,7 @@ const App: Component = () => {
openPalette()}
+ activeRepoRoot={() => store.activeMeta()?.git?.repoRoot ?? null}
workspaceSwitcher={
string | null;
}> = (props) => {
const rightPanel = useRightPanel();
const posture = useViewPosture();
@@ -122,6 +127,7 @@ const ChromeBar: Component<{
* area covered when the cluster overlaps the right panel pass
* clicks through; each button re-enables pointer-events-auto. */}
+ {
]
: []),
actionPaletteCommand("toggleRightPanel", deps),
+ actionPaletteCommand("toggleCommentMode", deps, {
+ description:
+ "Open the Code tab and toggle annotation mode for clipboard-flush feedback",
+ }),
...(!deps.isMobile()
? [
actionPaletteCommand("openWorkspaceSwitcher", deps),
diff --git a/packages/client/src/input/actions.ts b/packages/client/src/input/actions.ts
index d23a33c5b..51ece1353 100644
--- a/packages/client/src/input/actions.ts
+++ b/packages/client/src/input/actions.ts
@@ -42,6 +42,7 @@ export interface ActionContext {
handleScreenshotTerminal: () => void;
toggleRightPanel: () => void;
toggleRecordingPause: () => void;
+ toggleCommentMode: () => void;
}
interface AppActionBase {
@@ -239,6 +240,14 @@ const _ACTIONS = {
keybind: { key: ".", code: "Period", mod: true, shift: true },
handler: (ctx) => ctx.toggleRecordingPause(),
},
+ toggleCommentMode: {
+ label: "Toggle comment mode",
+ // Mod+Shift+/ — slash is the universal "?" key, and Shift+? reads
+ // as "query / note?" which is the right mnemonic for "annotate."
+ // Avoids the Mod+/ chord that ShortcutsHelp owns.
+ keybind: { key: "?", code: "Slash", mod: true, shift: true },
+ handler: (ctx) => ctx.toggleCommentMode(),
+ },
} satisfies Record;
export type ActionId = keyof typeof _ACTIONS;
diff --git a/packages/client/src/right-panel/BrowseFileView.tsx b/packages/client/src/right-panel/BrowseFileView.tsx
index 303a48afc..dc3b3b339 100644
--- a/packages/client/src/right-panel/BrowseFileView.tsx
+++ b/packages/client/src/right-panel/BrowseFileView.tsx
@@ -1,30 +1,22 @@
/** File content viewer for the Code tab's browse mode. Subscribes to the
* server's live file-content stream so editor saves and branch checkouts
- * reflect without a manual refresh. The wrapper around `@kolu/solid-pierre`'s
- * `FileView` provides shiki-powered syntax highlighting; equality-gating
- * the snapshot via `reconcile` (inside `useStream`'s underlying primitive)
- * avoids stomping scroll position on no-op ticks. */
+ * reflect without a manual refresh. The `LineSelection` controller is
+ * owned one level up so this view and the diff view share a single
+ * `CodeMenuFrame` wrap. */
-import {
- FileView,
- type SelectedLineRange,
- Virtualizer,
-} from "@kolu/solid-pierre";
+import { FileView, Virtualizer } from "@kolu/solid-pierre";
import { type Component, Match, Show, Switch } from "solid-js";
import { toast } from "solid-sonner";
import { pierreDiffsStyle } from "../ui/pierreTheme";
+import type { LineSelection } from "../ui/useLineSelection";
import { app } from "../wire";
-import CodeMenuFrame from "./CodeMenuFrame";
export type BrowseFileViewProps = {
repoPath: string;
filePath: string;
theme: "light" | "dark";
- /** Initial line range to highlight (and scroll to). Set when the
- * caller opens the file at a specific range — e.g. a terminal
- * `path:line` click. Goes through the line-selection controller
- * so the right-click "Copy path:N" menu reflects the highlight. */
- initialSelectedLines?: SelectedLineRange | null;
+ /** Line-selection controller owned by the parent `CodeMenuFrame`. */
+ selection: LineSelection;
};
const BrowseFileView: Component = (props) => {
@@ -46,39 +38,32 @@ const BrowseFileView: Component = (props) => {
{(fc) => (
<>
-
+
File truncated (exceeds 1 MB)
- ` upgrades `` to Pierre's
+ * `VirtualizedFile` for very large files
+ * (#809 / #514 Phase 8). Without it, `` uses
+ * the vanilla `File` class — same behavior as before. */}
+
- {(selection) => (
- // `` upgrades `` to Pierre's
- // `VirtualizedFile` for very large files
- // (#809 / #514 Phase 8). Without it, `` uses
- // the vanilla `File` class — same behavior as before.
-
-
- toast.error(`File render failed: ${err.message}`)
- }
- class="w-full"
- />
-
- )}
-
+
+ toast.error(`File render failed: ${err.message}`)
+ }
+ class="w-full"
+ />
+
>
)}
diff --git a/packages/client/src/right-panel/CodeMenuFrame.tsx b/packages/client/src/right-panel/CodeMenuFrame.tsx
index 7accc9b30..1e0a5609e 100644
--- a/packages/client/src/right-panel/CodeMenuFrame.tsx
+++ b/packages/client/src/right-panel/CodeMenuFrame.tsx
@@ -10,6 +10,7 @@ import type { Component, JSX } from "solid-js";
import {
CodeContextMenu,
type CodeContextMenuController,
+ type CodeContextMenuItem,
} from "../ui/CodeContextMenu";
import { type LineSelection, useLineSelection } from "../ui/useLineSelection";
@@ -25,12 +26,21 @@ export type CodeMenuFrameProps = {
* controller so a terminal `path:line` click drives both the
* Pierre highlight AND the right-click menu's "Copy path:N" item. */
initialSelectedLines?: SelectedLineRange | null;
+ /** Forward selection changes to a parent (e.g. CodeTab's comments
+ * tray composer). Fires on every commit, including null. */
+ onSelectionChange?: (range: SelectedLineRange | null) => void;
+ /** Extra context-menu items contributed by the caller — appended
+ * after the built-in "Copy path" / "Copy path:N" entries. Receives
+ * the current selection range (null when nothing is selected). */
+ extraMenuItems?: (range: SelectedLineRange | null) => CodeContextMenuItem[];
};
export const CodeMenuFrame: Component = (props) => {
let menuCtrl: CodeContextMenuController | undefined;
const selection = useLineSelection(() => props.path, {
initialRange: () => props.initialSelectedLines,
+ onChange: (range) => props.onSelectionChange?.(range),
+ extraItems: (range) => props.extraMenuItems?.(range) ?? [],
});
return (
= (props) => {
// carry interactive JSX props — the inner Pierre canvas is the
// actual interactive surface; the host is layout only.
ref={(el) => el.addEventListener("contextmenu", (e) => menuCtrl?.open(e))}
- class="h-full w-full"
+ class="flex flex-col h-full w-full"
>
{props.children(selection)}
= {
local: "No local changes",
@@ -76,6 +97,58 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
const rightPanel = useRightPanel();
const [selectedPath, setSelectedPath] = createSignal(null);
+ // Comment state — all wiring lives in `useCommentInteraction`. One
+ // discriminated `intent` carries "open new composer", "edit existing",
+ // and "jump only" so seed-and-target derive from one source. The hook
+ // also owns orphan-clear effects (mode toggle, tab switch, repoRoot
+ // change). Bubble visibility and the file-row decoration are
+ // composed below from the hook's outputs.
+ const comments = useCommentInteraction({
+ repoRoot: () => props.meta?.git?.repoRoot ?? null,
+ selectedPath,
+ setSelectedPath,
+ activeTabKind: () => rightPanel.activeTab().kind,
+ });
+ // Viewer-root ref — scoped target for `querySelector` lookups so a
+ // future second viewer in the same DOM doesn't poach the search.
+ let viewerEl: HTMLDivElement | undefined;
+ // The OR's second arm keeps the tray visible on reload when the user has
+ // queued comments but never toggled mode back on.
+ const trayVisible = () =>
+ commentModeEnabled() || comments.api.comments().length > 0;
+
+ // Per-file bubble list, memoized so a no-op `comments()`/`selectedPath()`
+ // tick doesn't churn the `` and remount existing markers.
+ const fileBubbles = createMemo(() => {
+ const p = selectedPath();
+ if (!p) return [];
+ return comments.api.comments().filter((c) => c.path === p);
+ });
+
+ const renderFileRowDecoration = createMemo(() => {
+ const set = comments.commentedPaths();
+ if (set.size === 0) return undefined;
+ return (ctx: { row: { path: string } }) =>
+ set.has(ctx.row.path)
+ ? { text: "●", title: "Has comments queued" }
+ : null;
+ });
+
+ // Right-click "Add comment on path:Lrange" — generic `extraMenuItems`
+ // contract on `CodeMenuFrame`, so neither `useLineSelection` nor the
+ // frame knows about comments. CodeTab supplies the comment-specific
+ // entry here.
+ const commentMenuItems =
+ (path: string) => (range: SelectedLineRange | null) => {
+ if (!range) return [];
+ return [
+ {
+ label: `Add comment on ${formatLPathRef(path, range.start, range.end)}`,
+ onClick: () => comments.handleAddComment(range),
+ },
+ ];
+ };
+
// Read `codeMode` directly rather than projecting it from `activeTab`.
// CodeTab now stays mounted across the Inspector tab toggle (#818); a
// projection-with-fallback (`activeTab.kind === "code" ? mode : "local"`)
@@ -275,6 +348,14 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
start: number;
end: number;
} | null>(() => {
+ // Comment-intent-driven navigation (jump / edit pencil / bubble
+ // edit) wins over the terminal-click flow — it's the more recent
+ // user intent. Scoped to path so a stale seed doesn't smear into a
+ // later-opened file.
+ const seed = comments.navSeed();
+ if (seed && seed.path === selectedPath()) {
+ return { start: seed.start, end: seed.end };
+ }
const req = pendingCodeOpen();
if (!req) return null;
const h = handled();
@@ -429,6 +510,18 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
modes={modeOptions()}
/>
+
+
+
+
+ viewerEl ?? null}
+ target={comments.composerTarget}
+ onSubmit={comments.handlePopoverSubmit}
+ onClose={comments.handlePopoverClose}
+ />
+ {/* "+" bubble at the selected line — comment-mode discoverable
+ affordance. Visible only while: comment-mode is on, no
+ composer is already open, the Code tab is the active
+ right-panel tab, a file is selected with a non-null range,
+ and the selected line doesn't already carry a comment (in
+ that case the "💬" bubble from the For loop below takes
+ over). Encoding all guards into the `active` accessor makes
+ the bubble disappear reactively the instant any flips. */}
+ viewerEl ?? null}
+ active={() => {
+ if (!commentModeEnabled()) return false;
+ if (comments.composerTarget() !== null) return false;
+ if (rightPanel.activeTab().kind !== "code") return false;
+ if (!repoPath()) return false;
+ const r = comments.currentRange();
+ const p = selectedPath();
+ if (!r || !p) return false;
+ const existing = comments.api
+ .comments()
+ .some(
+ (c) =>
+ c.path === p && c.startLine <= r.start && c.endLine >= r.end,
+ );
+ return !existing;
+ }}
+ resolveLine={() =>
+ viewerEl
+ ? deepQuerySelector(viewerEl, "[data-selected-line]")
+ : null
+ }
+ label="+"
+ title="Add comment on this line"
+ testid="inline-add-bubble"
+ onClick={comments.handleBubbleAddNew}
+ />
+ {/* "💬" bubbles for each comment in the currently-shown file.
+ Visible regardless of comment mode so the user always sees
+ "this line has notes". Pierre virtualizes lines off-screen,
+ so the resolver may return null for scrolled-out comments;
+ the marker hides itself in that case. */}
+
+ {(c) => (
+ viewerEl ?? null}
+ active={() =>
+ rightPanel.activeTab().kind === "code" &&
+ !!repoPath() &&
+ selectedPath() === c.path
+ }
+ resolveLine={() =>
+ // Pierre's `data-line` attribute holds the actual file
+ // line number; `data-line-index` is its internal
+ // render-position index (0-based, skips diff context),
+ // which only matches line numbers by coincidence in
+ // browse mode and never in diff mode. Use `data-line`
+ // — works uniformly across browse / local / branch.
+ viewerEl
+ ? deepQuerySelector(viewerEl, `[data-line="${c.startLine}"]`)
+ : null
+ }
+ label="💬"
+ title={`Edit comment: ${c.text}`}
+ testid="inline-comment-bubble"
+ onClick={() => comments.handleBubbleEdit(c)}
+ />
+ )}
+
);
diff --git a/packages/client/src/right-panel/CommentComposer.tsx b/packages/client/src/right-panel/CommentComposer.tsx
new file mode 100644
index 000000000..099ebe29d
--- /dev/null
+++ b/packages/client/src/right-panel/CommentComposer.tsx
@@ -0,0 +1,115 @@
+/** Inline composer used both by the line-anchored popover (new comment)
+ * and the edit affordance from the tray (existing comment). One widget,
+ * two modes — distinguished by whether `initialText` is set. Submit
+ * shortcut is Enter (Shift+Enter for newline); Cmd+Enter is reserved
+ * for the "New terminal" global keybind so we don't want to collide. */
+
+import { type Component, createSignal, onMount } from "solid-js";
+import { formatLineRef } from "../ui/lineRef";
+
+export const composerTestIds = {
+ root: "comment-composer",
+ textarea: "comment-composer-textarea",
+ submit: "comment-composer-submit",
+ cancel: "comment-composer-cancel",
+} as const;
+
+export type CommentComposerProps = {
+ /** Target line range — drives the `path:Lrange` chip at the top. */
+ path: string;
+ startLine: number;
+ endLine: number;
+ /** Text to pre-fill (edit mode). Empty string for the new-comment
+ * case. */
+ initialText: string;
+ /** Submit handler — receives the trimmed text. The composer does not
+ * reset on submit; the parent unmounts (or swaps `initialText`) for
+ * the next operation. */
+ onSubmit: (text: string) => void;
+ /** Cancel handler — fires on Esc, blur-without-text in new mode, or
+ * the explicit cancel button. */
+ onCancel: () => void;
+ /** Optional ref handle for the parent to focus the textarea (e.g.
+ * when re-mounted at a new range). */
+ ref?: (api: { focus: () => void }) => void;
+};
+
+const CommentComposer: Component = (props) => {
+ const [draft, setDraft] = createSignal(props.initialText);
+ let textarea: HTMLTextAreaElement | undefined;
+
+ // Expose focus() so the parent can refocus when re-anchoring without
+ // remounting (e.g. user re-selects another line while popover open).
+ onMount(() => {
+ props.ref?.({ focus: () => textarea?.focus() });
+ textarea?.focus();
+ // Place caret at the end so edit-mode prefilled text is immediately
+ // appendable, not selected-and-overwritable.
+ const len = textarea?.value.length ?? 0;
+ textarea?.setSelectionRange(len, len);
+ });
+
+ const submit = () => {
+ const text = draft().trim();
+ if (text.length === 0) {
+ props.onCancel();
+ return;
+ }
+ props.onSubmit(text);
+ };
+
+ return (
+