@lamemind/loom-deck 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ // T134 — la lista hard-wrap: settima schermata sostitutiva, apri-e-chiudi.
2
+ //
3
+ // Non è un pane e non è un catalogo: è un elenco di sola LETTURA più un campo
4
+ // che nomina il perimetro dello srotolamento. Nessuna selezione di riga, e non
5
+ // è una mancanza — l'azione non ha per oggetto un file ma un PATH, che può
6
+ // essere una cartella intera; una selezione prometterebbe un'azione per riga
7
+ // che non esiste. Da qui anche la rotella, che qui scorre il testo perché non
8
+ // c'è nessuna selezione da muovere (D5 di T21 vale al contrario).
9
+ //
10
+ // Lo spawn NON sta qui: arriva come callback `onApply`, per lo stesso confine
11
+ // degli altri overlay.
12
+ import { useState } from 'react';
13
+ import { useWrapScan } from '../hooks.js';
14
+ import { pageStep, wrapListCapacity } from '../viewport.js';
15
+ import { cpLen, insertAt, removeAt } from '../layout.js';
16
+ import { sanitizeTyped } from '../glyphs.js';
17
+ import { WRAP_DEFAULT_PATH, wrapPrompt } from '../wrap-scan.js';
18
+ export function useWrapOverlay(deps) {
19
+ const { cwd, rows, setMode, setNote, onApply } = deps;
20
+ // Lo stato dello scan vive QUI e non nel modello, sul precedente di
21
+ // `useProjectStatus`: non è solo il contenuto di una schermata — i suoi
22
+ // numeri si leggono in testata a schermata chiusa, e la lista è una delle sue
23
+ // superfici invece che il suo contenuto. Tenerli in due posti significherebbe
24
+ // che l'indicatore e la lista possono dire cose diverse dello stesso scan.
25
+ const measure = useWrapScan(cwd);
26
+ /** Le righe FOTOGRAFATE all'apertura: uno scan che finisse mentre la lista è
27
+ * a schermo la riscriverebbe sotto gli occhi, e la posizione di scroll non
28
+ * avrebbe più un riferimento. Stessa regola del viewer del project status. */
29
+ const [files, setFiles] = useState(null);
30
+ const [top, setTop] = useState(0);
31
+ const [path, setPath] = useState(WRAP_DEFAULT_PATH);
32
+ const [caret, setCaret] = useState(cpLen(WRAP_DEFAULT_PATH));
33
+ const capacity = wrapListCapacity(rows);
34
+ const maxTop = Math.max(0, (files?.length ?? 0) - capacity);
35
+ /**
36
+ * Apre la lista, o dichiara perché non si apre.
37
+ *
38
+ * Rifiuta di aprirsi su una lista vuota, e i due modi di esserlo si dicono
39
+ * distinti: «non ho ancora misurato» e «ho misurato e non c'è niente» sono
40
+ * due stati del progetto diversi, e una schermata vuota li confonderebbe in
41
+ * uno solo. È la stessa forma di `^O` senza cache.
42
+ */
43
+ function open() {
44
+ if (measure.files.length === 0) {
45
+ setNote(measure.mtime === null
46
+ ? '^W → hard-wrap non ancora misurato · ^E per lo scan'
47
+ : '^W → nessun file con l\'a-capo rientrato: niente da srotolare');
48
+ return;
49
+ }
50
+ setFiles([...measure.files]);
51
+ setTop(0);
52
+ setPath(WRAP_DEFAULT_PATH);
53
+ setCaret(cpLen(WRAP_DEFAULT_PATH));
54
+ setNote('');
55
+ setMode('wrap');
56
+ }
57
+ function close() {
58
+ setMode('normal');
59
+ setFiles(null);
60
+ }
61
+ function scroll(delta) {
62
+ setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
63
+ }
64
+ function onKey(input, key) {
65
+ if (key.escape) {
66
+ close();
67
+ return;
68
+ }
69
+ if (key.return) {
70
+ const target = path.trim() || WRAP_DEFAULT_PATH;
71
+ close();
72
+ onApply(target, wrapPrompt(target));
73
+ return;
74
+ }
75
+ if (key.upArrow) {
76
+ scroll(-1);
77
+ }
78
+ else if (key.downArrow) {
79
+ scroll(1);
80
+ }
81
+ else if (key.pageUp) {
82
+ scroll(-pageStep(capacity));
83
+ }
84
+ else if (key.pageDown) {
85
+ scroll(pageStep(capacity));
86
+ }
87
+ else if (key.leftArrow || key.rightArrow) {
88
+ const d = key.leftArrow ? -1 : 1;
89
+ setCaret((c) => Math.max(0, Math.min(cpLen(path), c + d)));
90
+ }
91
+ else if (key.backspace || key.delete) {
92
+ if (caret > 0) {
93
+ setPath((p) => removeAt(p, caret - 1));
94
+ setCaret((c) => c - 1);
95
+ }
96
+ }
97
+ else if (key.ctrl) {
98
+ // `^U` svuota, come il campo di ricerca e il filtro dell'assegnazione.
99
+ // Ogni altra combo è no-op: dentro un modo capturing gli acceleratori
100
+ // globali restano inerti.
101
+ if (input === 'u') {
102
+ setPath('');
103
+ setCaret(0);
104
+ }
105
+ }
106
+ else if (input && !key.meta) {
107
+ // `↑↓` sono già state consumate sopra: qui arrivano solo i caratteri, che
108
+ // vanno nel campo. Il testo si sanifica al confine come ogni altro campo
109
+ // del deck — un incollaggio porta byte di controllo che Ink conterebbe
110
+ // nella larghezza della riga.
111
+ const ins = sanitizeTyped(input);
112
+ setPath((p) => insertAt(p, caret, ins));
113
+ setCaret((c) => c + cpLen(ins));
114
+ }
115
+ }
116
+ /** Lo scan a richiesta, con la nota che dice cosa sta succedendo: cammina
117
+ * l'albero del progetto intero, quindi dura, e un tasto che non desse segno
118
+ * si leggerebbe come inerte. */
119
+ function scan() {
120
+ if (measure.scanning) {
121
+ setNote('^E → scan hard-wrap già in corso');
122
+ return;
123
+ }
124
+ setNote('⏳ scan hard-wrap sul progetto intero…');
125
+ measure.scan();
126
+ }
127
+ return {
128
+ files,
129
+ top,
130
+ capacity,
131
+ maxTop,
132
+ path,
133
+ caret,
134
+ count: measure.count,
135
+ mixed: measure.mixed,
136
+ mtime: measure.mtime,
137
+ ok: measure.ok,
138
+ scanning: measure.scanning,
139
+ open,
140
+ scan,
141
+ onKey,
142
+ scroll,
143
+ };
144
+ }
@@ -1,8 +1,8 @@
1
1
  // T21 (mandata 3) — le PARTI degli header dei due pane come DATO, non come JSX.
