Files
locode/src/ui/ink/DiffView.tsx
T
kimandClaude f347c150a4 v0.6.0: sub-agent orchestration, plan mode, memory, and the Claude-Code feature gap
Closes the gap to Claude Code across four upgrade rounds (all verified
green: typecheck, 388 tests, build 313 KB).

Sub-agent orchestration:
- Resumable sub-agents (send_message) + MaxIterations "continue" path
- Configurable multi-depth sub-agent nesting
- Parallel fan-out for pure agent/agent__* batches, each writable delegation
  in its own throwaway git worktree (no file collisions)
- Named specialist fleet: explore, code-reviewer, planner, debugger,
  test-writer (read-only types skip worktree isolation)
- Named teammates: agent tool `name` arg + list_teammates + send_message
  by name (synchronous, addressable handle — not true async background)

Plan mode:
- True in-turn pause/resume on approval (no synthetic proceed turn)
- exit_plan_mode tool + side-by-side diff rendering (/diff)

Persistent memory:
- Typed file-per-fact memory under config dir, index folded into the
  system prompt (bounded), memory + memory_write tools, /memory command

Toolset + UI:
- multi_edit, notebook_edit, web_search/web_fetch, workflow orchestration
  primitive (agent/parallel/pipeline/phase/log + worktree isolation +
  best-effort structured output with recursive schema validation)
- Structured task system (task_create/list/get/update with dependency
  graph + ownership) replacing flat todo_write
- Cron/scheduled tasks (cron_create/list/delete, schedule_wakeup) with
  durable persistence and idle-gated ticking
- Interactive worktree session (enter_worktree/exit_worktree) with a
  live-cwd UI switch that survives /model changes
- AskUserQuestion tool + QuestionPrompt UI
- Permission rule granularity + user/project settings merge
- Color diffs, sub-agent activity panel, /undo, dashboard refinements

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 11:35:05 +09:00

151 lines
6.0 KiB
TypeScript

import { Box, Text, useStdout } from "ink";
import type { ReactNode } from "react";
import { DIFF_ADD_HEX, DIFF_REMOVE_HEX } from "../theme.js";
/** A single row of a side-by-side diff: the old (left) and new (right) versions of one line, plus
* each side's kind so the renderer can color it. `empty` is a padding row used when one side has a
* line the other doesn't (a pure insertion or deletion). */
interface DiffRow {
left: string;
leftKind: "ctx" | "add" | "del" | "empty";
right: string;
rightKind: "ctx" | "add" | "del" | "empty";
}
/** Splits a unified-diff hunk body (the lines after a `@@ … @@` header, each starting with ` `,
* `+`, or `-`) into paired old/new rows. A deletion immediately followed by an addition is treated
* as a change and shown on one row (old left, new right); runs of pure deletions or additions are
* padded with `empty` on the opposite side so the two columns stay aligned. Exported for testing. */
export function pairHunk(body: string[]): DiffRow[] {
const rows: DiffRow[] = [];
for (let i = 0; i < body.length; i++) {
const line = body[i] ?? "";
const tag = line[0];
const rest = line.slice(1);
if (tag === " ") {
rows.push({ left: rest, leftKind: "ctx", right: rest, rightKind: "ctx" });
} else if (tag === "-") {
const next = body[i + 1] ?? "";
if (next[0] === "+") {
rows.push({ left: rest, leftKind: "del", right: next.slice(1), rightKind: "add" });
i++; // consume the paired addition
} else {
rows.push({ left: rest, leftKind: "del", right: "", rightKind: "empty" });
}
} else if (tag === "+") {
// An addition not preceded by a paired deletion (the deletion branch above consumes its
// paired addition) — a pure insertion.
rows.push({ left: "", leftKind: "empty", right: rest, rightKind: "add" });
} else {
// Blank or unexpected line (e.g. a trailing "\ No newline at end of file") — render as context.
rows.push({ left: line, leftKind: "ctx", right: line, rightKind: "ctx" });
}
}
return rows;
}
/** Truncates `s` to `width` columns with an ellipsis when it overflows, so the two side-by-side
* columns stay row-aligned even when a line is longer than half the terminal. Exported for testing. */
export function clip(s: string, width: number): string {
if (width <= 0) return "";
if (s.length <= width) return s;
return s.slice(0, Math.max(0, width - 1)) + "…";
}
function colorFor(kind: DiffRow["leftKind"]): string | undefined {
if (kind === "add") return DIFF_ADD_HEX;
if (kind === "del") return DIFF_REMOVE_HEX;
return undefined;
}
/** Renders a unified-diff string with color-coded lines: additions green, removals red, hunk
* headers (`@@ … @@`) and the `Index:`/`--- `/`+++ ` file headers dimmed. In `sideBySide` mode the
* old and new versions are shown in two columns (old left, new right, separated by a dim ` │ `),
* with deletions red on the left and additions green on the right — easier to compare at a glance
* than a unified diff, at the cost of horizontal space. Used both for the pre-confirmation
* permission preview and the post-confirmation scrollback entry. */
export function DiffView({ diff, sideBySide = false }: { diff: string; sideBySide?: boolean }) {
const { stdout } = useStdout();
const columns = stdout?.columns ?? 80;
const lines = diff.split("\n");
if (!sideBySide) {
return (
<Box flexDirection="column">
{lines.map((line, i) => {
let color: string | undefined;
let dim = false;
const c = line[0];
if (c === "+" && !line.startsWith("+++")) {
color = DIFF_ADD_HEX;
} else if (c === "-" && !line.startsWith("---")) {
color = DIFF_REMOVE_HEX;
} else if (c === "@") {
dim = true; // hunk header
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
dim = true; // file headers
}
return (
<Text key={i} color={color} dimColor={dim}>
{line}
</Text>
);
})}
</Box>
);
}
// Side-by-side: reserve 3 columns for the " │ " gutter; split the rest evenly. Guard against the
// no-stdout / very-narrow case so a tiny terminal doesn't produce negative widths. Each cell keeps
// a 1-column sign prefix (` ` context, `-` deletion, `+` addition) so the kind is legible without
// relying on color alone.
const colWidth = Math.max(10, Math.floor((columns - 3) / 2));
// Walk the unified diff and group lines under their hunk headers, pairing each hunk body into rows.
const rendered: ReactNode[] = [];
let hunkBody: string[] = [];
let hunkIdx = 0;
const flushHunk = () => {
if (!hunkBody.length) return;
const rows = pairHunk(hunkBody);
rows.forEach((row, r) => {
const key = `${hunkIdx}-${r}`;
rendered.push(
<Box key={key} flexDirection="row">
<Text color={colorFor(row.leftKind)} dimColor={row.leftKind === "ctx"}>
{row.leftKind === "empty" ? "" : `${row.leftKind === "del" ? "-" : " "}${clip(row.left, colWidth - 1)}`}
</Text>
<Text dimColor> │ </Text>
<Text color={colorFor(row.rightKind)} dimColor={row.rightKind === "ctx"}>
{row.rightKind === "empty" ? "" : `${row.rightKind === "add" ? "+" : " "}${clip(row.right, colWidth - 1)}`}
</Text>
</Box>,
);
});
hunkBody = [];
hunkIdx++;
};
for (const line of lines) {
if (line.startsWith("@@")) {
flushHunk();
rendered.push(
<Text key={`h${hunkIdx}`} dimColor>
{line}
</Text>,
);
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
flushHunk();
rendered.push(
<Text key={`f${rendered.length}`} dimColor>
{line}
</Text>,
);
} else {
hunkBody.push(line);
}
}
flushHunk();
return <Box flexDirection="column">{rendered}</Box>;
}