floq 0.4.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.
- package/README.ja.md +16 -3
- package/README.md +16 -3
- package/dist/cli.js +12 -1
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +51 -1
- package/dist/commands/list.js +17 -5
- package/dist/config.d.ts +3 -0
- package/dist/config.js +13 -0
- package/dist/i18n/en.d.ts +13 -0
- package/dist/i18n/en.js +7 -0
- package/dist/i18n/ja.js +7 -0
- package/dist/ui/App.js +39 -20
- package/dist/ui/SplashScreen.d.ts +2 -1
- package/dist/ui/SplashScreen.js +109 -10
- package/dist/ui/components/DQLayout.d.ts +36 -0
- package/dist/ui/components/DQLayout.js +53 -0
- package/dist/ui/components/DQTaskList.d.ts +53 -0
- package/dist/ui/components/DQTaskList.js +48 -0
- package/dist/ui/components/DQWindow.d.ts +19 -0
- package/dist/ui/components/DQWindow.js +33 -0
- package/dist/ui/components/GtdDQ.d.ts +7 -0
- package/dist/ui/components/GtdDQ.js +773 -0
- package/dist/ui/components/HelpModal.js +136 -102
- package/dist/ui/components/KanbanBoard.js +10 -6
- package/dist/ui/components/KanbanColumn.js +53 -1
- package/dist/ui/components/KanbanDQ.d.ts +7 -0
- package/dist/ui/components/KanbanDQ.js +470 -0
- package/dist/ui/components/SearchResults.d.ts +2 -1
- package/dist/ui/components/SearchResults.js +22 -2
- package/dist/ui/components/TitledBox.d.ts +11 -0
- package/dist/ui/components/TitledBox.js +66 -0
- package/dist/ui/theme/themes.d.ts +1 -0
- package/dist/ui/theme/themes.js +43 -1
- package/dist/ui/theme/types.d.ts +3 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ interface SearchResultsProps {
|
|
|
4
4
|
results: Task[];
|
|
5
5
|
selectedIndex: number;
|
|
6
6
|
query: string;
|
|
7
|
+
viewMode?: 'gtd' | 'kanban';
|
|
7
8
|
}
|
|
8
|
-
export declare function SearchResults({ results, selectedIndex, query }: SearchResultsProps): React.ReactElement;
|
|
9
|
+
export declare function SearchResults({ results, selectedIndex, query, viewMode }: SearchResultsProps): React.ReactElement;
|
|
9
10
|
export {};
|
|
@@ -2,7 +2,17 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { t } from '../../i18n/index.js';
|
|
4
4
|
import { useTheme } from '../theme/index.js';
|
|
5
|
-
|
|
5
|
+
// Map GTD status to Kanban column
|
|
6
|
+
function getKanbanColumn(status) {
|
|
7
|
+
if (status === 'inbox' || status === 'someday') {
|
|
8
|
+
return 'todo';
|
|
9
|
+
}
|
|
10
|
+
if (status === 'next' || status === 'waiting') {
|
|
11
|
+
return 'doing';
|
|
12
|
+
}
|
|
13
|
+
return 'done';
|
|
14
|
+
}
|
|
15
|
+
export function SearchResults({ results, selectedIndex, query, viewMode = 'gtd' }) {
|
|
6
16
|
const i18n = t();
|
|
7
17
|
const theme = useTheme();
|
|
8
18
|
const search = i18n.tui.search;
|
|
@@ -12,7 +22,17 @@ export function SearchResults({ results, selectedIndex, query }) {
|
|
|
12
22
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, paddingY: 1, minHeight: 5, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["[", search.resultsTitle, "] (", results.length, ")"] }) }), results.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: search.noResults })) : (results.slice(0, 10).map((task, index) => {
|
|
13
23
|
const isSelected = index === selectedIndex;
|
|
14
24
|
const shortId = task.id.slice(0, 8);
|
|
15
|
-
|
|
25
|
+
let displayLabel;
|
|
26
|
+
if (task.isProject) {
|
|
27
|
+
displayLabel = i18n.tui.keyBar.project;
|
|
28
|
+
}
|
|
29
|
+
else if (viewMode === 'kanban') {
|
|
30
|
+
const kanbanColumn = getKanbanColumn(task.status);
|
|
31
|
+
displayLabel = i18n.kanban[kanbanColumn];
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
displayLabel = i18n.status[task.status];
|
|
35
|
+
}
|
|
16
36
|
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", displayLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
|
|
17
37
|
})), results.length > 10 && (_jsxs(Text, { color: theme.colors.textMuted, italic: true, children: ["... and ", results.length - 10, " more"] }))] }));
|
|
18
38
|
}
|
|
@@ -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;
|