2
2
  //
3
- // Stesso principio della riga launch (`launchSegments` in `cli.tsx`): gli
3
+ // Stesso principio della riga launch (`launchRow` in `frame.ts`): gli
4
4
  // stessi oggetti compongono la riga disegnata (`ui/panes.tsx`) e le colonne
5
- // dell'hit-test (`cli.tsx`). Chi li deriva dall'altro — ri-splittando la
5
+ // dell'hit-test (`frame.ts`). Chi li deriva dall'altro — ri-splittando la
6
6
  // stringa renderizzata, o ricontando le voci del catalogo — tiene due conti
7
7
  // paralleli che divergono alla prima voce che cambia testo o ordine.
8
8
  //
@@ -12,6 +12,7 @@
12
12
  import { cutParts } from './width.js';
13
13
  import { paneTextWidth } from './layout.js';
14
14
  import { SESSION_VIEWS, TASK_VIEWS, } from './pane-views.js';
15
+ import { INBOX_VIEWS } from './inbox-views.js';
15
16
  function finish(parts, columns) {
16
17
  const shown = cutParts(parts.map((p) => p.text), paneTextWidth(columns), parts.findIndex((p) => p.active));
17
18
  return { parts, shown };
@@ -55,6 +56,30 @@ export function sessionHeaderParts(parentLabel, counts, active, above, below, co
55
56
  { key: null, text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false },
56
57
  ], columns);
57
58
  }
59
+ /**
60
+ * T134 — header del pane inbox, terzo gemello: `Inbox` nomina il pane e non si
61
+ * seleziona, poi le quattro viste del catalogo, poi `↑N`/`↓N`.
62
+ *
63
+ * L'attenuazione la decide il catalogo (`v.dim(counts)`) e non il contatore
64
+ * della voce, a differenza dei due header storici: la voce `Tutti` conta ogni
65
+ * file in lista ma si grigia sulla somma delle tre nature (D7), perché è quella
66
+ * a dire se c'è lavoro da prendere.
67
+ */
68
+ export function inboxHeaderParts(counts, active, above, below, columns) {
69
+ const views = INBOX_VIEWS.map((v) => ({
70
+ key: v.id,
71
+ text: ` · ${v.label(counts)}`,
72
+ color: v.color,
73
+ dim: v.dim(counts),
74
+ active: v.id === active,
75
+ }));
76
+ return finish([
77
+ { key: null, text: 'Inbox', dim: false, active: false },
78
+ ...views,
79
+ { key: null, text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false },
80
+ { key: null, text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false },
81
+ ], columns);
82
+ }
58
83
  /** Le parti COME SONO A SCHERMO — testo tagliato, chiave intatta — nella forma
59
84
  * che `inlineRegions` (`mouse.ts`) misura per l'hit-test. */
