floq 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect, useCallback } from 'react';
3
+ import { Box, Text, useInput, useApp, useStdout } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { eq, and, inArray, gte } from 'drizzle-orm';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { getDb, schema } from '../../db/index.js';
8
+ import { t, fmt } from '../../i18n/index.js';
9
+ import { useTheme } from '../theme/index.js';
10
+ import { isTursoEnabled, getContexts, addContext } from '../../config.js';
11
+ import { VERSION } from '../../version.js';
12
+ import { useHistory, CreateTaskCommand, MoveTaskCommand, } from '../history/index.js';
13
+ import { SearchBar } from './SearchBar.js';
14
+ import { SearchResults } from './SearchResults.js';
15
+ import { HelpModal } from './HelpModal.js';
16
+ // Round border characters
17
+ const BORDER = {
18
+ topLeft: '╭',
19
+ topRight: '╮',
20
+ bottomLeft: '╰',
21
+ bottomRight: '╯',
22
+ horizontal: '─',
23
+ vertical: '│',
24
+ };
25
+ const SHADOW = '░';
26
+ function getDisplayWidth(str) {
27
+ let width = 0;
28
+ for (const char of str) {
29
+ const code = char.charCodeAt(0);
30
+ if ((code >= 0x1100 && code <= 0x115F) ||
31
+ (code >= 0x2E80 && code <= 0x9FFF) ||
32
+ (code >= 0xAC00 && code <= 0xD7AF) ||
33
+ (code >= 0xF900 && code <= 0xFAFF) ||
34
+ (code >= 0xFE10 && code <= 0xFE1F) ||
35
+ (code >= 0xFE30 && code <= 0xFE6F) ||
36
+ (code >= 0xFF00 && code <= 0xFF60) ||
37
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
38
+ (code >= 0x20000 && code <= 0x2FFFF)) {
39
+ width += 2;
40
+ }
41
+ else {
42
+ width += 1;
43
+ }
44
+ }
45
+ return width;
46
+ }
47
+ function TitledBoxInline({ title, children, width, minHeight = 1, isActive = false, showShadow = true, }) {
48
+ const theme = useTheme();
49
+ const color = isActive ? theme.colors.borderActive : theme.colors.border;
50
+ const shadowColor = theme.colors.muted;
51
+ const innerWidth = width - 2;
52
+ const titleLength = getDisplayWidth(title);
53
+ const leftDashes = 3;
54
+ const titlePadding = 2;
55
+ const rightDashes = Math.max(0, innerWidth - leftDashes - titlePadding - titleLength);
56
+ const childArray = React.Children.toArray(children);
57
+ const contentRows = childArray.length || 1;
58
+ const emptyRowsNeeded = Math.max(0, minHeight - contentRows);
59
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.topLeft }), _jsxs(Text, { color: color, children: [BORDER.horizontal.repeat(leftDashes), " "] }), _jsx(Text, { color: theme.colors.accent, bold: true, children: title }), _jsxs(Text, { color: color, children: [" ", BORDER.horizontal.repeat(rightDashes)] }), _jsx(Text, { color: color, children: BORDER.topRight }), showShadow && _jsx(Text, { children: " " })] }), childArray.length > 0 ? (childArray.map((child, i) => (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(Box, { flexGrow: 1, children: child }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, i)))) : (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { width: innerWidth, children: _jsx(Text, { children: " " }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] })), Array.from({ length: emptyRowsNeeded }).map((_, i) => (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { width: innerWidth, children: _jsx(Text, { children: " " }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, `empty-${i}`))), _jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.bottomLeft }), _jsx(Text, { color: color, children: BORDER.horizontal.repeat(innerWidth) }), _jsx(Text, { color: color, children: BORDER.bottomRight }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }), showShadow && (_jsx(Box, { children: _jsxs(Text, { color: shadowColor, children: [" ", SHADOW.repeat(width)] }) }))] }));
60
+ }
61
+ const CATEGORIES = ['todo', 'doing', 'done'];
62
+ export function KanbanDQ({ onOpenSettings }) {
63
+ const theme = useTheme();
64
+ const { exit } = useApp();
65
+ const { stdout } = useStdout();
66
+ const history = useHistory();
67
+ const i18n = t();
68
+ const [mode, setMode] = useState('normal');
69
+ const [paneFocus, setPaneFocus] = useState('category');
70
+ const [selectedCategory, setSelectedCategory] = useState('todo');
71
+ const [selectedTaskIndex, setSelectedTaskIndex] = useState(0);
72
+ const [tasks, setTasks] = useState({
73
+ todo: [],
74
+ doing: [],
75
+ done: [],
76
+ });
77
+ const [message, setMessage] = useState(null);
78
+ const [inputValue, setInputValue] = useState('');
79
+ const [selectedTask, setSelectedTask] = useState(null);
80
+ const [taskComments, setTaskComments] = useState([]);
81
+ const [selectedCommentIndex, setSelectedCommentIndex] = useState(0);
82
+ const [contextFilter, setContextFilter] = useState(null);
83
+ const [contextSelectIndex, setContextSelectIndex] = useState(0);
84
+ const [availableContexts, setAvailableContexts] = useState([]);
85
+ const [searchQuery, setSearchQuery] = useState('');
86
+ const [searchResults, setSearchResults] = useState([]);
87
+ const [searchResultIndex, setSearchResultIndex] = useState(0);
88
+ const terminalWidth = stdout?.columns || 80;
89
+ const leftPaneWidth = 20;
90
+ const rightPaneWidth = terminalWidth - leftPaneWidth - 6;
91
+ const loadTasks = useCallback(async () => {
92
+ const db = getDb();
93
+ const filterByContext = (taskList) => {
94
+ if (contextFilter === null)
95
+ return taskList;
96
+ if (contextFilter === '')
97
+ return taskList.filter(t => !t.context);
98
+ return taskList.filter(t => t.context === contextFilter);
99
+ };
100
+ let todoTasks = await db
101
+ .select()
102
+ .from(schema.tasks)
103
+ .where(and(inArray(schema.tasks.status, ['inbox', 'someday']), eq(schema.tasks.isProject, false)));
104
+ let doingTasks = await db
105
+ .select()
106
+ .from(schema.tasks)
107
+ .where(and(inArray(schema.tasks.status, ['next', 'waiting']), eq(schema.tasks.isProject, false)));
108
+ const oneWeekAgo = new Date();
109
+ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
110
+ let doneTasks = await db
111
+ .select()
112
+ .from(schema.tasks)
113
+ .where(and(eq(schema.tasks.status, 'done'), eq(schema.tasks.isProject, false), gte(schema.tasks.updatedAt, oneWeekAgo)));
114
+ setTasks({
115
+ todo: filterByContext(todoTasks),
116
+ doing: filterByContext(doingTasks),
117
+ done: filterByContext(doneTasks),
118
+ });
119
+ setAvailableContexts(getContexts());
120
+ }, [contextFilter]);
121
+ const loadTaskComments = useCallback(async (taskId) => {
122
+ const db = getDb();
123
+ const comments = await db
124
+ .select()
125
+ .from(schema.comments)
126
+ .where(eq(schema.comments.taskId, taskId));
127
+ setTaskComments(comments);
128
+ }, []);
129
+ // Get all tasks for search (across all categories)
130
+ const getAllTasks = useCallback(() => {
131
+ const allTasks = [];
132
+ for (const cat of CATEGORIES) {
133
+ allTasks.push(...tasks[cat]);
134
+ }
135
+ return allTasks;
136
+ }, [tasks]);
137
+ // Search tasks by query
138
+ const searchTasks = useCallback((query) => {
139
+ if (!query.trim())
140
+ return [];
141
+ const lowerQuery = query.toLowerCase();
142
+ const allTasks = getAllTasks();
143
+ return allTasks.filter(task => task.title.toLowerCase().includes(lowerQuery) ||
144
+ (task.description && task.description.toLowerCase().includes(lowerQuery)));
145
+ }, [getAllTasks]);
146
+ // Handle search query change
147
+ const handleSearchChange = useCallback((value) => {
148
+ setSearchQuery(value);
149
+ const results = searchTasks(value);
150
+ setSearchResults(results);
151
+ setSearchResultIndex(0);
152
+ }, [searchTasks]);
153
+ // Get kanban category from task status
154
+ const getKanbanCategory = (status) => {
155
+ if (status === 'inbox' || status === 'someday')
156
+ return 'todo';
157
+ if (status === 'next' || status === 'waiting')
158
+ return 'doing';
159
+ return 'done';
160
+ };
161
+ // Navigate to a task from search results
162
+ const navigateToTask = useCallback((task) => {
163
+ const targetCategory = getKanbanCategory(task.status);
164
+ const categoryTasks = tasks[targetCategory];
165
+ const taskIndex = categoryTasks.findIndex(t => t.id === task.id);
166
+ if (taskIndex >= 0) {
167
+ setSelectedCategory(targetCategory);
168
+ setSelectedTaskIndex(taskIndex);
169
+ setPaneFocus('tasks');
170
+ setMode('normal');
171
+ }
172
+ }, [tasks]);
173
+ useEffect(() => {
174
+ loadTasks();
175
+ }, [loadTasks]);
176
+ const currentTasks = tasks[selectedCategory];
177
+ const getCategoryLabel = (cat) => {
178
+ return i18n.kanban[cat];
179
+ };
180
+ const addTask = useCallback(async (title, context) => {
181
+ if (!title.trim())
182
+ return;
183
+ const now = new Date();
184
+ const taskId = uuidv4();
185
+ const command = new CreateTaskCommand({
186
+ task: {
187
+ id: taskId,
188
+ title: title.trim(),
189
+ status: 'inbox',
190
+ context: context || null,
191
+ createdAt: now,
192
+ updatedAt: now,
193
+ },
194
+ description: fmt(i18n.tui.added, { title: title.trim() }),
195
+ });
196
+ await history.execute(command);
197
+ setMessage(fmt(i18n.tui.added, { title: title.trim() }));
198
+ await loadTasks();
199
+ }, [i18n.tui.added, loadTasks, history]);
200
+ const moveTaskRight = useCallback(async (task) => {
201
+ let newStatus;
202
+ if (task.status === 'inbox' || task.status === 'someday') {
203
+ newStatus = 'next';
204
+ }
205
+ else if (task.status === 'next' || task.status === 'waiting') {
206
+ newStatus = 'done';
207
+ }
208
+ else {
209
+ return;
210
+ }
211
+ const command = new MoveTaskCommand({
212
+ taskId: task.id,
213
+ fromStatus: task.status,
214
+ toStatus: newStatus,
215
+ fromWaitingFor: task.waitingFor,
216
+ toWaitingFor: null,
217
+ description: fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }),
218
+ });
219
+ await history.execute(command);
220
+ setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
221
+ await loadTasks();
222
+ }, [i18n.tui.movedTo, i18n.status, loadTasks, history]);
223
+ const markTaskDone = useCallback(async (task) => {
224
+ const command = new MoveTaskCommand({
225
+ taskId: task.id,
226
+ fromStatus: task.status,
227
+ toStatus: 'done',
228
+ fromWaitingFor: task.waitingFor,
229
+ toWaitingFor: null,
230
+ description: fmt(i18n.tui.completed, { title: task.title }),
231
+ });
232
+ await history.execute(command);
233
+ setMessage(fmt(i18n.tui.completed, { title: task.title }));
234
+ await loadTasks();
235
+ }, [i18n.tui.completed, loadTasks, history]);
236
+ const handleInputSubmit = async (value) => {
237
+ // Handle search mode submit
238
+ if (mode === 'search') {
239
+ if (searchResults.length > 0) {
240
+ const task = searchResults[searchResultIndex];
241
+ navigateToTask(task);
242
+ }
243
+ else {
244
+ setMode('normal');
245
+ }
246
+ setSearchQuery('');
247
+ setSearchResults([]);
248
+ setSearchResultIndex(0);
249
+ return;
250
+ }
251
+ if (mode === 'add') {
252
+ if (value.trim()) {
253
+ await addTask(value, contextFilter && contextFilter !== '' ? contextFilter : null);
254
+ }
255
+ setInputValue('');
256
+ setMode('normal');
257
+ return;
258
+ }
259
+ if (mode === 'add-context') {
260
+ if (value.trim()) {
261
+ const newContext = value.trim().toLowerCase().replace(/^@/, '');
262
+ addContext(newContext);
263
+ setAvailableContexts(getContexts());
264
+ }
265
+ setInputValue('');
266
+ setContextSelectIndex(0);
267
+ setMode('normal');
268
+ return;
269
+ }
270
+ };
271
+ useInput((input, key) => {
272
+ // Handle help mode - let HelpModal handle its own input
273
+ if (mode === 'help') {
274
+ return;
275
+ }
276
+ if (mode === 'add' || mode === 'add-comment' || mode === 'add-context') {
277
+ if (key.escape) {
278
+ setInputValue('');
279
+ setMode('normal');
280
+ }
281
+ return;
282
+ }
283
+ // Handle search mode
284
+ if (mode === 'search') {
285
+ if (key.escape) {
286
+ setSearchQuery('');
287
+ setSearchResults([]);
288
+ setSearchResultIndex(0);
289
+ setMode('normal');
290
+ return;
291
+ }
292
+ // Navigate search results with arrow keys, Ctrl+j/k, or Ctrl+n/p
293
+ if (key.downArrow || (key.ctrl && (input === 'j' || input === 'n'))) {
294
+ setSearchResultIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0);
295
+ return;
296
+ }
297
+ if (key.upArrow || (key.ctrl && (input === 'k' || input === 'p'))) {
298
+ setSearchResultIndex((prev) => prev > 0 ? prev - 1 : Math.max(0, searchResults.length - 1));
299
+ return;
300
+ }
301
+ // Let TextInput handle other keys
302
+ return;
303
+ }
304
+ if (mode === 'context-filter') {
305
+ if (key.escape) {
306
+ setContextSelectIndex(0);
307
+ setMode('normal');
308
+ return;
309
+ }
310
+ const contextOptions = ['all', 'none', ...availableContexts];
311
+ if (key.upArrow || input === 'k') {
312
+ setContextSelectIndex((prev) => (prev > 0 ? prev - 1 : contextOptions.length - 1));
313
+ return;
314
+ }
315
+ if (key.downArrow || input === 'j') {
316
+ setContextSelectIndex((prev) => (prev < contextOptions.length - 1 ? prev + 1 : 0));
317
+ return;
318
+ }
319
+ if (key.return) {
320
+ const selected = contextOptions[contextSelectIndex];
321
+ if (selected === 'all') {
322
+ setContextFilter(null);
323
+ }
324
+ else if (selected === 'none') {
325
+ setContextFilter('');
326
+ }
327
+ else {
328
+ setContextFilter(selected);
329
+ }
330
+ setContextSelectIndex(0);
331
+ setMode('normal');
332
+ return;
333
+ }
334
+ return;
335
+ }
336
+ if (message)
337
+ setMessage(null);
338
+ // Quit
339
+ if (input === 'q' || (key.ctrl && input === 'c')) {
340
+ exit();
341
+ return;
342
+ }
343
+ // Help
344
+ if (input === '?') {
345
+ setMode('help');
346
+ return;
347
+ }
348
+ // Add task
349
+ if (input === 'a') {
350
+ setMode('add');
351
+ return;
352
+ }
353
+ // Context filter
354
+ if (input === '@') {
355
+ setContextSelectIndex(0);
356
+ setMode('context-filter');
357
+ return;
358
+ }
359
+ // Search
360
+ if (input === '/') {
361
+ setMode('search');
362
+ return;
363
+ }
364
+ // Settings
365
+ if (input === 'T' && onOpenSettings) {
366
+ onOpenSettings('theme-select');
367
+ return;
368
+ }
369
+ if (input === 'V' && onOpenSettings) {
370
+ onOpenSettings('mode-select');
371
+ return;
372
+ }
373
+ if (input === 'L' && onOpenSettings) {
374
+ onOpenSettings('lang-select');
375
+ return;
376
+ }
377
+ // Navigation
378
+ if (paneFocus === 'category') {
379
+ if (key.upArrow || input === 'k') {
380
+ const idx = CATEGORIES.indexOf(selectedCategory);
381
+ setSelectedCategory(CATEGORIES[idx > 0 ? idx - 1 : CATEGORIES.length - 1]);
382
+ setSelectedTaskIndex(0);
383
+ return;
384
+ }
385
+ if (key.downArrow || input === 'j') {
386
+ const idx = CATEGORIES.indexOf(selectedCategory);
387
+ setSelectedCategory(CATEGORIES[idx < CATEGORIES.length - 1 ? idx + 1 : 0]);
388
+ setSelectedTaskIndex(0);
389
+ return;
390
+ }
391
+ if (key.rightArrow || input === 'l' || key.return) {
392
+ if (currentTasks.length > 0) {
393
+ setPaneFocus('tasks');
394
+ }
395
+ return;
396
+ }
397
+ }
398
+ if (paneFocus === 'tasks') {
399
+ if (key.escape || key.leftArrow || input === 'h') {
400
+ setPaneFocus('category');
401
+ return;
402
+ }
403
+ if (key.upArrow || input === 'k') {
404
+ setSelectedTaskIndex((prev) => (prev > 0 ? prev - 1 : currentTasks.length - 1));
405
+ return;
406
+ }
407
+ if (key.downArrow || input === 'j') {
408
+ setSelectedTaskIndex((prev) => (prev < currentTasks.length - 1 ? prev + 1 : 0));
409
+ return;
410
+ }
411
+ // Mark done
412
+ if (input === 'd' && currentTasks.length > 0) {
413
+ const task = currentTasks[selectedTaskIndex];
414
+ markTaskDone(task).then(() => {
415
+ if (selectedTaskIndex >= currentTasks.length - 1) {
416
+ setSelectedTaskIndex(Math.max(0, selectedTaskIndex - 1));
417
+ }
418
+ });
419
+ return;
420
+ }
421
+ // Move right
422
+ if (input === 'm' && currentTasks.length > 0 && selectedCategory !== 'done') {
423
+ const task = currentTasks[selectedTaskIndex];
424
+ moveTaskRight(task).then(() => {
425
+ if (selectedTaskIndex >= currentTasks.length - 1) {
426
+ setSelectedTaskIndex(Math.max(0, selectedTaskIndex - 1));
427
+ }
428
+ });
429
+ return;
430
+ }
431
+ // Undo
432
+ if (input === 'u') {
433
+ history.undo().then((didUndo) => {
434
+ if (didUndo) {
435
+ setMessage(fmt(i18n.tui.undone, { action: history.undoDescription || '' }));
436
+ loadTasks();
437
+ }
438
+ else {
439
+ setMessage(i18n.tui.nothingToUndo);
440
+ }
441
+ });
442
+ return;
443
+ }
444
+ }
445
+ // Refresh
446
+ if (input === 'r' && !key.ctrl) {
447
+ loadTasks();
448
+ setMessage(i18n.tui.refreshed);
449
+ return;
450
+ }
451
+ });
452
+ const tursoEnabled = isTursoEnabled();
453
+ // Help modal overlay
454
+ if (mode === 'help') {
455
+ return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(HelpModal, { onClose: () => setMode('normal'), isKanban: true }) }));
456
+ }
457
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: "FLOQ" }), _jsx(Text, { color: theme.colors.accent, children: " [KANBAN]" }), _jsxs(Text, { color: theme.colors.textMuted, children: [" v", VERSION] }), _jsx(Text, { color: tursoEnabled ? theme.colors.accent : theme.colors.textMuted, children: tursoEnabled ? ' [TURSO]' : ' [LOCAL]' }), contextFilter !== null && (_jsxs(Text, { color: theme.colors.accent, children: [' ', "@", contextFilter === '' ? 'none' : contextFilter] }))] }), _jsx(Text, { color: theme.colors.textMuted, children: "?=help q=quit" })] }), mode === 'context-filter' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: "Filter by context" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: ['all', 'none', ...availableContexts].map((ctx, index) => {
458
+ const label = ctx === 'all' ? 'All' : ctx === 'none' ? 'No context' : `@${ctx}`;
459
+ return (_jsxs(Text, { color: index === contextSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === contextSelectIndex, children: [index === contextSelectIndex ? '▶ ' : ' ', label] }, ctx));
460
+ }) })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { marginRight: 2, children: _jsx(TitledBoxInline, { title: 'カテゴリ', width: leftPaneWidth, minHeight: 5, isActive: paneFocus === 'category', children: CATEGORIES.map((cat) => {
461
+ const isSelected = cat === selectedCategory;
462
+ const count = tasks[cat].length;
463
+ return (_jsxs(Text, { color: isSelected && paneFocus === 'category' ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? '▶ ' : ' ', getCategoryLabel(cat), " (", count, ")"] }, cat));
464
+ }) }) }), _jsx(Box, { flexGrow: 1, children: _jsx(TitledBoxInline, { title: getCategoryLabel(selectedCategory), width: rightPaneWidth, minHeight: 10, isActive: paneFocus === 'tasks', children: currentTasks.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: i18n.tui.noTasks })) : (currentTasks.map((task, index) => {
465
+ const isSelected = paneFocus === 'tasks' && index === selectedTaskIndex;
466
+ return (_jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? '▶ ' : ' ', task.title, task.context && _jsxs(Text, { color: theme.colors.muted, children: [" @", task.context] })] }, task.id));
467
+ })) }) })] })), mode === 'add' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.newTask }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.placeholder })] })), mode === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), mode === 'search' && searchQuery && (_jsx(SearchResults, { results: searchResults, selectedIndex: searchResultIndex, query: searchQuery, viewMode: "kanban" })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textMuted, children: paneFocus === 'category'
468
+ ? 'j/k=select l/Enter=tasks a=add @=filter /=search'
469
+ : 'j/k=select h/Esc=back d=done m=move a=add u=undo /=search' }) })] }));
470
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ interface TitledBoxProps {
3
+ title?: string;
4
+ children: React.ReactNode;
5
+ borderColor?: string;
6
+ minHeight?: number;
7
+ paddingX?: number;
8
+ showShadow?: boolean;
9
+ }
10
+ export declare function TitledBox({ title, children, borderColor, minHeight, paddingX, showShadow, }: TitledBoxProps): React.ReactElement;
11
+ export {};
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text, useStdout } from 'ink';
4
+ import { useTheme } from '../theme/index.js';
5
+ // Round border characters (DQ style)
6
+ const BORDER = {
7
+ topLeft: '╭',
8
+ topRight: '╮',
9
+ bottomLeft: '╰',
10
+ bottomRight: '╯',
11
+ horizontal: '─',
12
+ vertical: '│',
13
+ };
14
+ // Shadow character
15
+ const SHADOW = '░';
16
+ // Calculate display width of string (full-width chars = 2, half-width = 1)
17
+ function getDisplayWidth(str) {
18
+ let width = 0;
19
+ for (const char of str) {
20
+ const code = char.charCodeAt(0);
21
+ // Full-width characters: CJK, full-width forms, etc.
22
+ if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
23
+ (code >= 0x2E80 && code <= 0x9FFF) || // CJK
24
+ (code >= 0xAC00 && code <= 0xD7AF) || // Hangul Syllables
25
+ (code >= 0xF900 && code <= 0xFAFF) || // CJK Compatibility
26
+ (code >= 0xFE10 && code <= 0xFE1F) || // Vertical forms
27
+ (code >= 0xFE30 && code <= 0xFE6F) || // CJK Compatibility Forms
28
+ (code >= 0xFF00 && code <= 0xFF60) || // Full-width forms
29
+ (code >= 0xFFE0 && code <= 0xFFE6) || // Full-width symbols
30
+ (code >= 0x20000 && code <= 0x2FFFF) // CJK Extension B+
31
+ ) {
32
+ width += 2;
33
+ }
34
+ else {
35
+ width += 1;
36
+ }
37
+ }
38
+ return width;
39
+ }
40
+ export function TitledBox({ title, children, borderColor, minHeight = 1, paddingX = 1, showShadow = true, }) {
41
+ const theme = useTheme();
42
+ const { stdout } = useStdout();
43
+ const color = borderColor || theme.colors.border;
44
+ const shadowColor = theme.colors.muted;
45
+ // Get terminal width, account for outer padding and shadow
46
+ const terminalWidth = stdout?.columns || 80;
47
+ const shadowWidth = showShadow ? 1 : 0;
48
+ const boxWidth = terminalWidth - 2 - shadowWidth; // Outer padding + shadow
49
+ const innerWidth = boxWidth - 2; // Subtract left and right border characters
50
+ // Calculate title section widths
51
+ const titleText = title || '';
52
+ const titleLength = getDisplayWidth(titleText);
53
+ // Title format: "─── Title ─────..."
54
+ const leftDashes = 3;
55
+ const titlePadding = titleLength > 0 ? 2 : 0; // spaces around title
56
+ const rightDashes = innerWidth - leftDashes - titlePadding - titleLength;
57
+ // Convert children to array for proper rendering
58
+ const childArray = React.Children.toArray(children);
59
+ const hasContent = childArray.length > 0 && childArray.some(child => child !== null && child !== undefined);
60
+ // Calculate how many rows we need
61
+ const contentRows = hasContent ? childArray.length : 1;
62
+ const emptyRowsNeeded = Math.max(0, minHeight - contentRows);
63
+ // Build content rows
64
+ const contentElements = hasContent ? childArray : [_jsx(Text, { children: " " }, "empty")];
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.topLeft }), titleLength > 0 ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: color, children: [BORDER.horizontal.repeat(leftDashes), " "] }), _jsx(Text, { color: theme.colors.accent, bold: true, children: titleText }), _jsxs(Text, { color: color, children: [" ", BORDER.horizontal.repeat(Math.max(0, rightDashes))] })] })) : (_jsx(Text, { color: color, children: BORDER.horizontal.repeat(innerWidth) })), _jsx(Text, { color: color, children: BORDER.topRight }), showShadow && _jsx(Text, { children: " " })] }), contentElements.map((child, index) => (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Box, { width: innerWidth, paddingX: paddingX, children: _jsx(Box, { flexGrow: 1, children: child }) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, index))), Array.from({ length: emptyRowsNeeded }).map((_, index) => (_jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.vertical }), _jsx(Text, { children: ' '.repeat(innerWidth) }), _jsx(Text, { color: color, children: BORDER.vertical }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }, `empty-${index}`))), _jsxs(Box, { children: [_jsx(Text, { color: color, children: BORDER.bottomLeft }), _jsx(Text, { color: color, children: BORDER.horizontal.repeat(innerWidth) }), _jsx(Text, { color: color, children: BORDER.bottomRight }), showShadow && _jsx(Text, { color: shadowColor, children: SHADOW })] }), showShadow && (_jsx(Box, { children: _jsxs(Text, { color: shadowColor, children: [" ", SHADOW.repeat(boxWidth)] }) }))] }));
66
+ }
@@ -25,6 +25,7 @@ export declare const oceanTheme: Theme;
25
25
  export declare const sakuraTheme: Theme;
