Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codedeck",
"version": "0.5.2",
"version": "0.5.3",
"description": "CodeDeck, a local runtime for coding agents with session management, process supervision, event normalization, and git isolation",
"type": "module",
"bin": {
Expand Down
125 changes: 69 additions & 56 deletions plugin/mods/agents/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import type { PaneRow, PaneSnapshot, SessionRow } from "./types.js";
/** Cards never stretch past this, however wide the dock is. */
export const MAX_CARD_COLUMNS = 56;

/** Keep finished work useful without turning the pane into a transcript. */
const HISTORY_PREVIEW_ROWS = 3;

/** Below this many usable columns the pane draws nothing rather than garbage. */
export const MIN_COLUMNS = 24;

Expand Down Expand Up @@ -50,12 +53,12 @@ const STATUS_DOT = vocab({
needs_input: "◉",
});
const STATUS_WORD = vocab({
working: "Trabalhando agora",
starting: "Subindo",
needs_input: "Esperando voce",
completed: "Concluida",
stopped: "Em pausa",
failed: "Falhou",
working: "Working now",
starting: "Starting",
needs_input: "Waiting for you",
completed: "Completed",
stopped: "Paused",
failed: "Failed",
});
const HARNESS_GLYPH = vocab({
claude: "▲",
Expand Down Expand Up @@ -205,15 +208,15 @@ function rowAge(iso: unknown): number {
}

// One drawable piece of the pane: an orchestrator card, a worker card, or a
// collapsed finished row. `size` counts the block's own lines and never the
// connector above it: only the assembled order knows where a stem hangs, so
// `build` receives `connects`, whether the bottom edge grows the stem
// junction, and `lead`, whether a stem is drawn above the block.
// compact history section. `count` is how many sessions disappear if the block
// is evicted to make room for the frame and footer. `size` counts only the
// block's own lines; the assembled order adds any connector above it.
type Block = {
kind: "card" | "line";
kind: "card" | "history";
size: number;
age: number;
id: string;
count: number;
build: (connects: boolean, lead: boolean) => string[];
};

Expand Down Expand Up @@ -242,6 +245,9 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
// snapshot, so it is folded into "finished": the most recently touched
// sessions are the live ones and stay inside the budget.
const finished = Math.max(0, workerTotal - working - waiting);
const finishedRows = rows.filter(
(row) => !LIVE.has(row.status ?? "") && !WAIT.has(row.status ?? ""),
);

const W = Math.trunc(columns);
const IN = W - 2;
Expand Down Expand Up @@ -276,6 +282,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
size: body.length + 2,
age: ageValue,
id,
count: 1,
build: (connects, lead) => [
...(lead ? [stem()] : []),
cardTop(),
Expand All @@ -289,16 +296,16 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
if (orchestrator !== undefined) {
blocks.push(
cardBlock(Number.POSITIVE_INFINITY, "", [
` ${glyph(orchestrator.agent)} ● Orquestrador`,
` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agentes`,
` ${glyph(orchestrator.agent)} ● Orchestrator`,
` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`,
]),
);
}

for (const row of rows) {
const status = row.status ?? "";
// Rule 4: live rows draw as full cards; everything else collapses to one
// line naming its glyph, dot, id, name and age.
// Live rows stay prominent because they need attention. Finished rows are
// collected below into one compact history section.
if (LIVE.has(status) || WAIT.has(status)) {
const when = age(row.updatedAt, now);
const word = statusWord(status);
Expand All @@ -309,57 +316,67 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
when === "" ? ` ${word}` : ` ${word} · ${when}`,
]),
);
} else {
}
}

if (finished > 0) {
const preview = finishedRows.slice(0, HISTORY_PREVIEW_ROWS);
const historyLines = [` ─ HISTORY · ${finished} finished`];
for (const row of preview) {
const when = age(row.updatedAt, now);
const line = ` ${glyph(row.agent)} ${dot(row.status)} ${cell(row.id)} ${cell(row.name)}${
when === "" ? "" : ` · ${when}`
}`;
blocks.push({
kind: "line",
size: 1,
age: rowAge(row.updatedAt),
id: cell(row.id),
build: (_connects, lead) => [...(lead ? [stem()] : []), frameRow(line)],
});
historyLines.push(
` ${glyph(row.agent)} ${cell(row.id)} ${cell(row.name)}${when === "" ? "" : ` · ${when}`}`,
);
}
const older = finished - preview.length;
if (older > 0) historyLines.push(` +${older} earlier`);

blocks.push({
kind: "history",
size: historyLines.length,
age: rowAge(preview[0]?.updatedAt),
id: "history",
count: finished,
build: (_connects, lead) => [...(lead ? [stem()] : []), ...historyLines.map(frameRow)],
});
}

// Whether the block at index i hangs from the one above it by a stem. The
// first block is the root and never leads with one. A card is always joined
// to what sits above it; a collapsed line only when it opens a run, so a
// run of one liners costs a single row per entry.
// to what sits above it; history only gets a stem when it follows a card.
const stemAbove = (list: Block[], i: number): boolean =>
i > 0 && (list[i].kind === "card" || list[i - 1].kind === "card");

// Rule 3: these ten lines are the frame. Whenever anything is drawn at all
// they are drawn, and they are never the lines that the fit cuts.
const head = [
frameTop(`Canvas do run ${cell(source.runId)}`),
frameRow(` ${total} sessoes · ${working} trabalhando`),
frameRow(` ${waiting} esperando voce · ${finished} prontas`),
frameTop(`Run canvas ${cell(source.runId)}`),
frameRow(` ${total} sessions · ${working} working`),
frameRow(` ${waiting} waiting for you · ${finished} finished`),
frameSep(),
frameRow(""),
];
const tail = [
frameRow(""),
frameSep(),
frameRow(" ● trabalhate espera ○ pronta"),
frameRow(" ● workingwaiting ○ finished"),
frameRow(" ▲ Claude ■ OpenCode ◆ Codex ⬟ OMP"),
frameBot(),
];
const fixed = head.length + tail.length;
// The hidden line keeps its own stem only when it hangs off a full card;
// after a run of one liners it extends the list and costs a single row.
// The hidden line keeps its own stem only when it hangs off a full card.
const hiddenSize = (list: Block[]): number =>
list.length > 0 && list[list.length - 1].kind === "card" ? 2 : 1;

let kept = blocks;
let dropped = 0;
let showHidden = false;
if (limit !== undefined) {
// Rule 7: the frame alone does not fit, so nothing is drawn at all.
if (limit < fixed) return [];
// Rule 5: collapsed lines leave first, oldest first; then full cards,
// oldest first. The hidden count grows with every row that leaves (rule 6).
// History leaves first, then full cards, with the oldest block leaving
// first within each group. The hidden count follows the block's session
// count rather than its number of rendered lines.
const victims = [...blocks].sort(
(a, b) =>
Number(a.kind === "card") - Number(b.kind === "card") ||
Expand All @@ -368,22 +385,19 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
);
const sizeOf = (list: Block[]) =>
list.reduce((sum, block, i) => sum + block.size + (stemAbove(list, i) ? 1 : 0), 0);
const needed = () => fixed + sizeOf(kept) + (hidden + dropped > 0 ? hiddenSize(kept) : 0);
const needed = () => fixed + sizeOf(kept);
for (let vi = 0; needed() > limit && vi < victims.length; vi += 1) {
kept = kept.filter((block) => block !== victims[vi]);
dropped += 1;
}
// Rule 6 outranks one more agent, but the height itself outranks
// everything: every block has left and the hidden line still does not
// fit, so the frame stands alone. kept is empty by here: the loop only
// runs out of victims once every block is gone.
if (needed() > limit && kept.length === 0 && hidden + dropped > 0) {
return [...head, ...tail];
dropped += victims[vi].count;
}
// A hidden line is useful only when it does not displace a live card.
showHidden = dropped > 0 && fixed + sizeOf(kept) + hiddenSize(kept) <= limit;
}

const hiddenTotal = hidden + dropped;
const hiddenRow = frameRow(` +${hiddenTotal} agentes ocultos`);
// `hidden` is already represented by the history block: the source does not
// expose the statuses of rows that were cut before formatting.
const hiddenTotal = showHidden ? dropped : 0;
const hiddenRow = frameRow(` +${hiddenTotal} hidden agents`);
const out: string[] = [...head];
for (let i = 0; i < kept.length; i += 1) {
const connects = i < kept.length - 1 || hiddenTotal > 0;
Expand Down Expand Up @@ -421,20 +435,19 @@ export function formatPane(

/**
* Label for the button drawn above the prompt, the one affordance that brings
* the pane back after the engine's own close box took it away. That close is
* invisible to this module (verified live: clicking the pane's X fires no hook
* at all), so the label never claims to know whether the pane is open and the
* button only ever opens. Never throws: it is read inside a render hook.
* the pane back after the engine's own close box took it away. It reports only
* current attention states, never the historical run total. Never throws: it
* is read inside a render hook.
*/
export function paneButtonLabel(snapshot: PaneSnapshot | undefined): string {
if (!snapshot || typeof snapshot !== "object") return "agentes";
if (!snapshot || typeof snapshot !== "object") return "agents";
try {
const total = count((snapshot as Partial<PaneSnapshot>).total);
const rows = Array.isArray(snapshot.rows) ? snapshot.rows : [];
const working = rows.filter((row) => LIVE.has(row?.status ?? "")).length;
const head = `${total} ${total === 1 ? "agente" : "agentes"}`;
return working > 0 ? `${head} · ${working} trabalhando` : head;
if (working > 0) return `${working} working`;
const waiting = rows.filter((row) => WAIT.has(row?.status ?? "")).length;
return waiting > 0 ? `${waiting} waiting for you` : "no active agents";
} catch {
return "agentes";
return "agents";
}
}
1 change: 0 additions & 1 deletion plugin/statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,6 @@ const fields = [
contextField(),
tokenField(),
runUsage ? runField() : localField(),
runUsage && paint(TEXT, String(Math.max(0, Math.round(runUsage.activeSessionCount))) + " agents"),
].filter(Boolean);

writeSync(1, fields.join(paint(MUTED, " · ")));
Expand Down
2 changes: 1 addition & 1 deletion scripts/pane-probe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ SESSION="pane-probe-$$-${RANDOM}"
COLS=200
ROWS=50
TIMEOUT=45
FRAME_TEXT="Canvas do run $RUN_ID"
FRAME_TEXT="Run canvas $RUN_ID"

probe_char=$'\xc3\xa9'
if [[ ${#probe_char} -ne 1 ]]; then
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function sseNamed(name: string, data: unknown): string {
return `event: ${name}\n${sseData(data)}`;
}

export function sseComment(text = "conectado"): string {
export function sseComment(text = "connected"): string {
return `: ${text}\n\n`;
}

Expand Down
Loading
Loading