60
85
  export function headerItems(h) {
@@ -61,7 +61,7 @@ export function selectTasks(tasks, id, ctx) {
61
61
  return picked;
62
62
  }
63
63
  /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
64
- * perché il catalogo è puro e non può importare da `cli.tsx`; è un carattere
64
+ * perché il catalogo è puro e non può importare da `ui/`; è un carattere
65
65
  * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
66
  * `sanitize`. */
67
67
  const LIVE_GLYPH = '●';
package/dist/spawn.js CHANGED
@@ -267,6 +267,33 @@ export function spawnDeckFork(taskId, cwd, originId, newId) {
267
267
  export function spawnClaudeEmpty(cwd) {
268
268
  return launchDeckRun(['--no-task'], cwd);
269
269
  }
270
+ /**
271
+ * T134 — sessione NUDA con un prompt letterale: `deck-run --no-task --prompt
272
+ * <testo> --model <alias>`.
273
+ *
274
+ * Il lavoro che apre — drenare un file inbox, srotolare l'hard-wrap di un path
275
+ * — sta sulla DOC, non su una task (D10 preflight). Legarlo a un cappello
276
+ * esporterebbe `LOOM_TASK`, farebbe scattare l'hook `SessionStart` che inietta
277
+ * in contesto il task file di un lavoro spesso già chiuso, e metterebbe
278
+ * `· T<n>` nel titolo della tab; per un file `sweep` senza cappello la strada
279
+ * non esisterebbe nemmeno.
280
+ *
281
+ * Presidiata, non headless (D11): `spawnSkill` resta dov'è e non si estende
282
+ * qui. Ne discendono due cose. Il confine presidiata / non presidiata delle
283
+ * skill di doc smette di essere un vincolo — anche `align-doc`, che apre un
284
+ * branch con PR, è offribile, perché c'è un umano nella tab che la guarda. E la
285
+ * guardia d'ingresso `doc-guard.sh worktree`, che esce 2 su worktree sporco
286
+ * sotto `inbox/`, `reference/` o `CLAUDE.md`, la incontra la sessione e la
287
+ * mostra a chi l'ha lanciata: il deck non deve replicarla per sapere in
288
+ * anticipo se l'azione morirebbe allo step 0.
289
+ *
290
+ * Il modello è passato SEMPRE, anche sul default, per la stessa ragione di
291
+ * `permissionMode`: lo spawn resta deterministico e leggibile nel process tree
292
+ * invece di dipendere da un default che può cambiare fra versioni.
293
+ */
294
+ export function spawnBare(cwd, prompt, model) {
295
+ return launchDeckRun(['--no-task', '--prompt', prompt, '--model', model], cwd);
296
+ }
270
297
  // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
271
298
  // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
272
299
  // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
package/dist/tasks.js CHANGED
@@ -8,11 +8,24 @@ import { sanitize } from './width.js';
8
8
  * divergano (uno accetterebbe righe che l'altro rifiuta).
9
9
  */
10
10
  export const TASK_ID_RE = /^T\d+$/;
11
- // D1 (preflight T20): default docs/tasks.md, override della docs-root via env
12
- // LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
11
+ /**
12
+ * Nome della cartella doc del progetto (`docs`, `runtime`, …).
13
+ *
14
+ * D1 (preflight T20): override via env `LOOM_DECK_DOCS_ROOT`, nessun
15
+ * auto-detect e nessuna lettura del file config. Il deck non si lancia mai a
16
+ * mano — nasce da compass, che legge `docsRoot` dal registry dconf e lo
17
+ * antepone al comando, o dal custom-command del profilo Ptyxis, che ce l'ha
18
+ * cablato — quindi l'env c'è sempre nei percorsi di avvio reali.
19
+ *
20
+ * T134 — funzione e non più letterale ripetuto: la legge anche lo scan della
21
+ * coda inbox, e due copie della stessa cascata divergerebbero appena una delle
22
+ * due imparasse a leggere il file config.
23
+ */
24
+ export function docsRootName() {
25
+ return process.env.LOOM_DECK_DOCS_ROOT || 'docs';
26
+ }
13
27
  export function resolveTasksPath(cwd = process.cwd()) {
14
- const docsRoot = process.env.LOOM_DECK_DOCS_ROOT || 'docs';
15
- return join(cwd, docsRoot, 'tasks.md');
28
+ return join(cwd, docsRootName(), 'tasks.md');
16
29
  }
17
30
  // I task file vivono in `<docsRoot>/tasks/` — sibling di tasks.md. Derivo la
18
31
  // dir dallo stesso path per rispettare l'override LOOM_DECK_DOCS_ROOT.
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T134 — il detail di un file inbox: testo scorrevole più la riga che dichiara
3
+ // cosa partirà su `⏎`.
4
+ //
5
+ // Riusa `DetailLine` del detail della task invece di ri-renderizzare il
6
+ // markdown per conto proprio: sono lo stesso costrutto — un documento reso in
7
+ // span tipizzati dentro un box — e due rese dello stesso markdown
8
+ // divergerebbero al primo stile aggiunto. L'evidenziazione della ricerca non
9
+ // serve qui (nessuna ricerca dentro l'inbox), quindi le occorrenze arrivano
10
+ // vuote e il secondo taglio di `DetailLine` non gira nemmeno.
11
+ import { Box, Text } from 'ink';
12
+ import { cut } from '../width.js';
13
+ import { DetailLine } from './detail-screen.js';
14
+ import { INBOX_MARK, WARN, fmtDateTime, fmtSize } from '../glyphs.js';
15
+ import { NATURA_SHORT, inboxMark } from '../inbox.js';
16
+ export function InboxScreen({ file, missing, lines, spans, top, total, capacity, prompt, columns, }) {
17
+ const last = Math.min(total, top + capacity);
18
+ const width = Math.max(20, (columns || 80) - 4);
19
+ const mark = inboxMark(file);
20
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: NATURA_SHORT[file.natura] }), ' ', cut(file.basename, Math.max(10, width - 8))] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [INBOX_MARK[mark] ? `${INBOX_MARK[mark]} ` : '', mark, file.branch ? ` ⟨${file.branch}⟩` : '', file.cappello ? ` · ${file.cappello}` : '', " \u00B7 ", file.nozioni, " nozioni \u00B7 ", file.aperte, ' ', "aperte \u00B7 ", fmtSize(file.chars), " \u00B7 ", fmtDateTime(file.created * 1000), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193/PgUp/PgDn" }), " testo \u00B7 ", _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre la sessione \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " file non leggibile \u00B7 l'azione resta attiva (la skill risolve il file per nome)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: [], current: -1 }, top + i)))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "\u23CE \u203A " }), _jsx(Text, { color: "green", children: cut(prompt, Math.max(10, width - 4)) })] }) })] }));
21
+ }
package/dist/ui/panes.js CHANGED
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  import { Box, Text } from 'ink';
6
6
  import { cut, pad, sanitize, termWidth } from '../width.js';
7
7
  import { paneTextWidth } from '../layout.js';
8
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
8
+ import { CARET, CARET_OFF, INBOX_MARK, INBOX_MARK_W, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
9
+ import { NATURA_SHORT, NATURA_W, inboxMark } from '../inbox.js';
10
+ import { inboxView, } from '../inbox-views.js';
9
11
  import { TaskRow } from './task-row.js';
10
12
  import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
11
13
  import { rowLabel, sessionTitle } from '../session-list.js';
12
14
  import { sessionView, taskView, } from '../pane-views.js';
13
- import { sessionHeaderParts, taskHeaderParts } from '../pane-header.js';
15
+ import { inboxHeaderParts, sessionHeaderParts, taskHeaderParts, } from '../pane-header.js';
14
16
  import { describeSort, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
15
17
  /**
16
18
  * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
@@ -33,7 +35,7 @@ import { describeSort, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
33
35
  */
34
36
  export function TasksHeader({ counts, active, above, below, focused, columns, }) {
35
37
  // T21 — le parti e il loro taglio vengono da `pane-header.ts`, lo stesso
36
- // modulo da cui `cli.tsx` ricava le colonne cliccabili: una fonte sola.
38
+ // modulo da cui `frame.ts` ricava le colonne cliccabili: una fonte sola.
37
39
  const { parts, shown } = taskHeaderParts(counts, active, above, below, columns);
38
40
  return _jsx(HeaderLine, { parts: parts, shown: shown, focused: focused });
39
41
  }
@@ -83,6 +85,69 @@ export function SessionsHeader({ parentLabel, counts, active, above, below, focu
83
85
  const { parts, shown } = sessionHeaderParts(parentLabel, counts, active, above, below, columns);
84
86
  return _jsx(HeaderLine, { parts: parts, shown: shown, focused: focused });
85
87
  }
88
+ /**
89
+ * T134 — header del pane inbox, terzo gemello dei due sopra: le parti e il loro
90
+ * taglio vengono da `pane-header.ts`, lo stesso modulo da cui `frame.ts` ricava
91
+ * le colonne cliccabili.
92
+ */
93
+ export function InboxHeader({ counts, active, above, below, focused, columns, }) {
94
+ const { parts, shown } = inboxHeaderParts(counts, active, above, below, columns);
95
+ return _jsx(HeaderLine, { parts: parts, shown: shown, focused: focused });
96
+ }
97
+ /**
98
+ * T134 — il pane inbox, alternativo a quello delle sessioni nello slot destro
99
+ * (D6). Uno dei due è montato e l'altro non esiste: `^B` li scambia.
100
+ *
101
+ * Mostra SEMPRE TUTTO, senza filtro sulla task selezionata a sinistra (D7
102
+ * preflight, che supersede la clausola di D6 congelate): niente righe meta,
103
+ * nessun trattamento speciale per il cappello vuoto o per un cappello che
104
+ * nomina una task non più in `tasks.md`. Ne discende che la colonna `CAPPELLO`
105
+ * di `doc-metrics` non serve al filtro — resta nel blocco preview, dove dice
106
+ * qualcosa senza costare una colonna su ogni riga.
107
+ *
108
+ * La riga porta marcatori, branch e nome (D8). Non le cifre (nozioni, aperte,
109
+ * char): stanno nella preview, e su un pane largo la metà del terminale ogni
110
+ * colonna fissa la paga la cella elastica del nome — che è l'unica cosa con cui
111
+ * si riconosce un file.
112
+ */
113
+ export function InboxPane({ files, counts, activeView, paneCount, selectedPath, focused, above, below, columns, ok, scanned, }) {
114
+ const width = paneTextWidth(columns);
115
+ // La cella elastica si calcola per SOTTRAZIONE dalle colonne fisse, come il
116
+ // titolo della lista sessioni: pavimento 0 e non un minimo di cortesia — è un
117
+ // tetto, non una preferenza, e alzarlo sopra lo spazio reale fa uscire la riga
118
+ // dal pane mangiandone il bordo (invariante ③ di width.ts).
119
+ const ageW = 3;
120
+ const nameW = Math.max(0, width -
121
+ (2 /* caret */ +
122
+ NATURA_W +
123
+ 1 /* gutter */ +
124
+ INBOX_MARK_W +
125
+ 1 /* gutter */ +
126
+ 1 /* gutter prima della data */ +
127
+ ageW));
128
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(InboxHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
129
+ // Tre vuoti diversi, e dirlo è il punto: «non ho ancora misurato» non è
130
+ // «ho misurato e non c'è niente», e «la misura si è rotta» non è nessuno
131
+ // dei due (D2 preflight). Una lista vuota che non dice quale dei tre è
132
+ // si legge come un pane rotto.
133
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(!scanned
134
+ ? 'coda non ancora misurata'
135
+ : !ok
136
+ ? 'misura della coda fallita: plugin assente o script in errore'
137
+ : inboxView(activeView).empty, width) })) : (files.map((f) => {
138
+ const sel = f.path === selectedPath;
139
+ const mark = inboxMark(f);
140
+ // Il branch sta DENTRO la cella del nome, non in una colonna propria:
141
+ // è raro, e una colonna dedicata costerebbe spazio vuoto su ogni riga
142
+ // non-branchata di un pane largo la metà del terminale. Stessa scelta
143
+ // del marcatore di fork nella lista sessioni.
144
+ const branchMark = f.branch ? `⟨${f.branch}⟩ ` : '';
145
+ const inner = Math.max(0, nameW - termWidth(sanitize(branchMark)));
146
+ const name = cut(f.basename.replace(/\.md$/, ''), inner);
147
+ const age = relTime(f.created * 1000);
148
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: mark === 'broken' ? 'red' : mark === 'queued' ? 'green' : undefined, dimColor: mark === 'held' || mark === 'branched', children: pad(NATURA_SHORT[f.natura], NATURA_W) }), ' ', _jsx(Text, { color: mark === 'branched' ? 'yellow' : undefined, children: pad(INBOX_MARK[mark], INBOX_MARK_W) }), ' ', branchMark ? _jsx(Text, { color: "yellow", children: sanitize(branchMark) }) : null, _jsx(Text, { dimColor: mark !== 'queued', children: name }), ' '.repeat(Math.max(0, inner - termWidth(name))), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, f.path));
149
+ }))] }));
150
+ }
86
151
  export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
