diff --git a/package-lock.json b/package-lock.json index 70c666e..4222d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codedeck", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codedeck", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index f6c28db..dcb7acf 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index 58d88f9..dfe6b92 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -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; @@ -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: "▲", @@ -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[]; }; @@ -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; @@ -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(), @@ -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); @@ -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(" ● trabalha ◉ te espera ○ pronta"), + frameRow(" ● working ◉ waiting ○ 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") || @@ -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; @@ -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).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"; } } diff --git a/plugin/statusline.sh b/plugin/statusline.sh index 084c0f9..b7bfc8c 100755 --- a/plugin/statusline.sh +++ b/plugin/statusline.sh @@ -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, " · "))); diff --git a/scripts/pane-probe.sh b/scripts/pane-probe.sh index 5f282bf..88ce82e 100755 --- a/scripts/pane-probe.sh +++ b/scripts/pane-probe.sh @@ -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 diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index 274744a..93fbae2 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -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`; } diff --git a/src/web/canvas-page.ts b/src/web/canvas-page.ts index 8247d35..2af4cac 100644 --- a/src/web/canvas-page.ts +++ b/src/web/canvas-page.ts @@ -119,28 +119,28 @@ export const CANVAS_PAGE: string = `
-

Canvas das runs

conectando...

+

Run canvas

connecting...

- - - + + +
-
TrabalhandoEsperando vocêEm pausaConcluída
-

AGORA MESMO

-
arraste para organizar · scroll dá zoom · clique num agente para o detalhe
-