From c245ac093ecd20ba37cb94186fdac066a1403821 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 20:56:29 -0700 Subject: [PATCH 1/9] feat: add cmd-layer context resolution helpers Co-Authored-By: Claude Opus 4.6 --- cmd/context_helpers.go | 49 ++++++++++++++++++++++++++++++++++++ cmd/context_helpers_test.go | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 cmd/context_helpers.go create mode 100644 cmd/context_helpers_test.go diff --git a/cmd/context_helpers.go b/cmd/context_helpers.go new file mode 100644 index 0000000..b2aa07b --- /dev/null +++ b/cmd/context_helpers.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/chris-regnier/diaryctl/internal/context" + "github.com/chris-regnier/diaryctl/internal/entry" +) + +// buildContentProviders creates ContentProviders from config names, skipping unknown. +func buildContentProviders(names []string) []context.ContentProvider { + var providers []context.ContentProvider + for _, name := range names { + p := context.LookupContentProvider(name) + if p != nil { + providers = append(providers, p) + } + } + return providers +} + +// buildContextResolvers creates ContextResolvers from config names, skipping unknown. +func buildContextResolvers(names []string) []context.ContextResolver { + var resolvers []context.ContextResolver + for _, name := range names { + r := context.LookupContextResolver(name) + if r != nil { + resolvers = append(resolvers, r) + } + } + return resolvers +} + +// resolveContexts is a convenience function that builds resolvers from config, +// loads manual contexts, calls ResolveActiveContexts, and prints warnings. +// Returns the resolved context refs. +func resolveContexts() []entry.ContextRef { + resolvers := buildContextResolvers(appConfig.ContextResolvers) + manual, err := context.LoadManualContexts(appConfig.DataDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load manual contexts: %v\n", err) + } + refs, warnings := context.ResolveActiveContexts(resolvers, manual, store) + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", w) + } + return refs +} diff --git a/cmd/context_helpers_test.go b/cmd/context_helpers_test.go new file mode 100644 index 0000000..1f5d188 --- /dev/null +++ b/cmd/context_helpers_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "testing" +) + +func TestBuildContentProviders(t *testing.T) { + // Known providers + providers := buildContentProviders([]string{"datetime", "git"}) + if len(providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(providers)) + } + if providers[0].Name() != "datetime" { + t.Errorf("expected datetime, got %s", providers[0].Name()) + } + if providers[1].Name() != "git" { + t.Errorf("expected git, got %s", providers[1].Name()) + } +} + +func TestBuildContentProvidersSkipsUnknown(t *testing.T) { + providers := buildContentProviders([]string{"datetime", "nonexistent"}) + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } +} + +func TestBuildContentProvidersEmpty(t *testing.T) { + providers := buildContentProviders(nil) + if len(providers) != 0 { + t.Fatalf("expected 0 providers, got %d", len(providers)) + } +} + +func TestBuildContextResolvers(t *testing.T) { + resolvers := buildContextResolvers([]string{"git"}) + if len(resolvers) != 1 { + t.Fatalf("expected 1 resolver, got %d", len(resolvers)) + } + if resolvers[0].Name() != "git" { + t.Errorf("expected git, got %s", resolvers[0].Name()) + } +} + +func TestBuildContextResolversSkipsUnknown(t *testing.T) { + resolvers := buildContextResolvers([]string{"nonexistent"}) + if len(resolvers) != 0 { + t.Fatalf("expected 0 resolvers, got %d", len(resolvers)) + } +} From 6797a7383af944d0e496598239c7cb0e1effafa5 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:04:50 -0700 Subject: [PATCH 2/9] feat: wire context resolution and content composition into create command All entry creation paths (editor, stdin, inline) now resolve active contexts and attach them to entries. The editor path additionally composes content providers into the editor buffer before opening. Co-Authored-By: Claude Opus 4.6 --- cmd/create.go | 11 ++++++++++- cmd/create_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/cmd/create.go b/cmd/create.go index 5c523a5..e9ddf23 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -7,6 +7,7 @@ import ( "strings" "time" + ctxpkg "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/editor" "github.com/chris-regnier/diaryctl/internal/entry" tmpl "github.com/chris-regnier/diaryctl/internal/template" @@ -87,11 +88,15 @@ Use --no-template to skip the default template.`, } } + // Compose content from providers and template + providers := buildContentProviders(appConfig.ContextProviders) + editorContent := ctxpkg.ComposeContent(providers, templateContent) + // Open editor editorCmd := editor.ResolveEditor(appConfig.Editor) var err error var changed bool - content, changed, err = editor.Edit(editorCmd, templateContent) + content, changed, err = editor.Edit(editorCmd, editorContent) if err != nil { fmt.Fprintln(os.Stderr, "Editor error:", err) os.Exit(3) @@ -115,6 +120,9 @@ Use --no-template to skip the default template.`, os.Exit(2) } + // Resolve active contexts + contextRefs := resolveContexts() + now := time.Now().UTC() e := entry.Entry{ ID: id, @@ -122,6 +130,7 @@ Use --no-template to skip the default template.`, CreatedAt: now, UpdatedAt: now, Templates: templateRefs, + Contexts: contextRefs, } if err := store.Create(e); err != nil { diff --git a/cmd/create_test.go b/cmd/create_test.go index 4188ab7..7ec5563 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" tmpl "github.com/chris-regnier/diaryctl/internal/template" @@ -191,3 +192,50 @@ func TestCreateDefaultTemplateMisconfigured(t *testing.T) { t.Errorf("expected ErrNotFound in error, got: %v", err) } } + +func TestCreateInlineWithContextResolution(t *testing.T) { + setupTestEnv(t) + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "test-context", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := store.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if err := context.SetManualContext(appConfig.DataDir, "test-context"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + id, _ := entry.NewID() + now := time.Now().UTC() + refs := resolveContexts() + + e := entry.Entry{ + ID: id, + Content: "Entry with context", + CreatedAt: now, + UpdatedAt: now, + Contexts: refs, + } + if err := store.Create(e); err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context ref, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "test-context" { + t.Errorf("context name = %q, want %q", got.Contexts[0].ContextName, "test-context") + } +} From 22cf7f7d943b0591cd839ced6ac3c89451d473d4 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:08:04 -0700 Subject: [PATCH 3/9] feat: wire context resolution into jot command Co-Authored-By: Claude Opus 4.6 --- cmd/jot.go | 6 ++++ cmd/jot_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/cmd/jot.go b/cmd/jot.go index 0bce32e..2fb6935 100644 --- a/cmd/jot.go +++ b/cmd/jot.go @@ -76,6 +76,12 @@ func jotRun(w io.Writer, content string, templateName string) error { return fmt.Errorf("updating entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + if jsonOutput { return ui.FormatJSON(w, updated) } diff --git a/cmd/jot_test.go b/cmd/jot_test.go index d4374af..4292edd 100644 --- a/cmd/jot_test.go +++ b/cmd/jot_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/chris-regnier/diaryctl/internal/config" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/daily" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" @@ -183,3 +184,90 @@ func TestJotJSONOutput(t *testing.T) { t.Error("expected jot content in JSON") } } + +func TestJotAttachesContextsToNewEntry(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "sprint-1", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "sprint-1"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + err := jotRun(io.Discard, "hello", "") + if err != nil { + t.Fatalf("jotRun: %v", err) + } + + e, _, err := daily.GetOrCreateToday(s, "") + if err != nil { + t.Fatalf("GetOrCreateToday: %v", err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "sprint-1" { + t.Errorf("context = %q, want sprint-1", got.Contexts[0].ContextName) + } +} + +func TestJotAttachesContextsToExistingEntry(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + _, _, err := daily.GetOrCreateToday(s, "") + if err != nil { + t.Fatalf("GetOrCreateToday: %v", err) + } + + ctx := storage.Context{ + ID: "ctx002", + Name: "bugfix", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "bugfix"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + err = jotRun(io.Discard, "fixed the bug", "") + if err != nil { + t.Fatalf("jotRun: %v", err) + } + + e, _, _ := daily.GetOrCreateToday(s, "") + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "bugfix" { + t.Errorf("context = %q, want bugfix", got.Contexts[0].ContextName) + } +} From 076f62f57c9c09816f389c01178b3ae10ac52de6 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:10:29 -0700 Subject: [PATCH 4/9] feat: wire context resolution into today command Co-Authored-By: Claude Opus 4.6 --- cmd/today.go | 12 ++++++++++++ cmd/today_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/cmd/today.go b/cmd/today.go index 508b92e..98b2926 100644 --- a/cmd/today.go +++ b/cmd/today.go @@ -43,6 +43,12 @@ func todayRun(w io.Writer, idOnly bool, contentOnly bool) error { return fmt.Errorf("getting today's entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + if jsonOutput { return ui.FormatJSON(w, e) } @@ -69,6 +75,12 @@ func todayEditRun() error { return fmt.Errorf("getting today's entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + editorCmd := editor.ResolveEditor(appConfig.Editor) content, changed, err := editor.Edit(editorCmd, e.Content) if err != nil { diff --git a/cmd/today_test.go b/cmd/today_test.go index c4fbdfd..020a35e 100644 --- a/cmd/today_test.go +++ b/cmd/today_test.go @@ -7,7 +7,9 @@ import ( "time" "github.com/chris-regnier/diaryctl/internal/config" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/daily" + "github.com/chris-regnier/diaryctl/internal/storage" ) func TestTodayShowsExistingEntry(t *testing.T) { @@ -115,3 +117,43 @@ func TestTodayContentOnly(t *testing.T) { t.Errorf("expected content %q, got %q", e.Content, output) } } + +func TestTodayAttachesContexts(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "daily-work", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "daily-work"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + var buf bytes.Buffer + err := todayRun(&buf, false, false) + if err != nil { + t.Fatalf("todayRun: %v", err) + } + + e, _, _ := daily.GetOrCreateToday(s, "") + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "daily-work" { + t.Errorf("context = %q, want daily-work", got.Contexts[0].ContextName) + } +} From 865018c5ac7d8030a5bcd9c1df6c2bbdedc41617 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:33:22 -0700 Subject: [PATCH 5/9] feat: extend TUI StorageProvider and TUIConfig for context support Co-Authored-By: Claude Opus 4.6 --- cmd/root.go | 11 +++++++---- internal/ui/picker.go | 12 ++++++++---- internal/ui/picker_test.go | 9 +++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index df34ad0..a48ac0a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,10 +63,13 @@ var rootCmd = &cobra.Command{ return todayRun(os.Stdout, false, false) } return ui.RunTUI(store, ui.TUIConfig{ - Editor: editor.ResolveEditor(appConfig.Editor), - DefaultTemplate: appConfig.DefaultTemplate, - MaxWidth: appConfig.MaxWidth, - Theme: ui.ResolveTheme(appConfig.Theme), + Editor: editor.ResolveEditor(appConfig.Editor), + DefaultTemplate: appConfig.DefaultTemplate, + MaxWidth: appConfig.MaxWidth, + Theme: ui.ResolveTheme(appConfig.Theme), + ContextProviders: appConfig.ContextProviders, + ContextResolvers: appConfig.ContextResolvers, + DataDir: appConfig.DataDir, }) }, } diff --git a/internal/ui/picker.go b/internal/ui/picker.go index 7d18666..5334cd0 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -57,6 +57,7 @@ type StorageProvider interface { // Context ListContexts() ([]storage.Context, error) CreateContext(c storage.Context) error + GetContextByName(name string) (storage.Context, error) AttachContext(entryID string, contextID string) error DetachContext(entryID string, contextID string) error @@ -1551,10 +1552,13 @@ func (m pickerModel) startEdit(e entry.Entry) (tea.Model, tea.Cmd) { // TUIConfig holds configuration needed by the TUI. type TUIConfig struct { - Editor string // resolved editor command - DefaultTemplate string // default template name - MaxWidth int // maximum viewport width (0 = no limit) - Theme Theme // resolved theme + Editor string // resolved editor command + DefaultTemplate string // default template name + MaxWidth int // maximum viewport width (0 = no limit) + Theme Theme // resolved theme + ContextProviders []string // content provider names from config + ContextResolvers []string // context resolver names from config + DataDir string // data directory for manual contexts state } // newTUIModel creates a new TUI model starting at the today screen. diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go index fa9b9a4..5377b87 100644 --- a/internal/ui/picker_test.go +++ b/internal/ui/picker_test.go @@ -123,6 +123,15 @@ func (m *mockStorage) CreateContext(c storage.Context) error { return nil } +func (m *mockStorage) GetContextByName(name string) (storage.Context, error) { + for _, c := range m.contexts { + if c.Name == name { + return c, nil + } + } + return storage.Context{}, storage.ErrNotFound +} + func (m *mockStorage) AttachContext(entryID string, contextID string) error { if m.attachError != nil { return m.attachError From 309cca6df929b6d0212f33c266f0ccecb3ca61e5 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:50:49 -0700 Subject: [PATCH 6/9] feat: wire context resolution into TUI doCreateWithEditor Add resolveContexts, buildTUIContentProviders, and buildTUIContextResolvers helper methods. Modify doCreateWithEditor to compose content via providers and attach resolved contexts to newly created entries. Co-Authored-By: Claude Opus 4.6 --- internal/ui/picker.go | 48 ++++++++++++++++++++++++++++++++++++-- internal/ui/picker_test.go | 33 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/internal/ui/picker.go b/internal/ui/picker.go index 5334cd0..61ff8da 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + dctx "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/editor" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" @@ -1319,6 +1320,41 @@ func appendTemplatesCallback(m *pickerModel, names []string) tea.Cmd { } } +// resolveContexts resolves active contexts for the TUI using config. +func (m pickerModel) resolveContexts() []entry.ContextRef { + resolvers := buildTUIContextResolvers(m.cfg.ContextResolvers) + manual, err := dctx.LoadManualContexts(m.cfg.DataDir) + if err != nil { + manual = nil + } + refs, _ := dctx.ResolveActiveContexts(resolvers, manual, m.store) + return refs +} + +// buildTUIContentProviders creates ContentProviders from config names. +func buildTUIContentProviders(names []string) []dctx.ContentProvider { + var providers []dctx.ContentProvider + for _, name := range names { + p := dctx.LookupContentProvider(name) + if p != nil { + providers = append(providers, p) + } + } + return providers +} + +// buildTUIContextResolvers creates ContextResolvers from config names. +func buildTUIContextResolvers(names []string) []dctx.ContextResolver { + var resolvers []dctx.ContextResolver + for _, name := range names { + r := dctx.LookupContextResolver(name) + if r != nil { + resolvers = append(resolvers, r) + } + } + return resolvers +} + func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.TemplateRef) (tea.Model, tea.Cmd) { editorCmd := editor.ResolveEditor(m.cfg.Editor) parts := strings.Fields(editorCmd) @@ -1333,8 +1369,11 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp } tmpName := tmpFile.Name() - if initialContent != "" { - if _, err := tmpFile.WriteString(initialContent); err != nil { + providers := buildTUIContentProviders(m.cfg.ContextProviders) + composedContent := dctx.ComposeContent(providers, initialContent) + + if composedContent != "" { + if _, err := tmpFile.WriteString(composedContent); err != nil { tmpFile.Close() os.Remove(tmpName) m.err = fmt.Errorf("failed to write to temp file: %w", err) @@ -1351,6 +1390,7 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp cmdArgs := append(parts[1:], tmpName) c := exec.Command(parts[0], cmdArgs...) templateRefs := refs + contextRefs := m.resolveContexts() return m, tea.ExecProcess(c, func(err error) tea.Msg { defer os.Remove(tmpName) @@ -1376,10 +1416,14 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp CreatedAt: now, UpdatedAt: now, Templates: templateRefs, + Contexts: contextRefs, } if err := m.store.Create(e); err != nil { return editorFinishedMsg{err: err} } + for _, ref := range contextRefs { + _ = m.store.AttachContext(e.ID, ref.ContextID) + } return editorFinishedMsg{} }) } diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go index 5377b87..4422740 100644 --- a/internal/ui/picker_test.go +++ b/internal/ui/picker_test.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/list" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" ) @@ -1976,3 +1977,35 @@ func TestJotIntoSelectedEntry_TodayScreen(t *testing.T) { t.Error("Jot should NOT have gone to daily entry") } } + +func TestResolveContextsForTUI(t *testing.T) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + ctx1 := storage.Context{ + ID: "ctx001", + Name: "test-ctx", + Source: "manual", + } + mock := &mockStorage{ + entries: map[string][]entry.Entry{today.Format("2006-01-02"): {}}, + byID: map[string]entry.Entry{}, + contexts: []storage.Context{ctx1}, + } + + cfg := TUIConfig{ + Editor: "vi", + DataDir: t.TempDir(), + ContextResolvers: []string{}, + } + m := newTUIModel(mock, cfg) + + context.SetManualContext(cfg.DataDir, "test-ctx") + refs := m.resolveContexts() + if len(refs) != 1 { + t.Fatalf("expected 1 context ref, got %d", len(refs)) + } + if refs[0].ContextName != "test-ctx" { + t.Errorf("context = %q, want test-ctx", refs[0].ContextName) + } +} From 7180612e3b52f376e89d3358408a2ba884ce4065 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 21:51:15 -0700 Subject: [PATCH 7/9] feat: wire context resolution into TUI doJot Attach resolved contexts to entries created or updated via jot in both the existing-entry and new-entry paths. Co-Authored-By: Claude Opus 4.6 --- internal/ui/picker.go | 8 ++++++ internal/ui/picker_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/ui/picker.go b/internal/ui/picker.go index 61ff8da..e892865 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -1502,6 +1502,10 @@ func (m pickerModel) doJot(content string) tea.Msg { if err != nil { return jotCompleteMsg{err: err} } + contextRefs := m.resolveContexts() + for _, ref := range contextRefs { + _ = m.store.AttachContext(m.jotTarget.ID, ref.ContextID) + } } else { // No target — create new daily entry (screenToday with no entries) id, err := entry.NewID() @@ -1532,6 +1536,10 @@ func (m pickerModel) doJot(content string) tea.Msg { if err := m.store.Create(e); err != nil { return jotCompleteMsg{err: err} } + contextRefs := m.resolveContexts() + for _, ref := range contextRefs { + _ = m.store.AttachContext(e.ID, ref.ContextID) + } } return jotCompleteMsg{} diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go index 4422740..6385419 100644 --- a/internal/ui/picker_test.go +++ b/internal/ui/picker_test.go @@ -2009,3 +2009,57 @@ func TestResolveContextsForTUI(t *testing.T) { t.Errorf("context = %q, want test-ctx", refs[0].ContextName) } } + +func TestDoJotAttachesContexts(t *testing.T) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + existingEntry := entry.Entry{ + ID: "entry001", + Content: "# Today", + CreatedAt: today.Add(8 * time.Hour), + UpdatedAt: today.Add(8 * time.Hour), + } + + ctx1 := storage.Context{ + ID: "ctx001", + Name: "test-jot-ctx", + Source: "manual", + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{ + today.Format("2006-01-02"): {existingEntry}, + }, + byID: map[string]entry.Entry{"entry001": existingEntry}, + contexts: []storage.Context{ctx1}, + } + + dataDir := t.TempDir() + cfg := TUIConfig{ + Editor: "vi", + DataDir: dataDir, + ContextResolvers: []string{}, + } + m := newTUIModel(mock, cfg) + m.jotTarget = &existingEntry + + context.SetManualContext(dataDir, "test-jot-ctx") + + msg := m.doJot("test note") + jotMsg, ok := msg.(jotCompleteMsg) + if !ok { + t.Fatalf("expected jotCompleteMsg, got %T", msg) + } + if jotMsg.err != nil { + t.Fatalf("jot error: %v", jotMsg.err) + } + + attached := mock.entryContexts["entry001"] + if len(attached) != 1 { + t.Fatalf("expected 1 attached context, got %d", len(attached)) + } + if attached[0] != "ctx001" { + t.Errorf("attached context ID = %q, want ctx001", attached[0]) + } +} From c282a08ff3b2c24bd6f6f06f9be6d963bdb0cb8e Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 22:10:48 -0700 Subject: [PATCH 8/9] feat: show contexts in TUI entry detail view Co-Authored-By: Claude Opus 4.6 --- internal/ui/picker.go | 7 +++++ internal/ui/picker_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/ui/picker.go b/internal/ui/picker.go index e892865..a049ac3 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -672,6 +672,13 @@ func (m pickerModel) formatEntry() string { fmt.Fprintf(&b, "Entry: %s\n", m.entry.ID) fmt.Fprintf(&b, "Created: %s\n", m.entry.CreatedAt.Local().Format("2006-01-02 15:04")) fmt.Fprintf(&b, "Modified: %s\n", m.entry.UpdatedAt.Local().Format("2006-01-02 15:04")) + if len(m.entry.Contexts) > 0 { + names := make([]string, len(m.entry.Contexts)) + for i, ref := range m.entry.Contexts { + names[i] = ref.ContextName + } + fmt.Fprintf(&b, "Contexts: %s\n", strings.Join(names, ", ")) + } fmt.Fprintln(&b) // Render markdown content as rich text diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go index 6385419..4eb6211 100644 --- a/internal/ui/picker_test.go +++ b/internal/ui/picker_test.go @@ -2063,3 +2063,67 @@ func TestDoJotAttachesContexts(t *testing.T) { t.Errorf("attached context ID = %q, want ctx001", attached[0]) } } + +func TestFormatEntryShowsContexts(t *testing.T) { + now := time.Now() + e := entry.Entry{ + ID: "entry001", + Content: "Hello world", + CreatedAt: now, + UpdatedAt: now, + Contexts: []entry.ContextRef{ + {ContextID: "ctx001", ContextName: "feature/auth"}, + {ContextID: "ctx002", ContextName: "sprint-5"}, + }, + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{}, + byID: map[string]entry.Entry{"entry001": e}, + } + + cfg := TUIConfig{Editor: "vi", Theme: presets["default-dark"]} + m := newTUIModel(mock, cfg) + m.entry = e + + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(pickerModel) + + output := m.formatEntry() + if !strings.Contains(output, "Contexts:") { + t.Error("expected 'Contexts:' in formatted entry") + } + if !strings.Contains(output, "feature/auth") { + t.Error("expected 'feature/auth' in formatted entry") + } + if !strings.Contains(output, "sprint-5") { + t.Error("expected 'sprint-5' in formatted entry") + } +} + +func TestFormatEntryNoContextsOmitsLine(t *testing.T) { + now := time.Now() + e := entry.Entry{ + ID: "entry001", + Content: "Hello world", + CreatedAt: now, + UpdatedAt: now, + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{}, + byID: map[string]entry.Entry{"entry001": e}, + } + + cfg := TUIConfig{Editor: "vi"} + m := newTUIModel(mock, cfg) + m.entry = e + + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(pickerModel) + + output := m.formatEntry() + if strings.Contains(output, "Contexts:") { + t.Error("should not show 'Contexts:' when no contexts are attached") + } +} From cadf181bb6cfde6da6a3e9fa846846eaf6d49703 Mon Sep 17 00:00:00 2001 From: Chris Regnier Date: Tue, 3 Mar 2026 22:12:13 -0700 Subject: [PATCH 9/9] chore: add TODO for CompositeProvider wiring Co-Authored-By: Claude Opus 4.6 --- internal/context/composite.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/context/composite.go b/internal/context/composite.go index f5c6d32..9b288c4 100644 --- a/internal/context/composite.go +++ b/internal/context/composite.go @@ -1,5 +1,9 @@ package context +// TODO: Wire CompositeProvider into the application layer (cmd/root.go or TUI) +// to enable MCP-backed context search and filter capabilities. +// See: docs/plans/2026-03-03-context-wiring-design.md + import ( "context"