87
152
  return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
88
153
  // T100 — la nota della vista di default resta quella storica, che nomina
@@ -2,11 +2,41 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  // Blocco preview a piena larghezza sotto i due pane: mostra la task o la
3
3
  // conversazione selezionata a seconda del pane a fuoco.
4
4
  import { Box, Text } from 'ink';
5
- import { wrapLines } from '../width.js';
5
+ import { cut, wrapLines } from '../width.js';
6
6
  import { previewTextWidth } from '../layout.js';
7
7
  import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize } from '../glyphs.js';
8
+ import { NATURA_SHORT, inboxMark } from '../inbox.js';
9
+ /** Colonne del prefisso delle due anteprime della preview sessione (`» `, `« `,
10
+ * e i due spazi delle righe di continuazione). Costante perché entra nel
11
+ * budget di wrap, che è il posto in cui dimenticarla non produce un errore ma
12
+ * un bordo mangiato. */
13
+ const PREVIEW_PREFIX_W = 2;
8
14
  export function PreviewPane(p) {
9
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
15
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : p.kind === 'inbox' ? (_jsx(InboxPreview, { file: p.file, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
16
+ }
17
+ /**
18
+ * T134 — corpo della preview inbox: due righe FISSE, contate da
19
+ * `INBOX_DETAIL_FIXED`. Ogni riga aggiunta va scalata lì, o il frame sfonda
20
+ * `rows` e Ink passa a `clearTerminal` — che su VTE riversa un frame nello
21
+ * scrollback a ogni tick del poll.
22
+ *
23
+ * Porta ciò che la riga di lista non può: il nome INTERO (in lista si tronca
24
+ * su un pane largo la metà del terminale), le cifre del marker e il cappello.
25
+ * Il perché di uno stato — `branch:` che congela per chiunque, `drainable`
26
+ * assente che tiene fuori dalla coda ma non vieta l'esecuzione — è scritto per
27
+ * esteso, perché il glifo della lista dice solo QUALE stato, non cosa comporta.
28
+ */
29
+ export function InboxPreview({ file, columns }) {
30
+ const mark = inboxMark(file);
31
+ const width = previewTextWidth(columns);
32
+ const state = mark === 'broken'
33
+ ? 'marker illeggibile: nessuna skill lo prende, va riparato'
34
+ : mark === 'branched'
35
+ ? `congelato su ${file.branch}: lo sblocca pull-repos quando il branch è su main`
36
+ : mark === 'held'
37
+ ? 'fuori dalla coda automatica (nessun drainable): eseguibile se lo nomini'
38
+ : 'in coda: una skill può prenderlo da sola';
39
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: NATURA_SHORT[file.natura] }), ' ', cut(file.basename, Math.max(10, width - 8))] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [file.nozioni, " nozioni \u00B7 ", file.aperte, " aperte \u00B7 ", fmtSize(file.chars), " \u00B7", ' ', fmtDateTime(file.created * 1000), file.cappello ? ` · ${file.cappello}` : '', file.indexed ? ' · indexed' : '', " \u00B7 ", state] })] }));
10
40
  }
