diff --git a/src/config/segment_types.go b/src/config/segment_types.go index 7ac77d10c..0ec281e2c 100644 --- a/src/config/segment_types.go +++ b/src/config/segment_types.go @@ -127,6 +127,7 @@ func init() { gob.Register(&segments.Swift{}) gob.Register(&segments.SystemInfo{}) gob.Register(&segments.TalosCTL{}) + gob.Register(&segments.Taskwarrior{}) gob.Register(&segments.Tauri{}) gob.Register(&segments.Terraform{}) gob.Register(&segments.Text{}) @@ -338,6 +339,8 @@ const ( SYSTEMINFO SegmentType = "sysinfo" // TALOSCTL writes the talosctl context TALOSCTL SegmentType = "talosctl" + // TASKWARRIOR writes Taskwarrior task counts and context + TASKWARRIOR SegmentType = "taskwarrior" // Tauri Segment TAURI SegmentType = "tauri" // TERRAFORM writes the terraform workspace we're currently in @@ -470,6 +473,7 @@ var Segments = map[SegmentType]func() SegmentWriter{ SWIFT: func() SegmentWriter { return &segments.Swift{} }, SYSTEMINFO: func() SegmentWriter { return &segments.SystemInfo{} }, TALOSCTL: func() SegmentWriter { return &segments.TalosCTL{} }, + TASKWARRIOR: func() SegmentWriter { return &segments.Taskwarrior{} }, TAURI: func() SegmentWriter { return &segments.Tauri{} }, TERRAFORM: func() SegmentWriter { return &segments.Terraform{} }, TEXT: func() SegmentWriter { return &segments.Text{} }, diff --git a/src/segments/taskwarrior.go b/src/segments/taskwarrior.go new file mode 100644 index 000000000..dadc07785 --- /dev/null +++ b/src/segments/taskwarrior.go @@ -0,0 +1,69 @@ +package segments + +import ( + "strings" + + c "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/jandedobbeleer/oh-my-posh/src/log" + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" +) + +// Taskwarrior option constants +const ( + TaskwarriorCommand options.Option = "command" + TaskwarriorCommands options.Option = "commands" +) + +// Taskwarrior displays task counts and context from Taskwarrior. +// The Commands field is a map from capitalized command name to the raw output +// of the corresponding Taskwarrior invocation. Each entry in the config map +// has the command name as key and a full Taskwarrior argument string as value. +type Taskwarrior struct { + Base + + // Commands holds the raw output of each configured command, keyed by name + // with the first letter uppercased. + Commands map[string]string +} + +func (t *Taskwarrior) Template() string { + return " \uf4a0 {{ range $k, $v := .Commands }}{{ $k }}:{{ $v }} {{ end }}" +} + +func (t *Taskwarrior) Enabled() bool { + cmd := t.options.String(TaskwarriorCommand, "task") + + if !t.env.HasCommand(cmd) { + return false + } + + defaultCommands := map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context", + } + + configuredCommands := t.options.KeyValueMap(TaskwarriorCommands, defaultCommands) + + t.Commands = make(map[string]string, len(configuredCommands)) + + for name, args := range configuredCommands { + key := c.Title(language.English).String(name) + t.Commands[key] = t.runCommand(cmd, args) + } + + return true +} + +func (t *Taskwarrior) runCommand(cmd, args string) string { + output, err := t.env.RunCommand(cmd, strings.Fields(args)...) + if err != nil { + log.Error(err) + return "" + } + + return strings.TrimSpace(output) +} diff --git a/src/segments/taskwarrior_test.go b/src/segments/taskwarrior_test.go new file mode 100644 index 000000000..edd58e83e --- /dev/null +++ b/src/segments/taskwarrior_test.go @@ -0,0 +1,184 @@ +package segments + +import ( + "errors" + "strings" + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/runtime/mock" + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" + + "github.com/stretchr/testify/assert" +) + +func TestTaskwarrior(t *testing.T) { + cases := []struct { + ConfiguredCommands map[string]string + CommandOutputs map[string]string + CommandErrors map[string]error + ExpectedCommands map[string]string + Case string + Command string + HasCommand bool + ExpectedEnabled bool + }{ + { + Case: "happy path default commands", + HasCommand: true, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "3", + "+PENDING scheduled.before:tomorrow count": "1", + "+WAITING count": "2", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "3", + "Scheduled": "1", + "Waiting": "2", + "Context": "", + }, + }, + { + Case: "no command", + HasCommand: false, + ExpectedEnabled: false, + }, + { + Case: "all zeros", + HasCommand: true, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "0", + "+PENDING scheduled.before:tomorrow count": "0", + "+WAITING count": "0", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "0", + "Scheduled": "0", + "Waiting": "0", + "Context": "", + }, + }, + { + Case: "custom commands only", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "urgent": "+PENDING +OVERDUE count", + }, + CommandOutputs: map[string]string{ + "+PENDING +OVERDUE count": "5", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Urgent": "5", + }, + }, + { + Case: "context command via commands map", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "context": "_get rc.context", + }, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "3", + "_get rc.context": "work", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "3", + "Context": "work", + }, + }, + { + Case: "command error returns empty string for that command", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + }, + CommandErrors: map[string]error{ + "+PENDING due.before:tomorrow count": errors.New("command failed"), + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "", + }, + }, + { + Case: "custom executable is used when TaskwarriorCommand is set", + Command: "task2", + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + }, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "7", + }, + HasCommand: true, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "7", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.Case, func(t *testing.T) { + env := new(mock.Environment) + + cmd := tc.Command + if cmd == "" { + cmd = "task" + } + + env.On("HasCommand", cmd).Return(tc.HasCommand) + + configuredCommands := tc.ConfiguredCommands + if configuredCommands == nil && tc.HasCommand { + // default commands + configuredCommands = map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context", + } + } + + for _, args := range configuredCommands { + splitArgs := splitTaskArgs(args) + var output string + var err error + if tc.CommandOutputs != nil { + output = tc.CommandOutputs[args] + } + if tc.CommandErrors != nil { + err = tc.CommandErrors[args] + } + env.On("RunCommand", cmd, splitArgs).Return(output, err) + } + + props := options.Map{} + if tc.ConfiguredCommands != nil { + props[TaskwarriorCommands] = tc.ConfiguredCommands + } + if tc.Command != "" { + props[TaskwarriorCommand] = tc.Command + } + + tw := &Taskwarrior{} + tw.Init(props, env) + + assert.Equal(t, tc.ExpectedEnabled, tw.Enabled(), tc.Case) + + if !tc.ExpectedEnabled { + return + } + + assert.Equal(t, tc.ExpectedCommands, tw.Commands, tc.Case) + }) + } +} + +// splitTaskArgs splits a space-separated argument string into a slice. +func splitTaskArgs(s string) []string { + return strings.Fields(s) +} diff --git a/themes/schema.json b/themes/schema.json index 8343451c8..603b61144 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -454,6 +454,7 @@ "swift", "sysinfo", "talosctl", + "taskwarrior", "tauri", "terraform", "text", @@ -4476,6 +4477,46 @@ } } }, + { + "if": { + "properties": { + "type": { + "const": "taskwarrior" + } + } + }, + "then": { + "title": "Taskwarrior Segment", + "description": "https://ohmyposh.dev/docs/segments/cli/taskwarrior", + "properties": { + "options": { + "properties": { + "command": { + "type": "string", + "title": "Taskwarrior Command", + "description": "The taskwarrior command to use", + "default": "task" + }, + "commands": { + "type": "object", + "title": "Commands", + "description": "Map of name to Taskwarrior arguments; the trimmed stdout of each invocation is exposed as .Commands. in the template", + "additionalProperties": { + "type": "string" + }, + "default": { + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context" + } + } + }, + "unevaluatedProperties": false + } + } + } + }, { "if": { "properties": { diff --git a/website/docs/segments/cli/taskwarrior.mdx b/website/docs/segments/cli/taskwarrior.mdx new file mode 100644 index 000000000..333e86e74 --- /dev/null +++ b/website/docs/segments/cli/taskwarrior.mdx @@ -0,0 +1,93 @@ +--- +id: taskwarrior +title: Taskwarrior +sidebar_label: Taskwarrior +--- + +## What + +Display [Taskwarrior][taskwarrior] task data for configurable commands. Each named command runs +`task` with the specified arguments and exposes the raw output in the template. + +## Sample Configuration + +import Config from "@site/src/components/Config.js"; + + + +## Options + +| Name | Type | Default | Description | +| ---------- | :-----------------: | :-------: | ------------------------------------------------------------------------------ | +| `command` | `string` | `task` | the Taskwarrior executable to use | +| `commands` | `map[string]string` | see below | map of name to Taskwarrior arguments; the raw output is exposed in `.Commands` | + +### Default `commands` value + +```json +{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context" +} +``` + +Each entry runs `task ` and stores the trimmed stdout as a string. Remove entries +you do not need to keep prompt rendering fast. + +## Template ([info][templates]) + +:::note default template + +```template + {{ "\uf4a0" }} {{ range $k, $v := .Commands }}{{ $k }}:{{ $v }} {{ end }} +``` + +::: + +### Properties + +| Name | Type | Description | +| ------------ | ------------------- | ------------------------------------------------------------------------------------ | +| `.Commands` | `map[string]string` | raw command output keyed by name with the first letter uppercased (e.g. `"Due"`) | + +### Examples + +Access a specific command result directly: + +```template + {{ "\uf4a0" }} Due: {{ .Commands.Due }} +``` + +Display multiple results: + +```template + {{ "\uf4a0" }} Due: {{ .Commands.Due }} | Waiting: {{ .Commands.Waiting }} +``` + +Include the active context alongside task counts: + +```template + {{ "\uf4a0" }} {{ .Commands.Context }} - Due: {{ .Commands.Due }} Scheduled: {{ .Commands.Scheduled }} +``` + +[templates]: /docs/configuration/templates +[taskwarrior]: https://taskwarrior.org diff --git a/website/sidebars.js b/website/sidebars.js index ad5082004..508de6ec4 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -79,6 +79,7 @@ export default { "segments/cli/react", "segments/cli/svelte", "segments/cli/talosctl", + "segments/cli/taskwarrior", "segments/cli/tauri", "segments/cli/terraform", "segments/cli/ui5tooling",