26
26
  export declare const msxTheme: Theme;
27
27
  export declare const pc98Theme: Theme;
28
+ export declare const dragonQuestTheme: Theme;
28
29
  export declare const themes: Record<ThemeName, Theme>;
29
30
  export declare const VALID_THEMES: ThemeName[];
30
31
  export declare function getTheme(name: ThemeName): Theme;
@@ -14,7 +14,7 @@ export const modernTheme = {
14
14
  statusSomeday: 'magenta',
15
15
  statusDone: 'gray',
16
16
  text: 'white',
17
- textMuted: 'gray',
17
+ textMuted: '#999999',
18
18
  textSelected: 'cyan',
19
19
  textHighlight: 'green',
20
20
  fnKeyLabel: 'black',
@@ -1001,6 +1001,46 @@ export const pc98Theme = {
1001
1001
  loadingChars: ['●', '○'],
1002
1002
  },
1003
1003
  };
1004
+ // Dragon Quest - Classic JRPG style with titled boxes
1005
+ export const dragonQuestTheme = {
1006
+ name: 'dragon-quest',
1007
+ displayName: 'Dragon Quest',
1008
+ uiStyle: 'titled-box',
1009
+ colors: {
1010
+ primary: '#ffffff',
1011
+ secondary: '#ffcc00',
1012
+ accent: '#ffcc00',
1013
+ muted: '#666666',
1014
+ border: '#ffffff',
1015
+ borderActive: '#ffcc00',
1016
+ background: '#000080',
1017
+ statusInbox: '#ffffff',
1018
+ statusNext: '#00ff00',
1019
+ statusWaiting: '#ffcc00',
1020
+ statusSomeday: '#00ccff',
1021
+ statusDone: '#888888',
1022
+ text: '#ffffff',
1023
+ textMuted: '#aaaaaa',
1024
+ textSelected: '#ffcc00',
1025
+ textHighlight: '#ffcc00',
1026
+ fnKeyLabel: '#000080',
1027
+ fnKeyText: '#ffffff',
1028
+ },
1029
+ borders: {
1030
+ main: 'round',
1031
+ modal: 'round',
1032
+ list: 'round',
1033
+ },
1034
+ style: {
1035
+ selectedPrefix: '▶ ',
1036
+ unselectedPrefix: ' ',
1037
+ tabActiveInverse: true,
1038
+ tabBrackets: ['「', '」'],
1039
+ headerUppercase: false,
1040
+ showFunctionKeys: false,
1041
+ loadingChars: ['●', '○'],
1042
+ },
1043
+ };
1004
1044
  export const themes = {
1005
1045
  'modern': modernTheme,
1006
1046
  'norton-commander': nortonCommanderTheme,
@@ -1028,6 +1068,7 @@ export const themes = {
1028
1068
  'sakura': sakuraTheme,
1029
1069
  'msx': msxTheme,
1030
1070
  'pc-98': pc98Theme,
1071
+ 'dragon-quest': dragonQuestTheme,
1031
1072
  };
1032
1073
  export const VALID_THEMES = [
1033
1074
  'modern',
@@ -1056,6 +1097,7 @@ export const VALID_THEMES = [
1056
1097
  'sakura',
1057
1098
  'msx',
1058
1099
  'pc-98',
1100
+ 'dragon-quest',
1059
1101
  ];
1060
1102
  export function getTheme(name) {
1061
1103
  return themes[name] || modernTheme;
@@ -1,5 +1,6 @@
1
- export type ThemeName = 'modern' | 'norton-commander' | 'dos-prompt' | 'turbo-pascal' | 'classic-mac' | 'apple-ii' | 'commodore-64' | 'amiga-workbench' | 'matrix' | 'amber-crt' | 'phosphor' | 'solarized-dark' | 'solarized-light' | 'synthwave' | 'paper' | 'coffee' | 'nord' | 'dracula' | 'monokai' | 'gruvbox' | 'tokyo-night' | 'catppuccin' | 'ocean' | 'sakura' | 'msx' | 'pc-98';
1
+ export type ThemeName = 'modern' | 'norton-commander' | 'dos-prompt' | 'turbo-pascal' | 'classic-mac' | 'apple-ii' | 'commodore-64' | 'amiga-workbench' | 'matrix' | 'amber-crt' | 'phosphor' | 'solarized-dark' | 'solarized-light' | 'synthwave' | 'paper' | 'coffee' | 'nord' | 'dracula' | 'monokai' | 'gruvbox' | 'tokyo-night' | 'catppuccin' | 'ocean' | 'sakura' | 'msx' | 'pc-98' | 'dragon-quest';
2
2
  export type BorderStyleType = 'single' | 'double' | 'round' | 'bold' | 'singleDouble' | 'doubleSingle' | 'classic';
3
+ export type UIStyleType = 'default' | 'titled-box';
3
4
  export interface ThemeColors {
4
5
  primary: string;
5
6
  secondary: string;
@@ -40,4 +41,5 @@ export interface Theme {
40
41
  colors: ThemeColors;
41
42
  borders: ThemeBorders;
42
43
  style: ThemeStyle;
44
+ uiStyle?: UIStyleType;
43
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "floq",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Floq - Getting Things Done Task Manager with MS-DOS style themes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",