Skip to content

Commit 2b857fb

Browse files
authored
Merge pull request #496 from bborn/task/1967-add-cli-command-for-live-tail-view
Add ty tail command for live task view
2 parents 33d0568 + 3428ffe commit 2b857fb

File tree

2 files changed

+229
-0
lines changed

2 files changed

+229
-0
lines changed

cmd/task/main.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,191 @@ that the TUI shows, either as formatted text or JSON for automation.`,
883883
boardCmd.Flags().Int("limit", 5, "Maximum entries to show per column")
884884
rootCmd.AddCommand(boardCmd)
885885

886+
// Tail subcommand - live updating task view grouped by project and status
887+
tailCmd := &cobra.Command{
888+
Use: "tail",
889+
Short: "Live view of tasks organized by project and status",
890+
Long: `Show a continuously updating view of all active tasks,
891+
grouped by project and then by status. Refreshes automatically.
892+
Press Ctrl+C to stop.`,
893+
Run: func(cmd *cobra.Command, args []string) {
894+
interval, _ := cmd.Flags().GetDuration("interval")
895+
showDone, _ := cmd.Flags().GetBool("done")
896+
897+
dbPath := db.DefaultPath()
898+
database, err := db.Open(dbPath)
899+
if err != nil {
900+
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
901+
os.Exit(1)
902+
}
903+
defer database.Close()
904+
905+
sigCh := make(chan os.Signal, 1)
906+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
907+
908+
ticker := time.NewTicker(interval)
909+
defer ticker.Stop()
910+
911+
statusStyle := func(status string) lipgloss.Style {
912+
switch status {
913+
case db.StatusQueued:
914+
return lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B"))
915+
case db.StatusProcessing:
916+
return lipgloss.NewStyle().Foreground(lipgloss.Color("#3B82F6"))
917+
case db.StatusBlocked:
918+
return lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
919+
case db.StatusDone:
920+
return lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981"))
921+
default:
922+
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
923+
}
924+
}
925+
926+
projectStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#A78BFA"))
927+
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E5E7EB"))
928+
929+
statusOrder := []string{
930+
db.StatusProcessing,
931+
db.StatusBlocked,
932+
db.StatusQueued,
933+
db.StatusBacklog,
934+
}
935+
if showDone {
936+
statusOrder = append(statusOrder, db.StatusDone)
937+
}
938+
939+
statusLabels := map[string]string{
940+
db.StatusProcessing: "In Progress",
941+
db.StatusBlocked: "Blocked",
942+
db.StatusQueued: "Queued",
943+
db.StatusBacklog: "Backlog",
944+
db.StatusDone: "Done",
945+
}
946+
947+
logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")).Italic(true)
948+
949+
renderTail := func() {
950+
opts := db.ListTasksOptions{
951+
IncludeClosed: showDone,
952+
Limit: 500,
953+
}
954+
tasks, err := database.ListTasks(opts)
955+
if err != nil {
956+
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
957+
return
958+
}
959+
960+
// Collect task IDs and fetch latest log per task
961+
var taskIDs []int64
962+
for _, t := range tasks {
963+
taskIDs = append(taskIDs, t.ID)
964+
}
965+
latestLogs, _ := database.GetLatestLogPerTask(taskIDs)
966+
if latestLogs == nil {
967+
latestLogs = make(map[int64]*db.TaskLog)
968+
}
969+
970+
// Group by project, then by status
971+
type projectGroup struct {
972+
name string
973+
statuses map[string][]*db.Task
974+
}
975+
projectMap := make(map[string]*projectGroup)
976+
var projectOrder []string
977+
978+
for _, t := range tasks {
979+
if t.Status == db.StatusArchived {
980+
continue
981+
}
982+
proj := t.Project
983+
if proj == "" {
984+
proj = "(no project)"
985+
}
986+
pg, exists := projectMap[proj]
987+
if !exists {
988+
pg = &projectGroup{name: proj, statuses: make(map[string][]*db.Task)}
989+
projectMap[proj] = pg
990+
projectOrder = append(projectOrder, proj)
991+
}
992+
pg.statuses[t.Status] = append(pg.statuses[t.Status], t)
993+
}
994+
995+
sort.Strings(projectOrder)
996+
997+
// Clear screen and move cursor to top
998+
fmt.Print("\033[2J\033[H")
999+
1000+
fmt.Println(headerStyle.Render("TaskYou — Live Tail") + " " + dimStyle.Render(time.Now().Format("15:04:05")))
1001+
fmt.Println(dimStyle.Render(strings.Repeat("─", 60)))
1002+
1003+
if len(tasks) == 0 {
1004+
fmt.Println(dimStyle.Render(" No tasks found"))
1005+
}
1006+
1007+
for i, proj := range projectOrder {
1008+
pg := projectMap[proj]
1009+
// Count total tasks in this project
1010+
total := 0
1011+
for _, tl := range pg.statuses {
1012+
total += len(tl)
1013+
}
1014+
fmt.Printf("\n%s %s\n", projectStyle.Render(proj), dimStyle.Render(fmt.Sprintf("(%d)", total)))
1015+
1016+
for _, status := range statusOrder {
1017+
statusTasks := pg.statuses[status]
1018+
if len(statusTasks) == 0 {
1019+
continue
1020+
}
1021+
label := statusLabels[status]
1022+
fmt.Printf(" %s\n", statusStyle(status).Render(fmt.Sprintf("▸ %s (%d)", label, len(statusTasks))))
1023+
for _, t := range statusTasks {
1024+
title := truncate(t.Title, 70)
1025+
age := boardAgeHint(t)
1026+
line := fmt.Sprintf(" #%-4d %s", t.ID, title)
1027+
if age != "" {
1028+
line += " " + dimStyle.Render(age)
1029+
}
1030+
fmt.Println(line)
1031+
1032+
// Show latest log line if available
1033+
if log, ok := latestLogs[t.ID]; ok && log.Content != "" {
1034+
content := strings.TrimSpace(log.Content)
1035+
// Take only the first line of multi-line content
1036+
if idx := strings.IndexByte(content, '\n'); idx != -1 {
1037+
content = content[:idx]
1038+
}
1039+
content = truncate(content, 80)
1040+
fmt.Printf(" %s\n", logStyle.Render(content))
1041+
}
1042+
}
1043+
}
1044+
1045+
if i < len(projectOrder)-1 {
1046+
fmt.Println()
1047+
}
1048+
}
1049+
1050+
fmt.Println()
1051+
fmt.Println(dimStyle.Render("Press Ctrl+C to stop • refreshing every " + interval.String()))
1052+
}
1053+
1054+
// Render immediately, then on each tick
1055+
renderTail()
1056+
for {
1057+
select {
1058+
case <-sigCh:
1059+
fmt.Println()
1060+
return
1061+
case <-ticker.C:
1062+
renderTail()
1063+
}
1064+
}
1065+
},
1066+
}
1067+
tailCmd.Flags().Duration("interval", 2*time.Second, "Refresh interval (e.g. 1s, 500ms)")
1068+
tailCmd.Flags().Bool("done", false, "Include completed tasks")
1069+
rootCmd.AddCommand(tailCmd)
1070+
8861071
// Show subcommand - show task details
8871072
showCmd := &cobra.Command{
8881073
Use: "show <task-id>",

internal/db/tasks.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,50 @@ func (db *DB) GetTaskLogs(taskID int64, limit int) ([]*TaskLog, error) {
908908
return logs, nil
909909
}
910910

911+
// GetLatestLogPerTask returns the most recent log entry for each of the given task IDs.
912+
// Returns a map of taskID -> latest TaskLog. Uses a single efficient query.
913+
func (db *DB) GetLatestLogPerTask(taskIDs []int64) (map[int64]*TaskLog, error) {
914+
if len(taskIDs) == 0 {
915+
return nil, nil
916+
}
917+
918+
// Build placeholders
919+
placeholders := make([]string, len(taskIDs))
920+
args := make([]interface{}, len(taskIDs))
921+
for i, id := range taskIDs {
922+
placeholders[i] = "?"
923+
args[i] = id
924+
}
925+
926+
query := fmt.Sprintf(`
927+
SELECT tl.id, tl.task_id, tl.line_type, tl.content, tl.created_at
928+
FROM task_logs tl
929+
INNER JOIN (
930+
SELECT task_id, MAX(id) as max_id
931+
FROM task_logs
932+
WHERE task_id IN (%s)
933+
GROUP BY task_id
934+
) latest ON tl.id = latest.max_id
935+
`, strings.Join(placeholders, ","))
936+
937+
rows, err := db.Query(query, args...)
938+
if err != nil {
939+
return nil, fmt.Errorf("query latest logs: %w", err)
940+
}
941+
defer rows.Close()
942+
943+
result := make(map[int64]*TaskLog)
944+
for rows.Next() {
945+
l := &TaskLog{}
946+
err := rows.Scan(&l.ID, &l.TaskID, &l.LineType, &l.Content, &l.CreatedAt)
947+
if err != nil {
948+
return nil, fmt.Errorf("scan latest log: %w", err)
949+
}
950+
result[l.TaskID] = l
951+
}
952+
return result, nil
953+
}
954+
911955
// GetConversationHistoryLogs retrieves only logs relevant for building conversation history.
912956
// This is much more efficient than GetTaskLogs for the executor's prompt building,
913957
// as it skips large output/tool log content that isn't needed for conversation context.

0 commit comments

Comments
 (0)