11
41
  // T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
12
42
  // cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
@@ -16,7 +46,15 @@ export function PreviewPane(p) {
16
46
  // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
17
47
  // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
18
48
  export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
19
- const width = previewTextWidth(columns);
49
+ // Il prefisso `» `/`« ` è largo 2 e sta SULLA riga: va tolto dal budget di
50
+ // wrap, o una riga piena esce dal box di quelle due colonne e a raccoglierla
51
+ // arriva `cli-truncate`, che sfora di una colonna per emoji e mangia il bordo
52
+ // (invariante ③ di width.ts). Il difetto si vede solo quando una riga cade
53
+ // esattamente sul budget, cioè su una risposta abbastanza lunga: da qui un
54
+ // gate che passa o fallisce a seconda delle conversazioni che trova sul
55
+ // disco. Le righe di continuazione portano due spazi, quindi il prefisso
56
+ // costa 2 su OGNI riga, non solo sulla prima.
57
+ const width = previewTextWidth(columns) - PREVIEW_PREFIX_W;
20
58
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
21
59
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
22
60
  return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', s.model ? ` · ${s.model}` : '', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  // Le SCHERMATE SOSTITUTIVE del deck e i due frammenti che le accompagnano.
3
3
  //
4
4
  // Una schermata sostitutiva prende il frame intero invece di stare in un box
5
- // sopra i due pane: assegnazione, detail della task, project status, ricerca e
6
- // reader. Il criterio è la taglia del contenuto — una lista di occorrenze o un
7
- // task file non entrano in quattro righe e la conseguenza è che il budget
8
- // d'altezza dei pane non viene nemmeno calcolato, perché il render esce prima.
5
+ // sopra i due pane: assegnazione, detail della task, project status, detail di
6
+ // un file inbox, ricerca e reader. Il criterio è la taglia del contenuto — una
7
+ // lista di occorrenze, un task file o un file di nozioni non entrano in quattro
8
+ // righe — e la conseguenza è che il budget d'altezza dei pane non viene nemmeno
9
+ // calcolato, perché il render esce prima.
9
10
  //
10
- // `screenFor` è il ROUTER: sceglie fra le cinque e restituisce `null` quando
11
+ // `screenFor` è il ROUTER: sceglie fra le sei e restituisce `null` quando
11
12
  // nessuna è attiva, cioè quando si resta sulla lista. È una funzione e non un
12
13
  // componente proprio per questo — il chiamante deve poter distinguere «ecco la
13
14
  // schermata» da «non è il tuo turno», e un componente che rende `null` non gli
@@ -25,12 +26,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
25
26
  // primo file di `ui/` a farlo, quindi va detto invece che scoperto a grep.
26
27
  import { Box, Text } from 'ink';
27
28
  import { rowIndexOfKey, selectedRow } from '../search.js';
29
+ import { cut } from '../width.js';
30
+ import { legendWidth } from '../frame.js';
28
31
  import { isCompact, searchPreviewCapacity, windowRange } from '../viewport.js';
29
32
  import { conversationLabel } from '../layout.js';
30
33
  import { taskColumns } from '../view.js';
31
34
  import { AssignScreen } from './assign-screen.js';
32
35
  import { DetailScreen } from './detail-screen.js';
33
36
  import { StatusScreen } from './status-screen.js';
37
+ import { InboxScreen } from './inbox-screen.js';
38
+ import { WrapScreen } from './wrap-screen.js';
34
39
  import { ReaderScreen, SearchScreen } from './search-screen.js';
35
40
  /**
36
41
  * Il ripiego per un terminale troppo basso: una riga sola al posto della
@@ -51,11 +56,17 @@ export function CompactNotice({ what, esc, rows, columns, dot = true, }) {
51
56
  * flusso le sue istruzioni. I modali sostitutivi non arrivano qui — hanno già
52
57
  * preso il frame.
53
58
  *
54
- * La legenda di `normal` la calcola il chiamante (`deckLegend` in `frame.ts`),
55
- * perché dipende da cosa è selezionato; le sette righe dei modali sono testo
56
- * fisso e vivono qui, accanto alla forma che le rende.
59
+ * T134 in `normal` la riga non è più tutta della legenda: divide lo spazio con
60
+ * gli INDICATORI ancorati a destra, e sono loro ad avere la precedenza sul
61
+ * budget (D5 preflight). `keyLegend` è già troncabile per costruzione e ciò che
62
+ * perde si ricorda; un contatore troncato invece mente.
63
+ *
64
+ * Dentro un modale in flusso gli indicatori non compaiono, e non è un
65
+ * nascondimento: la riga è dell'istruzione del modale, che sta a schermo per il
66
+ * tempo di una domanda. «Mai nascondibile» (D9) dice che non esiste un tasto per
67
+ * spegnerli, non che ogni riga del frame debba ospitarli.
57
68
  */
58
- export function HintBar({ mode, purge, keyLegend, }) {
69
+ export function HintBar({ mode, purge, keyLegend, indicators, columns, }) {
59
70
  if (mode === 'create') {
60
71
  return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
61
72
  }
@@ -74,19 +85,29 @@ export function HintBar({ mode, purge, keyLegend, }) {
74
85
  if (mode === 'edit') {
75
86
  return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
76
87
  }
77
- return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend }));
88
+ // Riga a piena larghezza nella stessa forma della testata: un `Box` con
89
+ // `justifyContent="space-between"` e due figli a stringa piatta. Il blocco
90
+ // indicatori arriva già unito (`indicators.text`) invece che come segmenti
91
+ // resi uno per uno — da cui il colore unico per il blocco.
92
+ //
93
+ // Il taglio della legenda resta del deck e non di Ink: la stringa porta emoji
94
+ // e frecce, e `cli-truncate` le conta con un budget in colonne indicizzando
95
+ // per code point — una colonna di troppo finisce sopra il bordo destro, che
96
+ // sparisce dalla riga (invariante ③ di width.ts).
97
+ return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(keyLegend, legendWidth(columns, indicators.width)) }), _jsx(Text, { color: "cyan", children: indicators.text })] }));
78
98
  }
