@lamemind/loom-deck 0.60.0 → 0.61.1
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.
- package/dist/actions.js +11 -4
- package/dist/commit-times.js +48 -2
- package/dist/epic-hierarchy.js +43 -11
- package/dist/hooks.js +21 -28
- package/dist/session-list.js +4 -2
- package/dist/spawn.js +39 -1
- package/dist/ui/panes.js +4 -4
- package/dist/ui/preview.js +1 -1
- package/dist/ui/search-screen.js +8 -7
- package/package.json +1 -1
- package/scripts/deck-run +9 -8
package/dist/actions.js
CHANGED
|
@@ -21,7 +21,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from '
|
|
|
21
21
|
import { neighborId } from './session-list.js';
|
|
22
22
|
import { cut, cutMiddle } from './width.js';
|
|
23
23
|
import { saveView, viewFilePath } from './view-store.js';
|
|
24
|
-
import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
|
|
24
|
+
import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, fallbackTitle, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
|
|
25
25
|
import { useTaskOps } from './task-ops.js';
|
|
26
26
|
export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNote, }) {
|
|
27
27
|
// La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
|
|
@@ -104,12 +104,19 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
|
|
|
104
104
|
// il campo è editabile: quello che l'utente legge è quello che parte. Assente
|
|
105
105
|
// per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
|
|
106
106
|
// viaggiano col simbolo.
|
|
107
|
+
// T150 — il vuoto si riempie QUI, prima che i due canali si separino: il
|
|
108
|
+
// sidecar (`appendNote`, sotto) e il titolo tab (`--title-note`, dentro
|
|
109
|
+
// `spawnDeck`) devono vedere lo STESSO valore, o le due superfici mostrano
|
|
110
|
+
// nomi diversi per la stessa conversazione. Vale anche sugli acceleratori
|
|
111
|
+
// della lista (^K/^P/^R), che passano di qui con `spawnNote` sul proprio
|
|
112
|
+
// default '' (P1 preflight) — nascono quindi titolati anche loro.
|
|
107
113
|
function spawnForTask(id, kind, modelKind, spawnNote = '', prompt) {
|
|
108
114
|
const sid = randomUUID();
|
|
115
|
+
const note = spawnNote || fallbackTitle(tasksDir, id, kind) || '';
|
|
109
116
|
appendTaskBinding(cwd, sid, id);
|
|
110
|
-
if (
|
|
111
|
-
appendNote(cwd, sid,
|
|
112
|
-
const spawned = spawnDeck(id, cwd, sid, kind, modelKind,
|
|
117
|
+
if (note)
|
|
118
|
+
appendNote(cwd, sid, note);
|
|
119
|
+
const spawned = spawnDeck(id, cwd, sid, kind, modelKind, note, prompt);
|
|
113
120
|
spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
|
|
114
121
|
// Il modello resta SEMPRE visibile anche quando è il default, perché è un
|
|
115
122
|
// argomento esplicito del comando (T108): gli acceleratori della lista non
|
package/dist/commit-times.js
CHANGED
|
@@ -18,6 +18,32 @@ const FORMAT_RE = /^#(\d+)$/;
|
|
|
18
18
|
// Il basename del task file, non il path intero (che cambia con la docs-root)
|
|
19
19
|
// e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
|
|
20
20
|
const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
|
|
21
|
+
const EMPTY_MAP = new Map();
|
|
22
|
+
/**
|
|
23
|
+
* T153 — gate module-level: `git log` gira solo se lo sha di HEAD è cambiato
|
|
24
|
+
* dall'ultima chiamata su questa stessa coppia (tasksDir, projectRoot). Le
|
|
25
|
+
* date di ultimo commit cambiano SOLO se HEAD si muove (§Implementation
|
|
26
|
+
* Notes T153): un rev-parse fallito degrada come degradava il log, mai un
|
|
27
|
+
* throw. `key` tiene le due coppie separate — un test che passa tmpdir
|
|
28
|
+
* diversi a chiamate successive non deve leggere la cache dell'altro.
|
|
29
|
+
*/
|
|
30
|
+
let cache = null;
|
|
31
|
+
let logSpawns = 0;
|
|
32
|
+
/** T153/DLV6 — quante volte è partito lo spawn COSTOSO (`git log`), non il
|
|
33
|
+
* gate. Il gate (`rev-parse`) gira a ogni chiamata per costruzione: contarlo
|
|
34
|
+
* renderebbe il numero inutile a dimostrare il no-op. */
|
|
35
|
+
export function commitLogSpawnCount() {
|
|
36
|
+
return logSpawns;
|
|
37
|
+
}
|
|
38
|
+
async function headSha(projectRoot) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: projectRoot });
|
|
41
|
+
return stdout.trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
21
47
|
/**
|
|
22
48
|
* Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
|
|
23
49
|
* `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
|
|
@@ -54,13 +80,33 @@ export function parseCommitLog(output) {
|
|
|
54
80
|
* incompleta proprio per le task vecchie mai più toccate, quelle che sotto
|
|
55
81
|
* `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
|
|
56
82
|
* confonderebbero.
|
|
83
|
+
*
|
|
84
|
+
* T153 — gate su `rev-parse HEAD` (un ordine di grandezza più economico
|
|
85
|
+
* dello spawn del log che sostituisce, cifre misurate nel Progress Log): a
|
|
86
|
+
* sha invariato torna la STESSA istanza di mappa, non una ricostruita — è
|
|
87
|
+
* ciò che rende inutile il confronto per firma in `useCommitTimes` (P1
|
|
88
|
+
* preflight).
|
|
57
89
|
*/
|
|
58
90
|
export async function commitTimes(tasksDir, projectRoot) {
|
|
91
|
+
const key = `${projectRoot}\u0000${tasksDir}`;
|
|
92
|
+
const sha = await headSha(projectRoot);
|
|
93
|
+
if (sha !== null && cache && cache.key === key && cache.sha === sha) {
|
|
94
|
+
return cache.map;
|
|
95
|
+
}
|
|
96
|
+
let map;
|
|
59
97
|
try {
|
|
98
|
+
// Il contatore sale PRIMA dell'await: conta gli spawn partiti, e uno
|
|
99
|
+
// spawn fallito costa comunque la fork del processo.
|
|
100
|
+
logSpawns++;
|
|
60
101
|
const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
|
|
61
|
-
|
|
102
|
+
map = parseCommitLog(stdout);
|
|
62
103
|
}
|
|
63
104
|
catch {
|
|
64
|
-
|
|
105
|
+
map = EMPTY_MAP;
|
|
65
106
|
}
|
|
107
|
+
// sha === null (git muto): non si cachea uno stato che il prossimo giro
|
|
108
|
+
// potrebbe smentire in silenzio (repo che ricompare) senza mai vedersi
|
|
109
|
+
// ricontrollato.
|
|
110
|
+
cache = sha !== null ? { key, sha, map } : null;
|
|
111
|
+
return map;
|
|
66
112
|
}
|
package/dist/epic-hierarchy.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// parentela si edita anche a mano dentro un task file già in tabella, non solo
|
|
9
9
|
// alla nascita di una riga: un trigger sulla sola firma degli id (come
|
|
10
10
|
// `useArchivable`) lascerebbe quell'edit stale fino al prossimo giro largo.
|
|
11
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { taskEpicOf, taskIsEpic } from './tasks.js';
|
|
14
14
|
// Stesso pattern di `TASK_FILE_RE` in commit-times.ts: il basename del task
|
|
@@ -17,10 +17,23 @@ import { taskEpicOf, taskIsEpic } from './tasks.js';
|
|
|
17
17
|
const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
|
|
18
18
|
export const EMPTY_EPIC_HIERARCHY = { epicOf: new Map(), epics: new Set() };
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* T153 — gate module-level: il read+parse dei task file gira solo se la mtime
|
|
21
|
+
* MASSIMA fra i file `T<N>-*.md` di `tasksDir` è cambiata da ultima chiamata
|
|
22
|
+
* (un ordine di grandezza più economico del parse pieno che sostituisce,
|
|
23
|
+
* cifre misurate nel Progress Log). Sulla SOLA mtime e non su una firma
|
|
24
|
+
* degli id: deve intercettare un edit a mano di
|
|
25
|
+
* `**Parent Task**` DENTRO un file già in tabella, che non aggiunge né toglie
|
|
26
|
+
* nessun id (Testing Notes T153). A gate scattato torna la STESSA istanza —
|
|
27
|
+
* `useEpicHierarchy` non ha più bisogno di una firma propria per lo stesso
|
|
28
|
+
* motivo di `useCommitTimes` (P1 preflight).
|
|
29
|
+
*/
|
|
30
|
+
let cache = null;
|
|
31
|
+
/**
|
|
32
|
+
* Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo del gate
|
|
33
|
+
* accettabile sul poll da 1,5s anche a scan pieno (mai il caso in regime).
|
|
34
|
+
* Cartella illeggibile o singolo file non apribile → entry saltata, mai un
|
|
35
|
+
* throw — un dato di rendering non può rompere il deck, stessa regola di
|
|
36
|
+
* `archivableIds`/`commitTimes`.
|
|
24
37
|
*/
|
|
25
38
|
export function scanEpicHierarchy(tasksDir) {
|
|
26
39
|
let entries;
|
|
@@ -28,15 +41,32 @@ export function scanEpicHierarchy(tasksDir) {
|
|
|
28
41
|
entries = readdirSync(tasksDir);
|
|
29
42
|
}
|
|
30
43
|
catch {
|
|
44
|
+
cache = null;
|
|
31
45
|
return EMPTY_EPIC_HIERARCHY;
|
|
32
46
|
}
|
|
33
|
-
|
|
34
|
-
const
|
|
47
|
+
let maxMtime = 0;
|
|
48
|
+
const files = [];
|
|
35
49
|
for (const file of entries) {
|
|
36
|
-
|
|
37
|
-
if (!m)
|
|
50
|
+
if (!TASK_FILE_RE.test(file))
|
|
38
51
|
continue;
|
|
39
|
-
|
|
52
|
+
let mtime;
|
|
53
|
+
try {
|
|
54
|
+
mtime = statSync(join(tasksDir, file)).mtimeMs;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
files.push(file);
|
|
60
|
+
if (mtime > maxMtime)
|
|
61
|
+
maxMtime = mtime;
|
|
62
|
+
}
|
|
63
|
+
if (cache && cache.key === tasksDir && cache.maxMtime === maxMtime) {
|
|
64
|
+
return cache.hierarchy;
|
|
65
|
+
}
|
|
66
|
+
const epicOf = new Map();
|
|
67
|
+
const epics = new Set();
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const id = TASK_FILE_RE.exec(file)[1];
|
|
40
70
|
let content;
|
|
41
71
|
try {
|
|
42
72
|
content = readFileSync(join(tasksDir, file), 'utf8');
|
|
@@ -50,5 +80,7 @@ export function scanEpicHierarchy(tasksDir) {
|
|
|
50
80
|
if (parent)
|
|
51
81
|
epicOf.set(id, parent);
|
|
52
82
|
}
|
|
53
|
-
|
|
83
|
+
const hierarchy = { epicOf, epics };
|
|
84
|
+
cache = { key: tasksDir, maxMtime, hierarchy };
|
|
85
|
+
return hierarchy;
|
|
54
86
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -72,28 +72,26 @@ export function useTasks(tasksPath) {
|
|
|
72
72
|
/**
|
|
73
73
|
* T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
|
|
74
74
|
* `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
|
|
75
|
-
* su una scala propria come `useArchivable
|
|
76
|
-
* ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
|
|
75
|
+
* su una scala propria come `useArchivable`.
|
|
77
76
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
77
|
+
* T153 — `commitTimes` gatea da sé su `rev-parse HEAD` (davanti allo spawn
|
|
78
|
+
* caro del log, vedi `commit-times.ts`) e a sha invariato torna la STESSA
|
|
79
|
+
* istanza di mappa. L'hook confronta quindi per IDENTITÀ, non più per firma
|
|
80
|
+
* ricostruita: la firma evitava solo il re-render, il gate a monte evita il
|
|
81
|
+
* lavoro. `lastMtime` in `useTasks` resta il gemello sullo stesso schema.
|
|
83
82
|
*/
|
|
84
83
|
export function useCommitTimes(tasksDir, projectRoot) {
|
|
85
84
|
const [commitAt, setCommitAt] = useState(() => new Map());
|
|
86
85
|
useEffect(() => {
|
|
87
|
-
let
|
|
86
|
+
let last = null;
|
|
88
87
|
let alive = true;
|
|
89
88
|
const reload = () => {
|
|
90
89
|
commitTimes(tasksDir, projectRoot).then((next) => {
|
|
91
90
|
if (!alive)
|
|
92
91
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
lastSig = sig;
|
|
92
|
+
if (next === last)
|
|
93
|
+
return; // stessa istanza → il gate non è scattato
|
|
94
|
+
last = next;
|
|
97
95
|
setCommitAt(next);
|
|
98
96
|
});
|
|
99
97
|
};
|
|
@@ -108,29 +106,24 @@ export function useCommitTimes(tasksDir, projectRoot) {
|
|
|
108
106
|
}
|
|
109
107
|
/**
|
|
110
108
|
* T67 — mappa cappello/figlie (`**Parent Task**`, `Size: Epic`), sullo STESSO
|
|
111
|
-
* poll di `tasks.md` e non su una scala propria
|
|
112
|
-
* per il read+parse di 106 task file, meno dei ~33 ms di `git log` che
|
|
113
|
-
* `useCommitTimes` paga già ogni tick. La parentela si edita anche a mano su
|
|
114
|
-
* un task file già in tabella — un trigger sulla sola firma degli id, come
|
|
115
|
-
* `useArchivable`, non vedrebbe quell'edit e lo lascerebbe stale fino al
|
|
116
|
-
* prossimo giro largo.
|
|
109
|
+
* poll di `tasks.md` e non su una scala propria.
|
|
117
110
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
111
|
+
* T153 — `scanEpicHierarchy` gatea da sé sulla mtime massima dei task file
|
|
112
|
+
* (davanti al read+parse caro, vedi `epic-hierarchy.ts`) e a mtime invariata
|
|
113
|
+
* torna la STESSA istanza: la parentela editata a mano dentro un file già in
|
|
114
|
+
* tabella resta vista, perché il gate è sulla mtime e non sugli id. L'hook
|
|
115
|
+
* confronta quindi per IDENTITÀ, non più per firma ricostruita, gemello di
|
|
116
|
+
* `useCommitTimes` per lo stesso motivo (P1 preflight).
|
|
121
117
|
*/
|
|
122
118
|
export function useEpicHierarchy(tasksDir) {
|
|
123
119
|
const [hierarchy, setHierarchy] = useState(EMPTY_EPIC_HIERARCHY);
|
|
124
120
|
useEffect(() => {
|
|
125
|
-
let
|
|
121
|
+
let last = null;
|
|
126
122
|
const reload = () => {
|
|
127
123
|
const next = scanEpicHierarchy(tasksDir);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (sig === lastSig)
|
|
132
|
-
return;
|
|
133
|
-
lastSig = sig;
|
|
124
|
+
if (next === last)
|
|
125
|
+
return; // stessa istanza → il gate non è scattato
|
|
126
|
+
last = next;
|
|
134
127
|
setHierarchy(next);
|
|
135
128
|
};
|
|
136
129
|
reload();
|
package/dist/session-list.js
CHANGED
|
@@ -111,9 +111,11 @@ const MIN_NOTE = 14;
|
|
|
111
111
|
export function rowLabel(text, note, budget) {
|
|
112
112
|
if (!note)
|
|
113
113
|
return { note: '', rest: cut(text, Math.max(0, budget)) };
|
|
114
|
-
//
|
|
114
|
+
// 1 colonna per lo spazio prima del residuo (T150 — la nota non porta più i
|
|
115
|
+
// caporali « » che decoravano il render, quindi non riserva più le loro 2
|
|
116
|
+
// colonne).
|
|
115
117
|
const rest = text;
|
|
116
|
-
const noteBudget = Math.max(0, budget
|
|
118
|
+
const noteBudget = Math.max(0, budget);
|
|
117
119
|
if (!rest)
|
|
118
120
|
return { note: cut(note, noteBudget), rest: '' };
|
|
119
121
|
// Il `min` col budget totale non è ridondante: su un pane strettissimo
|
package/dist/spawn.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { basename, dirname, join } from 'node:path';
|
|
7
|
+
import { findTaskFile } from './tasks.js';
|
|
7
8
|
// scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
|
|
8
9
|
// (build, node) stanno entrambi sotto la package root → risalita di un livello.
|
|
9
10
|
export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
|
|
@@ -171,6 +172,43 @@ export const DETAIL_ACTIONS = [
|
|
|
171
172
|
// DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
|
|
172
173
|
// irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
|
|
173
174
|
export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
|
|
175
|
+
// T150 — titolo di fallback quando il campo nota è vuoto, derivato dall'AZIONE
|
|
176
|
+
// e dal task. Mappa sul KIND, non sulla label (P2 preflight): alla sede del
|
|
177
|
+
// fallback (`spawnForTask` in actions.ts) arriva il kind — già specializzato
|
|
178
|
+
// quando la chiamata viene dal detail (`recap-task`/`recap-epic`) — mai la
|
|
179
|
+
// label del catalogo.
|
|
180
|
+
// D2/P7 — `none` (label "open") non ha una parola: è "nessuna azione", e
|
|
181
|
+
// inventargliene una contraddirebbe l'intenzione dell'utente.
|
|
182
|
+
// P3 — le tre varianti di recap condividono la parola: la specializzazione
|
|
183
|
+
// sceglie quale skill parte, non cosa l'utente sta chiedendo.
|
|
184
|
+
const ACTION_WORD = {
|
|
185
|
+
preflight: 'PREFL',
|
|
186
|
+
run: 'RUN',
|
|
187
|
+
recap: 'RECAP',
|
|
188
|
+
'recap-task': 'RECAP',
|
|
189
|
+
'recap-epic': 'RECAP',
|
|
190
|
+
checkpoint: 'CHKPOINT',
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Titolo di fallback per una conversazione con nota vuota: `{AZIONE} {slug}`,
|
|
194
|
+
* o il solo slug per `none` (D1/D2 preflight). Lo slug viene dal NOME del
|
|
195
|
+
* task file (`findTaskFile`), non dalla descrizione di `tasks.md`: il nome è
|
|
196
|
+
* già dentro l'alfabeto di `_sane_note` per costruzione — minuscolo, separato
|
|
197
|
+
* da trattini, senza punteggiatura — mentre la descrizione porta apostrofi e
|
|
198
|
+
* `/` che la riduzione toglie senza sostituto, saldando le parole (D1
|
|
199
|
+
* razionale).
|
|
200
|
+
*
|
|
201
|
+
* `null` quando il task file non si trova: il chiamante decide se ripiegare
|
|
202
|
+
* su nota vuota o su un altro fallback.
|
|
203
|
+
*/
|
|
204
|
+
export function fallbackTitle(tasksDir, id, kind) {
|
|
205
|
+
const path = findTaskFile(tasksDir, id);
|
|
206
|
+
if (!path)
|
|
207
|
+
return null;
|
|
208
|
+
const slug = basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
|
|
209
|
+
const word = ACTION_WORD[kind];
|
|
210
|
+
return word ? `${word} ${slug}` : slug;
|
|
211
|
+
}
|
|
174
212
|
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
175
213
|
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
176
214
|
// sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
|
package/dist/ui/panes.js
CHANGED
|
@@ -181,8 +181,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
|
|
|
181
181
|
termWidth(`${WARN} pin stale `) +
|
|
182
182
|
SID_CHARS +
|
|
183
183
|
(staleTask ? termWidth(staleTask) + 1 : 0) +
|
|
184
|
-
|
|
185
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: ["
|
|
184
|
+
1 /* spazio prima della nota */));
|
|
185
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" ", cut(staleNote, staleW)] }) : null] }, row.sessionId));
|
|
186
186
|
}
|
|
187
187
|
const s = row.session;
|
|
188
188
|
const isPinnedRow = row.pinned;
|
|
@@ -241,9 +241,9 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
|
|
|
241
241
|
// dicono già (progetto e task id): senza, la cella conterrebbe
|
|
242
242
|
// `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
|
|
243
243
|
const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
|
|
244
|
-
const used = (label.note ? termWidth(label.note)
|
|
244
|
+
const used = (label.note ? termWidth(label.note) : 0) +
|
|
245
245
|
(label.note && label.rest ? 1 : 0) +
|
|
246
246
|
termWidth(label.rest);
|
|
247
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (
|
|
247
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsx(Text, { color: "yellow", bold: true, children: label.note })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
|
|
248
248
|
}))] }));
|
|
249
249
|
}
|
package/dist/ui/preview.js
CHANGED
|
@@ -64,7 +64,7 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
|
|
|
64
64
|
// non c'è una provenienza vera da dichiarare.
|
|
65
65
|
const originAlias = modelAlias(s.model);
|
|
66
66
|
const originIndex = originAlias ? MODELS.indexOf(originAlias) : undefined;
|
|
67
|
-
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: [
|
|
67
|
+
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: [note, " "] }) : 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] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(resumeModel)), focused: false, width: previewTextWidth(columns), originIndex: originIndex }), 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}`)))] }));
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
70
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
package/dist/ui/search-screen.js
CHANGED
|
@@ -55,13 +55,14 @@ export function SearchScreen({ preview, hash, query, field, opts, result, rows,
|
|
|
55
55
|
const bound = bindings.get(s.sessionId);
|
|
56
56
|
const rowNote = sessionNotes.get(s.sessionId);
|
|
57
57
|
const noteShown = rowNote ? cut(rowNote, 24) : '';
|
|
58
|
-
// `+
|
|
59
|
-
// Il pavimento non è cosmetico:
|
|
60
|
-
// l'argomento di `cut` sotto
|
|
61
|
-
// La nota si misura con
|
|
62
|
-
// testo umano, emoji
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
// `+1` = lo spazio che separa la nota dall'etichetta (T150 — niente
|
|
59
|
+
// più caporali « » a decorarla). Il pavimento non è cosmetico:
|
|
60
|
+
// senza, un terminale stretto manda l'argomento di `cut` sotto
|
|
61
|
+
// zero, cioè un budget negativo. La nota si misura con
|
|
62
|
+
// `termWidth`, non con `.length`: contiene testo umano, emoji
|
|
63
|
+
// compresi.
|
|
64
|
+
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 1 : 0));
|
|
65
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: [noteShown, " "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
65
66
|
}
|
|
66
67
|
const h = row.hit;
|
|
67
68
|
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -183,7 +183,7 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
|
|
|
183
183
|
--model modello della sessione: fable|opus|sonnet|haiku (default: opus)
|
|
184
184
|
valore ignoto → fallback sul default, con avviso su stderr
|
|
185
185
|
|
|
186
|
-
--title-note nota della conversazione, appesa al titolo tab
|
|
186
|
+
--title-note nota della conversazione, appesa al titolo tab
|
|
187
187
|
(ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
|
|
188
188
|
|
|
189
189
|
if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
|
|
@@ -317,15 +317,16 @@ if [[ -n "$_cfg" ]]; then
|
|
|
317
317
|
fi
|
|
318
318
|
# Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
|
|
319
319
|
# STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
|
|
320
|
-
# la nota è già la maniglia con cui l'utente le distingue in lista
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
320
|
+
# la nota è già la maniglia con cui l'utente le distingue in lista. In coda per
|
|
321
|
+
# la solita ragione degli altri suffissi: il match compass è `.includes(label)`,
|
|
322
|
+
# la label deve restare intatta in TESTA.
|
|
323
|
+
# T150 — nudo, senza i caporali `«…»` che lo decoravano: la nota di fallback
|
|
324
|
+
# generata dal deck quando il campo è vuoto passa dallo stesso alfabeto ridotto
|
|
325
|
+
# e non ha bisogno di una cornice per distinguersi da un titolo scritto a mano.
|
|
326
|
+
# Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso.
|
|
326
327
|
if [[ -n "$TITLE_NOTE" ]]; then
|
|
327
328
|
_note="$(_sane_note "$TITLE_NOTE")"
|
|
328
|
-
[[ -n "$_note" ]] && TITLE="${TITLE}
|
|
329
|
+
[[ -n "$_note" ]] && TITLE="${TITLE} ${_note}"
|
|
329
330
|
fi
|
|
330
331
|
# Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
|
|
331
332
|
# marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
|