@lamemind/loom-deck 0.34.0 → 0.34.2
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/cli.js +308 -1973
- package/dist/glyphs.js +83 -0
- package/dist/hooks.js +192 -0
- package/dist/input-modes.js +52 -0
- package/dist/layout.js +121 -0
- package/dist/model.js +55 -0
- package/dist/overlays/assign.js +112 -0
- package/dist/overlays/search.js +259 -0
- package/dist/overlays/sheet.js +242 -0
- package/dist/spawn.js +262 -0
- package/dist/ui/assign-screen.js +40 -0
- package/dist/ui/detail-screen.js +117 -0
- package/dist/ui/modals.js +59 -0
- package/dist/ui/panes.js +220 -0
- package/dist/ui/preview.js +45 -0
- package/dist/ui/search-screen.js +115 -0
- package/dist/viewport.js +6 -5
- package/package.json +1 -1
- package/scripts/deck-run +49 -4
package/dist/ui/panes.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// I due pane della vista principale — task e sessioni — con i rispettivi
|
|
3
|
+
// header. Sono presentazionali puri: ricevono la vista già selezionata e le
|
|
4
|
+
// larghezze già calcolate, non li derivano.
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { cut, cutParts, pad, sanitize, termWidth } from '../width.js';
|
|
7
|
+
import { isDone, paneTextWidth } from '../layout.js';
|
|
8
|
+
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, displayProg, relTime, } from '../glyphs.js';
|
|
9
|
+
import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
|
|
10
|
+
import { rowLabel, sessionTitle } from '../session-list.js';
|
|
11
|
+
import { sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from '../pane-views.js';
|
|
12
|
+
import { describeSort, PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
|
|
13
|
+
/**
|
|
14
|
+
* Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
|
|
15
|
+
* `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
|
|
16
|
+
* ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
|
|
17
|
+
* riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
|
|
18
|
+
* dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
|
|
19
|
+
* entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
|
|
20
|
+
* scoprirlo sarebbe il bordo del pane a schermo.
|
|
21
|
+
*
|
|
22
|
+
* `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
|
|
23
|
+
* segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
|
|
24
|
+
*
|
|
25
|
+
* T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
|
|
26
|
+
* con `tab`, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
|
|
27
|
+
* entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
|
|
28
|
+
* anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
|
|
29
|
+
* L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
|
|
30
|
+
* su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
|
|
31
|
+
*/
|
|
32
|
+
export function TasksHeader({ counts, active, above, below, focused, columns, }) {
|
|
33
|
+
const views = TASK_VIEWS.map((v, i) => {
|
|
34
|
+
const n = v.count(counts);
|
|
35
|
+
return {
|
|
36
|
+
// Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
|
|
37
|
+
// riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
|
|
38
|
+
text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
|
|
39
|
+
color: v.color,
|
|
40
|
+
dim: v.dim || n === 0,
|
|
41
|
+
active: v.id === active,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
const segments = [
|
|
45
|
+
...views,
|
|
46
|
+
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
47
|
+
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
48
|
+
];
|
|
49
|
+
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
50
|
+
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
51
|
+
}
|
|
52
|
+
export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
|
|
53
|
+
const allSelected = selected === ROW_ALL;
|
|
54
|
+
const spotSelected = selected === ROW_SPOT;
|
|
55
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
|
|
56
|
+
(view.hiddenPri.length + view.hiddenProg.length > 0
|
|
57
|
+
? ' · filtri: ' +
|
|
58
|
+
[
|
|
59
|
+
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
60
|
+
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
61
|
+
]
|
|
62
|
+
.map((e) => `−${e.glyph}`)
|
|
63
|
+
.join(' ')
|
|
64
|
+
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
|
|
65
|
+
// T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
|
|
66
|
+
// una lista vuota che DICE perché è vuota. Senza la nota il pane si
|
|
67
|
+
// legge come rotto: le righe meta restano, le task no, e niente spiega
|
|
68
|
+
// che è la vista scelta a non contenere nulla.
|
|
69
|
+
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
|
|
70
|
+
// windowStart riporta l'indice di finestra a quello della lista
|
|
71
|
+
// completa, su cui è keyata la selezione. +META_ROWS: le prime due
|
|
72
|
+
// righe sono le meta.
|
|
73
|
+
const sel = windowStart + i + META_ROWS === selected;
|
|
74
|
+
const n = childCount.get(task.id) ?? 0;
|
|
75
|
+
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
|
|
76
|
+
// si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
|
|
77
|
+
// fare a `truncate-end` significa passare da `cli-truncate`, che
|
|
78
|
+
// restituisce una riga più larga del pane (una colonna per emoji) e
|
|
79
|
+
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
80
|
+
// `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
|
|
81
|
+
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
82
|
+
const tail = n > 0 ? ` (${n})` : '';
|
|
83
|
+
const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
|
|
84
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
85
|
+
}))] }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Header del pane sessioni, tagliato QUI e non da Ink.
|
|
89
|
+
*
|
|
90
|
+
* `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
|
|
91
|
+
* `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
|
|
92
|
+
* code point con un budget in colonne e restituiva una riga larga 45 su un
|
|
93
|
+
* budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
|
|
94
|
+
* spariva dalla riga (invariante ③ di `width.ts`).
|
|
95
|
+
*
|
|
96
|
+
* I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
|
|
97
|
+
* condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
|
|
98
|
+
* pinnate dal resto dell'header e non è decorazione.
|
|
99
|
+
*/
|
|
100
|
+
export function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
|
|
101
|
+
const views = SESSION_VIEWS.map((v) => {
|
|
102
|
+
const n = v.count(counts);
|
|
103
|
+
return {
|
|
104
|
+
text: ` · ${v.label(counts, parentLabel)}`,
|
|
105
|
+
color: v.color,
|
|
106
|
+
dim: v.dim || n === 0,
|
|
107
|
+
active: v.id === active,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
const segments = [
|
|
111
|
+
// `Sessions` non è una voce del catalogo: nomina il pane, non un
|
|
112
|
+
// sottoinsieme, quindi non è raggiungibile con le frecce.
|
|
113
|
+
{ text: 'Sessions', color: undefined, dim: false, active: false },
|
|
114
|
+
...views,
|
|
115
|
+
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
116
|
+
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
117
|
+
];
|
|
118
|
+
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
119
|
+
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
120
|
+
}
|
|
121
|
+
export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
|
|
122
|
+
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 ? (
|
|
123
|
+
// T100 — la nota della vista di default resta quella storica, che nomina
|
|
124
|
+
// il PARENT (task, spot o tutte); le altre tre viste portano la propria,
|
|
125
|
+
// che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
|
|
126
|
+
// ha conversazioni» e «questo sottoinsieme del parent è vuoto».
|
|
127
|
+
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
|
|
128
|
+
(isAll
|
|
129
|
+
? 'nessuna conversazione nel progetto'
|
|
130
|
+
: isSpot
|
|
131
|
+
? 'nessuna sessione libera'
|
|
132
|
+
: 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
|
|
133
|
+
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
134
|
+
// box pesante (coerente con lo styling delle Done dimmate).
|
|
135
|
+
if (row.kind === 'separator') {
|
|
136
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
137
|
+
}
|
|
138
|
+
const sel = row.sessionId === selectedId;
|
|
139
|
+
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
140
|
+
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
141
|
+
if (row.kind === 'pinned' && row.stale) {
|
|
142
|
+
// T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
|
|
143
|
+
// 30 inchiodato: su un pane stretto quel valore fisso mandava la
|
|
144
|
+
// riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
|
|
145
|
+
// sfora di una colonna per emoji e mangia il bordo stesso).
|
|
146
|
+
const staleNote = sessionNotes.get(row.sessionId);
|
|
147
|
+
// La riga stale è libera (niente colonne: non ha né titolo né
|
|
148
|
+
// data), ma il binding va detto lo stesso — è una pinnata, quindi
|
|
149
|
+
// l'header del pane non ne dice l'appartenenza.
|
|
150
|
+
const staleTask = bindings.get(row.sessionId) ?? null;
|
|
151
|
+
const staleW = Math.max(0, paneTextWidth(columns) -
|
|
152
|
+
(2 /* caret */ +
|
|
153
|
+
termWidth(`${WARN} pin stale `) +
|
|
154
|
+
SID_CHARS +
|
|
155
|
+
(staleTask ? termWidth(staleTask) + 1 : 0) +
|
|
156
|
+
3 /* spazio + caporali */));
|
|
157
|
+
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: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
|
|
158
|
+
}
|
|
159
|
+
const s = row.session; // non-stale → session presente
|
|
160
|
+
const isPinnedRow = row.kind === 'pinned';
|
|
161
|
+
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
162
|
+
// righe sarebbero identiche a occhio.
|
|
163
|
+
const forked = forkOf.has(s.sessionId);
|
|
164
|
+
// T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
|
|
165
|
+
// dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
|
|
166
|
+
// quindi un marker uniforme mentirebbe su metà delle righe. E il solo
|
|
167
|
+
// glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
|
|
168
|
+
// informazione monca proprio qui, l'unica vista dove l'appartenenza
|
|
169
|
+
// non è scritta da nessun'altra parte dello schermo: da qui la colonna
|
|
170
|
+
// task accanto, che esiste solo in questa vista.
|
|
171
|
+
const bound = bindings.get(s.sessionId) ?? null;
|
|
172
|
+
const linked = isAll ? Boolean(bound) : !isSpot;
|
|
173
|
+
// T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
|
|
174
|
+
// appartiene la conversazione (pin/task/spot), questa dice se è aperta
|
|
175
|
+
// adesso. Farle condividere una cella perderebbe una delle due.
|
|
176
|
+
const liveEntry = live.get(s.sessionId);
|
|
177
|
+
// Stesso motivo per cui la colonna esiste: una pinnata resta in lista
|
|
178
|
+
// qualunque sia il parent selezionato, quindi l'header non ne dice
|
|
179
|
+
// l'appartenenza e la cella va riempita anche fuori dalla vista
|
|
180
|
+
// "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
|
|
181
|
+
// ma la cella è comunque larga `taskW`, o le colonne a destra
|
|
182
|
+
// slitterebbero riga per riga.
|
|
183
|
+
const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
|
|
184
|
+
// T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
|
|
185
|
+
// dichiara, riempita di spazi con `pad` (che misura in colonne, non in
|
|
186
|
+
// caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
|
|
187
|
+
// era lui a far slittare a sinistra di una colonna tutta la riga di
|
|
188
|
+
// ogni sessione spot.
|
|
189
|
+
const age = relTime(s.ts);
|
|
190
|
+
// Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
|
|
191
|
+
// colonne fisse sono note, quindi l'unica cella elastica prende il
|
|
192
|
+
// resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
|
|
193
|
+
// una preferenza: alzarlo sopra lo spazio reale fa uscire la riga dal
|
|
194
|
+
// pane e le mangia il bordo (invariante ③).
|
|
195
|
+
const titleW = Math.max(0, paneTextWidth(columns) -
|
|
196
|
+
(2 /* caret */ +
|
|
197
|
+
2 /* marker */ +
|
|
198
|
+
1 /* gutter */ +
|
|
199
|
+
1 /* T62 · colonna liveness */ +
|
|
200
|
+
SID_CHARS +
|
|
201
|
+
1 /* gutter */ +
|
|
202
|
+
(taskW > 0 ? taskW + 1 : 0) +
|
|
203
|
+
1 /* gutter prima della data */ +
|
|
204
|
+
ageW));
|
|
205
|
+
// T28 — `⑂` sta DENTRO la cella titolo, non in una colonna sua: una
|
|
206
|
+
// colonna dedicata costerebbe 2 spazi vuoti su ogni riga non-fork, e
|
|
207
|
+
// metterlo fuori cella sposterebbe il bordo del titolo solo sui rami —
|
|
208
|
+
// cioè rimetterebbe lo slittamento che le colonne tolgono.
|
|
209
|
+
const forkMark = forked ? '⑂ ' : '';
|
|
210
|
+
const inner = Math.max(0, titleW - termWidth(forkMark));
|
|
211
|
+
// T60 — il testo arriva già ripulito di ciò che le colonne accanto
|
|
212
|
+
// dicono già (progetto e task id): senza, la cella conterrebbe
|
|
213
|
+
// `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
|
|
214
|
+
const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
|
|
215
|
+
const used = (label.note ? termWidth(label.note) + 2 : 0) +
|
|
216
|
+
(label.note && label.rest ? 1 : 0) +
|
|
217
|
+
termWidth(label.rest);
|
|
218
|
+
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) }), ' ', 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 ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : 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));
|
|
219
|
+
}))] }));
|
|
220
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Blocco preview a piena larghezza sotto i due pane: mostra la task o la
|
|
3
|
+
// conversazione selezionata a seconda del pane a fuoco.
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { wrapLines } from '../width.js';
|
|
6
|
+
import { previewTextWidth } from '../layout.js';
|
|
7
|
+
import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize } from '../glyphs.js';
|
|
8
|
+
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 })) }));
|
|
10
|
+
}
|
|
11
|
+
// T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
|
|
12
|
+
// cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
|
|
13
|
+
// Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
|
|
14
|
+
// l'ultima risposta del modello (`« `). L'anteprima del primo prompt compare
|
|
15
|
+
// SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
|
|
16
|
+
// lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
|
|
17
|
+
// dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
18
|
+
export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
|
|
19
|
+
const width = previewTextWidth(columns);
|
|
20
|
+
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
21
|
+
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
22
|
+
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 || '-', 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}`)))] }));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
26
|
+
* Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
|
|
27
|
+
* sono righe fisse che tolgono spazio alla descrizione.
|
|
28
|
+
*/
|
|
29
|
+
export function detailMetaOf(detail) {
|
|
30
|
+
const meta = META_KEYS.map((k) => detail.fields[k])
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(' · ');
|
|
33
|
+
const commit = detail.fields['Last tracked commit'] ?? '';
|
|
34
|
+
return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
|
|
35
|
+
}
|
|
36
|
+
/** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
|
|
37
|
+
export function TaskPreview({ detail, maxLines, columns, }) {
|
|
38
|
+
const { meta, commit } = detailMetaOf(detail);
|
|
39
|
+
// Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
|
|
40
|
+
// riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
|
|
41
|
+
// ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
|
|
42
|
+
// sforare e il bug si riapre da questa singola casella di testo.
|
|
43
|
+
const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
|
|
44
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Schermate SOSTITUTIVE della ricerca full-text (T52): lista delle occorrenze
|
|
3
|
+
// e reader del messaggio aperto. Prendono l'intero frame — il budget dei due
|
|
4
|
+
// pane non viene nemmeno calcolato.
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { cut, sanitize, termWidth } from '../width.js';
|
|
7
|
+
import { sliceLine } from '../text-search.js';
|
|
8
|
+
import { MIN_QUERY } from '../search.js';
|
|
9
|
+
import { KIND_LABEL } from '../model.js';
|
|
10
|
+
import { conversationLabel, searchExcerptWidth, searchTitleWidth } from '../layout.js';
|
|
11
|
+
import { CARET, CARET_OFF, WARN, fmtDateTime } from '../glyphs.js';
|
|
12
|
+
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
13
|
+
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
14
|
+
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
15
|
+
// terminale misurano diversamente (vedi width.ts).
|
|
16
|
+
export const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
17
|
+
export const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
18
|
+
/**
|
|
19
|
+
* Riga di toggle del modale ricerca.
|
|
20
|
+
*
|
|
21
|
+
* La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
|
|
22
|
+
* era il range `1-9` delle launch prima di T43.
|
|
23
|
+
*
|
|
24
|
+
* Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
|
|
25
|
+
* convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
|
|
26
|
+
* un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
|
|
27
|
+
* non direbbero più quali sono attivi.
|
|
28
|
+
*/
|
|
29
|
+
export function ToggleHint({ opts }) {
|
|
30
|
+
const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
|
|
31
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [flag(opts.regex, '^R', 'regex'), flag(opts.caseSensitive, '^A', 'Aa'), flag(opts.wholeWord, '^W', 'word'), _jsx(Text, { dimColor: true, children: ' │' }), flag(opts.kinds.ai, '^B', 'IA'), flag(opts.kinds.tool, '^T', 'tools'), flag(opts.kinds.human, '^U', 'human')] }));
|
|
32
|
+
}
|
|
33
|
+
/** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
|
|
34
|
+
* query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
|
|
35
|
+
export function SearchListHeader({ result, query, above, below, }) {
|
|
36
|
+
if (result.error) {
|
|
37
|
+
return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
|
|
38
|
+
}
|
|
39
|
+
if (result.idle) {
|
|
40
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
|
|
41
|
+
? 'digita la chiave da cercare'
|
|
42
|
+
: `almeno ${MIN_QUERY} caratteri (${query.length})` }));
|
|
43
|
+
}
|
|
44
|
+
if (result.shown === 0) {
|
|
45
|
+
return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
|
|
46
|
+
}
|
|
47
|
+
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
48
|
+
}
|
|
49
|
+
export function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
|
|
50
|
+
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
51
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
52
|
+
const sel = row.key === selectedKey;
|
|
53
|
+
if (row.kind === 'session') {
|
|
54
|
+
const s = row.session;
|
|
55
|
+
const bound = bindings.get(s.sessionId);
|
|
56
|
+
const rowNote = sessionNotes.get(s.sessionId);
|
|
57
|
+
const noteShown = rowNote ? cut(rowNote, 24) : '';
|
|
58
|
+
// `+3` = i due caporali e lo spazio che li separa dall'etichetta.
|
|
59
|
+
// Il pavimento non è cosmetico: senza, un terminale stretto manda
|
|
60
|
+
// l'argomento di `cut` sotto zero, cioè un budget negativo.
|
|
61
|
+
// La nota si misura con `termWidth`, non con `.length`: contiene
|
|
62
|
+
// testo umano, emoji compresi.
|
|
63
|
+
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
|
|
64
|
+
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: ["\u00AB", noteShown, "\u00BB "] }) : 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
|
+
const h = row.hit;
|
|
67
|
+
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));
|
|
68
|
+
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
72
|
+
*
|
|
73
|
+
* Riempie le righe che la lista non usa: con pochi risultati il terminale
|
|
74
|
+
* resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
|
|
75
|
+
* ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
|
|
76
|
+
* del tutto di aprire il reader.
|
|
77
|
+
*
|
|
78
|
+
* Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
|
|
79
|
+
* stessa `windowRange` della lista, stessa evidenziazione del reader
|
|
80
|
+
* (`ReaderLine`) — nessuna primitiva nuova.
|
|
81
|
+
*/
|
|
82
|
+
export function SearchPreviewPane({ p }) {
|
|
83
|
+
const last = Math.min(p.total, p.from + p.lines.length);
|
|
84
|
+
const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
|
|
85
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reader fullscreen (T52 · D8).
|
|
89
|
+
*
|
|
90
|
+
* Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
|
|
91
|
+
* sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
|
|
92
|
+
* ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
|
|
93
|
+
* con query, toggle e selezione intatti.
|
|
94
|
+
*/
|
|
95
|
+
export function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
96
|
+
const last = Math.min(total, top + capacity);
|
|
97
|
+
const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
|
|
98
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \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" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
|
|
99
|
+
}
|
|
100
|
+
/** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
|
|
101
|
+
* testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
|
|
102
|
+
* le righe senza casi speciali — entrambe intersecano il suo intervallo.
|
|
103
|
+
*
|
|
104
|
+
* Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
|
|
105
|
+
* reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
|
|
106
|
+
export function ReaderLine({ line, occ, current, }) {
|
|
107
|
+
const segs = sliceLine(line.text, line.start, occ, current);
|
|
108
|
+
if (segs.length === 0)
|
|
109
|
+
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
110
|
+
return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
|
|
111
|
+
// La corrente si distingue dalle altre per COLORE di sfondo, non per
|
|
112
|
+
// presenza: tutte restano visibili, o navigare fra occorrenze non
|
|
113
|
+
// mostrerebbe più dove sono le altre.
|
|
114
|
+
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
115
|
+
}
|
package/dist/viewport.js
CHANGED
|
@@ -217,13 +217,14 @@ export function readerCapacity(rows) {
|
|
|
217
217
|
// 1 marginTop del box testo
|
|
218
218
|
// 2 bordi del box testo
|
|
219
219
|
// 1 marginTop della riga azioni
|
|
220
|
+
// 1 riga modello (T108)
|
|
220
221
|
// 1 riga azioni
|
|
221
222
|
//
|
|
222
|
-
// Le ultime
|
|
223
|
-
//
|
|
224
|
-
// aggiunta va scalata dalla capienza del contenuto o il frame
|
|
225
|
-
// (stessa invariante di TASKS_PANE_CHROME).
|
|
226
|
-
const DETAIL_CHROME =
|
|
223
|
+
// Le ultime tre sono il motivo per cui il detail non può riusare READER_CHROME:
|
|
224
|
+
// barra bottoni e selettore modello sono righe FISSE in più dentro l'overlay, e
|
|
225
|
+
// ogni riga fissa aggiunta va scalata dalla capienza del contenuto o il frame
|
|
226
|
+
// sfonda `rows` (stessa invariante di TASKS_PANE_CHROME).
|
|
227
|
+
const DETAIL_CHROME = 11;
|
|
227
228
|
// T91 — la ricerca dentro il detail: marginTop + riga del campo.
|
|
228
229
|
//
|
|
229
230
|
// `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
# "recap stato task <TaskID>" — no skill, solo recap → più controllo).
|
|
9
9
|
# Riusabile identico da TUI (Ink) e da web.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
11
|
+
# Quattro assi ORTOGONALI, da non confondere fra loro:
|
|
12
12
|
# binding task <TaskID> ..... --no-task
|
|
13
13
|
# continuità nuova ........ --resume [--fork]
|
|
14
14
|
# prompt --prompt-kind none|recap|preflight|run|checkpoint
|
|
15
|
+
# modello --model fable|opus|sonnet|haiku
|
|
15
16
|
# Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
|
|
16
17
|
# altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
|
|
17
18
|
# "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
|
|
18
19
|
# prompt era perdere la task. Il catalogo dei prompt vive qui e non nel deck
|
|
19
20
|
# perché questo è il primitive UI-agnostico: il chiamante passa un SIMBOLO, non
|
|
20
21
|
# una stringa — il quoting resta verificato in un posto solo (vedi PROMPT sotto).
|
|
22
|
+
# Il quarto asse (T108) è indipendente dagli altri tre: vale sul ramo bound come
|
|
23
|
+
# su --no-task, su una sessione nuova come su una ripresa.
|
|
21
24
|
#
|
|
22
25
|
# Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
|
|
23
26
|
# niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
|
|
@@ -43,6 +46,7 @@
|
|
|
43
46
|
# LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
|
|
44
47
|
# del progetto, letto da dconf; settata a vuoto = nessun annuncio)
|
|
45
48
|
# LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
|
|
49
|
+
# LOOM_DECK_MODEL modello quando --model non è passato (default: opus)
|
|
46
50
|
#
|
|
47
51
|
set -euo pipefail
|
|
48
52
|
|
|
@@ -56,6 +60,10 @@ TITLE_NOTE=""
|
|
|
56
60
|
# "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
|
|
57
61
|
# (nessun prompt, come sempre) e il secondo è un errore d'uso.
|
|
58
62
|
PROMPT_KIND=""
|
|
63
|
+
# Vuoto = flag non passato: la cascata (env, poi default) si risolve più sotto,
|
|
64
|
+
# insieme alla validazione, così l'ingresso da argomento e quello da env passano
|
|
65
|
+
# per lo stesso enum.
|
|
66
|
+
MODEL=""
|
|
59
67
|
# Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
|
|
60
68
|
# l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
|
|
61
69
|
# --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
|
|
@@ -84,8 +92,17 @@ PROMPT_KIND=""
|
|
|
84
92
|
# (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
|
|
85
93
|
# si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
|
|
86
94
|
# ci rientra sparisce, apici inclusi.
|
|
95
|
+
# --model <fable|opus|sonnet|haiku> (T108): modello della sessione. Enum e non
|
|
96
|
+
# stringa libera perché il valore finisce dentro `bash -lc` e un refuso
|
|
97
|
+
# produrrebbe una tab con un comando che il CLI rifiuta. Le voci sono ALIAS e
|
|
98
|
+
# restano tali: espanderle a id versionati (`claude-opus-5`) cablerebbe qui una
|
|
99
|
+
# generazione, che al primo bump diventa un modello inesistente.
|
|
87
100
|
while [[ $# -gt 0 ]]; do
|
|
88
101
|
case "$1" in
|
|
102
|
+
--model)
|
|
103
|
+
MODEL="${2:-}"; shift 2 ;;
|
|
104
|
+
--model=*)
|
|
105
|
+
MODEL="${1#*=}"; shift ;;
|
|
89
106
|
--title-note)
|
|
90
107
|
TITLE_NOTE="${2:-}"; shift 2 ;;
|
|
91
108
|
--title-note=*)
|
|
@@ -112,7 +129,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
112
129
|
esac
|
|
113
130
|
done
|
|
114
131
|
|
|
115
|
-
USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
132
|
+
USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--session-id <uuid>]
|
|
116
133
|
(es. deck-run T18)
|
|
117
134
|
| deck-run --no-task (sessione nuda, senza task)
|
|
118
135
|
| deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
|
|
@@ -127,6 +144,9 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
|
127
144
|
run /loom-works:run-task <TaskID>
|
|
128
145
|
checkpoint /loom-works:checkpoint-task <TaskID>
|
|
129
146
|
|
|
147
|
+
--model modello della sessione: fable|opus|sonnet|haiku (default: opus)
|
|
148
|
+
valore ignoto → fallback sul default, con avviso su stderr
|
|
149
|
+
|
|
130
150
|
--title-note nota della conversazione, appesa al titolo tab come «nota»
|
|
131
151
|
(ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
|
|
132
152
|
|
|
@@ -274,6 +294,28 @@ case "${PERM_MODE:-}" in
|
|
|
274
294
|
esac
|
|
275
295
|
MODE_FLAG="--permission-mode ${PERM_MODE} "
|
|
276
296
|
|
|
297
|
+
# ── modello (T108) ───────────────────────────────────────────────────────────
|
|
298
|
+
# Precedenza: --model > env LOOM_DECK_MODEL > 'opus'. Nessun campo nel file
|
|
299
|
+
# config: il default è unico per tutta la famiglia, e uno scalare per-progetto
|
|
300
|
+
# aggiungerebbe una chiave allo schema del plugin per un valore che il selettore
|
|
301
|
+
# del deck sovrascrive a ogni spawn.
|
|
302
|
+
# Il flag è passato SEMPRE, anche sul default, per la stessa ragione di
|
|
303
|
+
# permissionMode: lo spawn resta leggibile nel process tree invece di dipendere
|
|
304
|
+
# dal default del CLI, che cambia fra versioni.
|
|
305
|
+
# Valore fuori enum → fallback con avviso, non exit. Regime opposto a
|
|
306
|
+
# --prompt-kind, e il discriminante è dove finisce il valore: un kind ignoto si
|
|
307
|
+
# ferma DENTRO lo script (nessun template da scegliere, l'errore è del deck),
|
|
308
|
+
# un modello ignoto arriverebbe al CLI e produrrebbe una tab con un comando che
|
|
309
|
+
# fallisce all'avvio — cioè il guasto che permissionMode evita degradando.
|
|
310
|
+
MODEL="${MODEL:-${LOOM_DECK_MODEL:-}}"
|
|
311
|
+
case "${MODEL:-}" in
|
|
312
|
+
fable|opus|sonnet|haiku) ;;
|
|
313
|
+
'') MODEL="opus" ;;
|
|
314
|
+
*) echo "modello ignoto: '${MODEL}' → fallback 'opus' (usa fable|opus|sonnet|haiku)" >&2
|
|
315
|
+
MODEL="opus" ;;
|
|
316
|
+
esac
|
|
317
|
+
MODEL_FLAG="--model ${MODEL} "
|
|
318
|
+
|
|
277
319
|
# ── Profilo di stato per compass ─────────────────────────────────────────────
|
|
278
320
|
# Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
|
|
279
321
|
# keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
|
|
@@ -388,10 +430,13 @@ PROMPT_ARG=""
|
|
|
388
430
|
# PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
|
|
389
431
|
# (esiste anche senza task), esattamente come la label del titolo — legarlo al
|
|
390
432
|
# ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
|
|
433
|
+
# MODEL_FLAG in entrambi i rami e prima dei flag di continuità: il modello è la
|
|
434
|
+
# scelta di chi apre la sessione, non una proprietà della task né della ripresa
|
|
435
|
+
# (T108) — stesso layer di MODE_FLAG, che infatti gli sta accanto.
|
|
391
436
|
if [[ $NO_TASK -eq 1 ]]; then
|
|
392
|
-
_default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
|
|
437
|
+
_default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
|
|
393
438
|
else
|
|
394
|
-
_default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
|
|
439
|
+
_default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
|
|
395
440
|
fi
|
|
396
441
|
IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
|
|
397
442
|
|