79
99
  /**
80
100
  * Sceglie la schermata sostitutiva attiva, o `null` per restare sulla lista.
81
101
  *
82
102
  * L'ordine dei rami è quello che avevano in `cli.tsx` e non è indifferente: i
83
- * modi si escludono a vicenda, ma `detail` e `status` chiedono anche che il
84
- * proprio contenuto esista (`sheet.sheet`, `status.view`) — un modo dichiarato
85
- * senza contenuto deve cadere alla lista, non a una schermata vuota.
103
+ * modi si escludono a vicenda, ma `detail`, `status` e `inbox` chiedono anche
104
+ * che il proprio contenuto esista (`sheet.sheet`, `status.view`, `inbox.sheet`)
105
+ * — un modo dichiarato senza contenuto deve cadere alla lista, non a una
106
+ * schermata vuota.
86
107
  */
87
108
  export function screenFor(input) {
88
109
  const { mode, rows, columns, note, overlays } = input;
89
- const { assign, sheet, search, status } = overlays;
110
+ const { assign, sheet, search, status, inbox, wrap } = overlays;
90
111
  // ── T57 · schermata di assegnazione ─────────────────────────────────────
91
112
  // Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
92
113
  // sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
@@ -142,6 +163,28 @@ export function screenFor(input) {
142
163
  const start = Math.min(status.top, status.maxTop);
143
164
  return (_jsx(StatusScreen, { name: input.projectCore ?? input.projectName, label: status.label, building: status.building, failed: status.failed, view: status.view, lines: status.lines.slice(start, start + status.capacity), spans: status.doc?.spans ?? [], top: start, total: status.lines.length, capacity: status.capacity, columns: columns }));
144
165
  }
166
+ // ── T134 · detail di un file inbox ──────────────────────────────────────
167
+ // Sesta schermata sostitutiva, stessa ragione delle altre cinque: un file di
168
+ // nozioni supera i 28KB, cioè è più lungo di ogni task file del progetto.
169
+ if (mode === 'inbox' && inbox.sheet) {
170
+ if (isCompact(inbox.capacity)) {
171
+ return (_jsx(CompactNotice, { what: inbox.sheet.file.basename, esc: "chiude", rows: rows, columns: columns }));
172
+ }
173
+ // Il clamp serve anche qui: un resize può accorciare il testo sotto uno
174
+ // scroll già dato.
175
+ const start = Math.min(inbox.top, inbox.maxTop);
176
+ return (_jsx(InboxScreen, { file: inbox.sheet.file, missing: inbox.sheet.text === null, lines: inbox.lines.slice(start, start + inbox.capacity), spans: inbox.doc?.spans ?? [], top: start, total: inbox.lines.length, capacity: inbox.capacity, prompt: inbox.prompt, columns: columns }));
177
+ }
178
+ // ── T134 · lista hard-wrap ──────────────────────────────────────────────
179
+ // Settima schermata sostitutiva: sul cappello lo scan trova 77 `WRAP` e 147
180
+ // `misto`, cioè una lista che in un box sopra i pane non entrerebbe.
181
+ if (mode === 'wrap' && wrap.files) {
182
+ if (isCompact(wrap.capacity)) {
183
+ return _jsx(CompactNotice, { what: "hard-wrap", esc: "chiude", rows: rows, columns: columns });
184
+ }
185
+ const start = Math.min(wrap.top, wrap.maxTop);
186
+ return (_jsx(WrapScreen, { files: wrap.files.slice(start, start + wrap.capacity), count: wrap.count, mixed: wrap.mixed, mtime: wrap.mtime, top: start, capacity: wrap.capacity, path: wrap.path, caret: wrap.caret, columns: columns }));
187
+ }
145
188
  // ── T52 · ricerca e reader ──────────────────────────────────────────────
146
189
  // Gli unici modali che NON stanno in flusso sopra i pane: una lista di
147
190
  // occorrenze non entra in un box da 4 righe. Prendono l'intero frame, quindi
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T134 — la lista hard-wrap: i file con l'a-capo automatico rientrato, più il
3
+ // campo che nomina il perimetro dello srotolamento.
4
+ import { Box, Text } from 'ink';
5
+ import { cut, pad, termWidth } from '../width.js';
6
+ import { FieldText } from './fields.js';
7
+ import { fmtTime } from '../glyphs.js';
8
+ /** Larghezza della colonna verdetto: il dominio è chiuso (`WRAP`, `misto`) e la
9
+ * più lunga è larga 5, quindi misurarla a ogni render calcolerebbe un numero
10
+ * già noto. */
11
+ const VERDICT_W = 5;
12
+ export function WrapScreen({ files, count, mixed, mtime, top, capacity, path, caret, columns, }) {
13
+ const width = Math.max(20, (columns || 80) - 4);
14
+ const total = count + mixed;
15
+ const last = Math.min(total, top + capacity);
16
+ // Cella elastica per sottrazione, come ogni lista del deck: le colonne fisse
17
+ // sono note, quindi il path prende il resto. Pavimento 0 — è un tetto, non
18
+ // una preferenza, e alzarlo sopra lo spazio reale fa uscire la riga dal box
19
+ // mangiandone il bordo (invariante ③ di width.ts).
20
+ //
21
+ // Le due celle numeriche vanno contate INSIEME al loro separatore: `col=` è
22
+ // larga 8, `br=` 6, e fra loro c'è uno spazio. Sommarne solo due su tre è
23
+ // l'off-by-one che tronca la coda della riga.
24
+ const COL_W = 8;
25
+ const BREAKS_W = 6;
26
+ const numsW = COL_W + 1 + BREAKS_W;
27
+ // `- 4` = bordo + padding del box della lista, che sta DENTRO quello esterno.
28
+ const pathW = Math.max(0, width - 4 - VERDICT_W - 1 - numsW);
29
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: "hard-wrap" }), ' ', _jsxs(Text, { color: "red", children: [count, " WRAP"] }), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", mixed, " misto"] }), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 scan ", mtime === null ? 'mai' : fmtTime(mtime), " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193/PgUp/PgDn" }), " scorre \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota il path \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " srotola \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude \u00B7 misto = falso allarme probabile, fuori dal contatore"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: files.map((f) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: f.verdict === 'WRAP' ? 'red' : undefined, dimColor: f.verdict !== 'WRAP', children: pad(f.verdict, VERDICT_W) }), ' ', _jsx(Text, { dimColor: f.verdict !== 'WRAP', children: cut(f.path, pathW) }), ' '.repeat(Math.max(0, pathW - termWidth(cut(f.path, pathW)))), _jsxs(Text, { dimColor: true, children: [pad(`col=${f.column ?? '?'}`, COL_W, 'right'), ' ', pad(`br=${f.breaks}`, BREAKS_W, 'right')] })] }, f.path))) }), _jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "path \u203A " }), _jsx(FieldText, { value: path, caret: caret, focused: true, cols: Math.max(10, width - 12) })] })] }));
30
+ }