@qvac/core 0.1.0 → 0.1.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/README.md +2 -2
- package/dist/assistant.bundle +6 -5
- package/dist-lib/schema/helpers.d.ts +28 -0
- package/dist-lib/schema/helpers.js +176 -0
- package/dist-lib/schema/vendor.d.ts +5 -0
- package/package.json +11 -4
- package/tui/README.md +133 -0
- package/tui/app.mjs +4179 -0
- package/tui/index.mjs +650 -0
- package/tui/lib/boot-timing.mjs +41 -0
- package/tui/lib/busy-words.mjs +64 -0
- package/tui/lib/export.mjs +125 -0
- package/tui/lib/knowledge-command.mjs +144 -0
- package/tui/lib/knowledge-import.mjs +135 -0
- package/tui/lib/prebuilds.mjs +81 -0
- package/tui/lib/projection-env.mjs +24 -0
- package/tui/lib/render.mjs +136 -0
- package/tui/lib/save-file.mjs +71 -0
- package/tui/lib/select.mjs +63 -0
- package/tui/lib/setup.mjs +109 -0
- package/tui/lib/sidebar.mjs +52 -0
- package/tui/lib/splash-art.mjs +107 -0
- package/tui/lib/splash.mjs +19 -0
- package/tui/lib/upload-frames.mjs +10 -0
- package/tui/lib/wrap.mjs +114 -0
- package/ducks.png +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { busyWord } from './busy-words.mjs'
|
|
2
|
+
|
|
3
|
+
const PAYLOAD_FIELDS = {
|
|
4
|
+
content: 'content',
|
|
5
|
+
thinking: 'thinking',
|
|
6
|
+
'tool-call': 'toolCall',
|
|
7
|
+
'tool-result': 'toolResult',
|
|
8
|
+
attachment: 'attachment'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function turnsFromChunks(chunks) {
|
|
12
|
+
const turns = []
|
|
13
|
+
const byRun = new Map()
|
|
14
|
+
for (const chunk of chunks) {
|
|
15
|
+
// deviceId (hex of the chunk's originDeviceId) lets a renderer label the
|
|
16
|
+
// human side per device — purely presentational, nothing schema-enforced.
|
|
17
|
+
if (chunk.type === 'user-message') {
|
|
18
|
+
turns.push({
|
|
19
|
+
from: 'user',
|
|
20
|
+
deviceId: chunk.originDeviceId?.toString('hex'),
|
|
21
|
+
segments: [{ type: 'user-message', text: chunk.userMessage.text }]
|
|
22
|
+
})
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
// A user-uploaded attachment has no runId (it isn't part of an agent
|
|
26
|
+
// turn) — an attachment a tool call produced mid-run does, and groups
|
|
27
|
+
// into that run's turn below like any other segment.
|
|
28
|
+
if (chunk.type === 'attachment' && !chunk.runId) {
|
|
29
|
+
turns.push({
|
|
30
|
+
from: 'user',
|
|
31
|
+
deviceId: chunk.originDeviceId?.toString('hex'),
|
|
32
|
+
segments: [{ type: 'attachment', detail: chunk.attachment }]
|
|
33
|
+
})
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
let turn = byRun.get(chunk.runId)
|
|
37
|
+
if (!turn) {
|
|
38
|
+
turn = { from: 'agent', runId: chunk.runId, status: null, segments: [] }
|
|
39
|
+
byRun.set(chunk.runId, turn)
|
|
40
|
+
turns.push(turn)
|
|
41
|
+
}
|
|
42
|
+
// The ask itself materializes the turn (so EVERY peer sees a run is in
|
|
43
|
+
// flight before the host even starts) but renders nothing of its own —
|
|
44
|
+
// the provider's run-status/content chunks are the visible side.
|
|
45
|
+
if (chunk.type === 'run-request') continue
|
|
46
|
+
if (chunk.type === 'run-status') {
|
|
47
|
+
turn.status = chunk.runStatus.state
|
|
48
|
+
turn.statusReason = chunk.runStatus.reason
|
|
49
|
+
turn.statusDetail = chunk.runStatus
|
|
50
|
+
// Status chunks are stamped by the device doing the work (the
|
|
51
|
+
// provider), so progress can be labelled with WHERE it's happening.
|
|
52
|
+
turn.statusDeviceId = chunk.originDeviceId?.toString('hex')
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
// An approval-request chunk IS the approval state (see runAgent's
|
|
56
|
+
// createApprove) — never coalesced with a neighbor, and carries its own
|
|
57
|
+
// id so the UI can resolveApproval() it directly.
|
|
58
|
+
if (chunk.type === 'approval-request') {
|
|
59
|
+
turn.segments.push({ type: 'approval-request', id: chunk.id, detail: chunk.approvalRequest })
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
// recovery appends a fresh sources chunk on the same run - the newest wins (v1 rule)
|
|
63
|
+
if (chunk.type === 'sources') {
|
|
64
|
+
const stale = turn.segments.findIndex((segment) => segment.type === 'sources')
|
|
65
|
+
if (stale >= 0) turn.segments.splice(stale, 1)
|
|
66
|
+
turn.segments.push({ type: 'sources', detail: chunk.sources })
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const detail = chunk[PAYLOAD_FIELDS[chunk.type]] ?? {}
|
|
70
|
+
const last = turn.segments[turn.segments.length - 1]
|
|
71
|
+
if (last && last.type === chunk.type) last.text += detail.text ?? ''
|
|
72
|
+
else {
|
|
73
|
+
turn.segments.push({
|
|
74
|
+
type: chunk.type,
|
|
75
|
+
text: detail.text ?? '',
|
|
76
|
+
detail
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return turns
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The latest still-pending approval-request across every turn, or null —
|
|
84
|
+
// there's at most one live tool call awaiting approval at a time (the
|
|
85
|
+
// harness's own loop blocks on it before doing anything else).
|
|
86
|
+
export function findPendingApproval(turns) {
|
|
87
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
88
|
+
for (const segment of turns[i].segments) {
|
|
89
|
+
if (segment.type === 'approval-request' && segment.detail.status === 'pending') return segment
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function runningAgentTurn(turns) {
|
|
96
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
97
|
+
const turn = turns[i]
|
|
98
|
+
if (turn.from === 'agent' && turn.status === 'executing') return turn
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled'])
|
|
104
|
+
|
|
105
|
+
export function runInFlight(turns) {
|
|
106
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
107
|
+
if (turns[i].from !== 'agent') continue
|
|
108
|
+
return !TERMINAL_RUN_STATES.has(turns[i].status)
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function busyStatus(modelState, runTurn) {
|
|
114
|
+
if (modelState?.status === 'loading') return { text: `loading ${modelState.model}…`, on: 'local' }
|
|
115
|
+
if (modelState?.status === 'downloading') {
|
|
116
|
+
return {
|
|
117
|
+
text: `downloading ${modelState.model}: ${Math.round(modelState.percentage ?? 0)}%`,
|
|
118
|
+
on: 'local'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (modelState?.status === 'unloading') {
|
|
122
|
+
return { text: `unloading ${modelState.model}…`, on: 'local' }
|
|
123
|
+
}
|
|
124
|
+
const progress = runTurn?.statusDetail?.progress
|
|
125
|
+
if (progress?.phase === 'loading') return { text: `loading ${progress.model}…`, on: 'host' }
|
|
126
|
+
if (progress?.phase === 'downloading') {
|
|
127
|
+
return { text: `downloading ${progress.model}: ${progress.percentage ?? 0}%`, on: 'host' }
|
|
128
|
+
}
|
|
129
|
+
if (progress?.phase === 'generating') {
|
|
130
|
+
return { text: `generating image: ${progress.percentage ?? 0}%`, on: 'host' }
|
|
131
|
+
}
|
|
132
|
+
if (progress?.phase === 'queued') {
|
|
133
|
+
return { text: `queued - agent busy in ${progress.busyChatId}`, on: 'host' }
|
|
134
|
+
}
|
|
135
|
+
return { text: busyWord(runTurn?.runId), on: null }
|
|
136
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
export async function saveUniqueFile(dir, fileName, bytes, extension) {
|
|
5
|
+
const saved = await saveUnique(dir, fileName, extension, (handle) => write(handle, bytes))
|
|
6
|
+
return saved?.file ?? null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function saveUniqueStream(dir, fileName, source, extension) {
|
|
10
|
+
return saveUnique(dir, fileName, extension, async (handle) => {
|
|
11
|
+
let bytesWritten = 0
|
|
12
|
+
for await (const data of source) bytesWritten += await write(handle, data)
|
|
13
|
+
return bytesWritten
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function saveUnique(dir, fileName, extension, writeFile) {
|
|
18
|
+
const tempDir = await fs.promises.mkdtemp(path.join(dir, '.qvac-download-'))
|
|
19
|
+
const tempFile = path.join(tempDir, 'download')
|
|
20
|
+
let handle = await fs.promises.open(tempFile, 'w')
|
|
21
|
+
let file = null
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
file = await claimUniqueFile(dir, fileName, extension, tempFile)
|
|
25
|
+
if (!file) return null
|
|
26
|
+
const bytesWritten = await writeFile(handle)
|
|
27
|
+
await handle.close()
|
|
28
|
+
handle = null
|
|
29
|
+
return { file, bytesWritten }
|
|
30
|
+
} catch (err) {
|
|
31
|
+
await handle.close().catch(noop)
|
|
32
|
+
handle = null
|
|
33
|
+
if (file) await fs.promises.unlink(file).catch(noop)
|
|
34
|
+
throw err
|
|
35
|
+
} finally {
|
|
36
|
+
// Cleanup must not replace the download error or remove the reserved destination.
|
|
37
|
+
if (handle) await handle.close().catch(noop)
|
|
38
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(noop)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function claimUniqueFile(dir, fileName, extension, source) {
|
|
43
|
+
const sanitized = path.basename(fileName).replace(/[^a-zA-Z0-9_.-]/g, '_')
|
|
44
|
+
const safe = !sanitized || sanitized === '.' || sanitized === '..' ? 'download' : sanitized
|
|
45
|
+
const dot = safe.lastIndexOf('.')
|
|
46
|
+
const base = (dot > 0 ? safe.slice(0, dot) : safe) || 'download'
|
|
47
|
+
const ext = extension === undefined ? (dot > 0 ? safe.slice(dot) : '') : extension
|
|
48
|
+
|
|
49
|
+
for (let n = 0; n < 100; n++) {
|
|
50
|
+
const file = path.join(dir, `${base}${n ? `-${n + 1}` : ''}${ext}`)
|
|
51
|
+
try {
|
|
52
|
+
// bare-fs does not preserve O_EXCL for concurrent opens; link is the atomic reservation.
|
|
53
|
+
await fs.promises.link(source, file)
|
|
54
|
+
return file
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code !== 'EEXIST') throw err
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function write(handle, data) {
|
|
63
|
+
let offset = 0
|
|
64
|
+
while (offset < data.length) {
|
|
65
|
+
const result = await handle.write(offset ? data.subarray(offset) : data)
|
|
66
|
+
offset += result.bytesWritten
|
|
67
|
+
}
|
|
68
|
+
return data.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function noop() {}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cells } from './wrap.mjs'
|
|
2
|
+
|
|
3
|
+
const REVERSE = '\x1b[7m'
|
|
4
|
+
const REVERSE_OFF = '\x1b[27m'
|
|
5
|
+
// anything that would drop reverse video mid-span, the input's own cursor cell included
|
|
6
|
+
const RESETS = new Set(['\x1b[0m', '\x1b[m', REVERSE_OFF])
|
|
7
|
+
|
|
8
|
+
function ordered(anchor, focus) {
|
|
9
|
+
const [from, to] =
|
|
10
|
+
anchor.row < focus.row || (anchor.row === focus.row && anchor.col <= focus.col)
|
|
11
|
+
? [anchor, focus]
|
|
12
|
+
: [focus, anchor]
|
|
13
|
+
return { from, to }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function span(row, from, to) {
|
|
17
|
+
if (row < from.row || row > to.row) return null
|
|
18
|
+
return {
|
|
19
|
+
start: row === from.row ? from.col : 0,
|
|
20
|
+
end: row === to.row ? to.col + 1 : Infinity
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function walk(line, start, end) {
|
|
25
|
+
let col = 0
|
|
26
|
+
let text = ''
|
|
27
|
+
let painted = ''
|
|
28
|
+
let on = false
|
|
29
|
+
for (const cell of cells(line)) {
|
|
30
|
+
if (cell.esc) {
|
|
31
|
+
painted += cell.esc
|
|
32
|
+
if (on && RESETS.has(cell.esc)) painted += REVERSE
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const inside = col >= start && col < end
|
|
36
|
+
if (inside && !on) painted += REVERSE
|
|
37
|
+
if (!inside && on) painted += REVERSE_OFF
|
|
38
|
+
on = inside
|
|
39
|
+
if (inside) text += cell.ch
|
|
40
|
+
painted += cell.ch
|
|
41
|
+
col += cell.w
|
|
42
|
+
}
|
|
43
|
+
if (on) painted += REVERSE_OFF
|
|
44
|
+
return { text: text.replace(/ +$/, ''), painted }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function selectionText(lines, anchor, focus) {
|
|
48
|
+
const { from, to } = ordered(anchor, focus)
|
|
49
|
+
const rows = []
|
|
50
|
+
for (let row = from.row; row <= Math.min(to.row, lines.length - 1); row++) {
|
|
51
|
+
const at = span(row, from, to)
|
|
52
|
+
rows.push(walk(lines[row], at.start, at.end).text)
|
|
53
|
+
}
|
|
54
|
+
return rows.join('\n').replace(/^\n+|\n+$/g, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function paintSelection(lines, anchor, focus) {
|
|
58
|
+
const { from, to } = ordered(anchor, focus)
|
|
59
|
+
return lines.map((line, row) => {
|
|
60
|
+
const at = span(row, from, to)
|
|
61
|
+
return at ? walk(line, at.start, at.end).painted : line
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// What a skill still needs before it can run, expressed as something the TUI can
|
|
2
|
+
// run for you: an OAuth route becomes /connect, a pasted-token route a /cred
|
|
3
|
+
// stub, anything else its own steps. The routes themselves come from the shared
|
|
4
|
+
// integrations registry, so the TUI and the desktop Settings UI agree on what
|
|
5
|
+
// setting a skill up means.
|
|
6
|
+
import { deriveSetupRoutes } from '../../dist-lib/lib/integrations/index.js'
|
|
7
|
+
|
|
8
|
+
const STATUS_LABELS = {
|
|
9
|
+
ready: 'ready',
|
|
10
|
+
'needs-setup': 'needs setup',
|
|
11
|
+
disconnected: 'reconnect',
|
|
12
|
+
outdated: 'update available',
|
|
13
|
+
failed: 'failed',
|
|
14
|
+
disabled: 'disabled',
|
|
15
|
+
unsupported: 'unsupported here',
|
|
16
|
+
'incompatible-model': 'needs a bigger model'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function statusLabel(status) {
|
|
20
|
+
return STATUS_LABELS[status] ?? status ?? 'unknown'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isReady(skill) {
|
|
24
|
+
return skill?.status === 'ready'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function connectSlug(credentialKey) {
|
|
28
|
+
return credentialKey
|
|
29
|
+
.replace(/_access_token$/, '')
|
|
30
|
+
.replace(/_mcp$/, '')
|
|
31
|
+
.replaceAll('_', '-')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Every route a skill exposes, in manifest order. A skill can have more than
|
|
35
|
+
// one way in — Obsidian is either the CLI or a vault folder — and the ones that
|
|
36
|
+
// carry a credential key are also how it is RECONFIGURED later.
|
|
37
|
+
export function setupActions(skill) {
|
|
38
|
+
return (deriveSetupRoutes(skillEntry(skill)).routes ?? []).map((route) =>
|
|
39
|
+
routeAction(route, skill)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// What to offer, and what else there is: `primary` is the first unsatisfied
|
|
44
|
+
// route, preferring one the TUI can run over one that only prints instructions;
|
|
45
|
+
// `others` are the remaining ways in, rendered as a one-line "or …" each.
|
|
46
|
+
export function setupPlan(skill) {
|
|
47
|
+
const actions = setupActions(skill)
|
|
48
|
+
const open = actions.filter((action) => !action.satisfied)
|
|
49
|
+
const primary = open.find((action) => action.command || action.prefill) ?? open[0] ?? null
|
|
50
|
+
const others = actions.filter((action) => action !== primary)
|
|
51
|
+
return { primary, others }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setupAction(skill) {
|
|
55
|
+
return setupPlan(skill).primary
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Every route the TUI can run, offered one first. A connected skill keeps
|
|
59
|
+
// these: they are how it is pointed somewhere else (another Obsidian vault,
|
|
60
|
+
// another GitHub token) — a route with a credential key is configuration, not
|
|
61
|
+
// a one-time connect.
|
|
62
|
+
export function configureActions(skill) {
|
|
63
|
+
const { primary, others } = setupPlan(skill)
|
|
64
|
+
return [primary, ...others].filter((action) => action?.command || action?.prefill)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function routeAction(route, skill) {
|
|
68
|
+
const action = {
|
|
69
|
+
kind: 'steps',
|
|
70
|
+
label: route.label,
|
|
71
|
+
description: route.description ?? null,
|
|
72
|
+
helpUrl: route.helpUrl ?? null,
|
|
73
|
+
satisfied: route.satisfied === true,
|
|
74
|
+
steps: route.kind === 'install' ? installSteps(skill) : [...(route.steps ?? [])],
|
|
75
|
+
command: undefined,
|
|
76
|
+
prefill: undefined
|
|
77
|
+
}
|
|
78
|
+
if (!route.credentialKey) return action
|
|
79
|
+
if (route.kind === 'oauth') {
|
|
80
|
+
return { ...action, kind: 'connect', command: `/connect ${connectSlug(route.credentialKey)}` }
|
|
81
|
+
}
|
|
82
|
+
return { ...action, kind: 'cred', prefill: `/cred ${route.credentialKey} ` }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function installSteps(skill) {
|
|
86
|
+
return (skill.install ?? []).map((s) => (s.command ? `${s.label} → ${s.command}` : s.label))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The engine reports a skill flat (name, status, credentials, …); the route
|
|
90
|
+
// registry reads a catalog entry. `a|b` credential slots list interchangeable
|
|
91
|
+
// keys — each is its own candidate route.
|
|
92
|
+
function skillEntry(skill) {
|
|
93
|
+
const credentials = (skill.requires?.credentials ?? skill.credentials ?? []).flatMap((slot) =>
|
|
94
|
+
slot.split('|')
|
|
95
|
+
)
|
|
96
|
+
return {
|
|
97
|
+
metadata: {
|
|
98
|
+
name: skill.name,
|
|
99
|
+
description: skill.description,
|
|
100
|
+
setup: skill.setup,
|
|
101
|
+
transports: skill.transports,
|
|
102
|
+
requires: { ...skill.requires, credentials },
|
|
103
|
+
install: skill.install
|
|
104
|
+
},
|
|
105
|
+
content: '',
|
|
106
|
+
available: isReady(skill),
|
|
107
|
+
state: skill.status
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The chat sidebar's row model: chats under their groups, in the order core
|
|
2
|
+
// presents them (groups first, each chat newest-activity first). Rows are what
|
|
3
|
+
// the pane draws and what its cursor walks; only `chat` rows are selectable.
|
|
4
|
+
|
|
5
|
+
export const SIDEBAR_COLS = 26
|
|
6
|
+
|
|
7
|
+
export function sidebarRows(chats, groups, activeId) {
|
|
8
|
+
const live = [...(chats ?? [])].reverse().filter((chat) => !chat.deletedAt)
|
|
9
|
+
const rows = []
|
|
10
|
+
for (const group of groups ?? []) {
|
|
11
|
+
const held = live.filter((chat) => chat.groupId === group.id)
|
|
12
|
+
rows.push({ kind: 'group', label: group.name })
|
|
13
|
+
if (!held.length) rows.push({ kind: 'empty', label: '(empty)' })
|
|
14
|
+
for (const chat of held) rows.push(chatRow(chat, activeId))
|
|
15
|
+
}
|
|
16
|
+
const loose = live.filter((chat) => !chat.groupId)
|
|
17
|
+
if (loose.length && rows.length) rows.push({ kind: 'group', label: '(ungrouped)' })
|
|
18
|
+
for (const chat of loose) rows.push(chatRow(chat, activeId))
|
|
19
|
+
return rows
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The move picker's rows — every group, plus ungrouping, minus the one the chat
|
|
23
|
+
// already sits in.
|
|
24
|
+
export function moveRows(groups, groupId) {
|
|
25
|
+
const rows = (groups ?? [])
|
|
26
|
+
.filter((group) => group.id !== groupId)
|
|
27
|
+
.map((group) => ({ kind: 'target', label: group.name, groupId: group.id }))
|
|
28
|
+
if (groupId) rows.unshift({ kind: 'target', label: '(no group)', groupId: null })
|
|
29
|
+
rows.push({ kind: 'new-group', label: 'New group…' })
|
|
30
|
+
return rows
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The next row the cursor may land on, walking `delta` at a time; the headers
|
|
34
|
+
// and the (empty) placeholders are skipped. Stays put at either end.
|
|
35
|
+
export function nextSelectable(rows, from, delta) {
|
|
36
|
+
for (let i = from + delta; i >= 0 && i < rows.length; i += delta) {
|
|
37
|
+
if (selectable(rows[i])) return i
|
|
38
|
+
}
|
|
39
|
+
return selectable(rows[from]) ? from : firstSelectable(rows)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function firstSelectable(rows) {
|
|
43
|
+
return rows.findIndex(selectable)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function selectable(row) {
|
|
47
|
+
return row?.kind === 'chat' || row?.kind === 'target' || row?.kind === 'new-group'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function chatRow(chat, activeId) {
|
|
51
|
+
return { kind: 'chat', label: chat.title, chatId: chat.id, active: chat.id === activeId }
|
|
52
|
+
}
|